Contracts and their parsers

Most document formats serve one audience. A gateline contract under contracts/ serves two: an agent writes the artifact, another agent parses it, and a human reads the result well enough to approve or refuse a phase of work. The interesting engineering is not the parser. It is deciding, shape by shape, what a machine must read, what a human must check, and what happens when an instance is malformed.

Prose as a wire format

The rule that organizes everything here is bounce, never guess. An artifact missing a required section is malformed, and the consuming agent's first duty is to bounce it back to its producer, not to infer what the author meant.

That is a stronger commitment than it looks. A tolerant parser is normally a virtue: accept what you can, recover, keep going. Here tolerance is the failure mode. An agent that fills in a missing ## Coverage section with its own summary of what it believes was reviewed has manufactured evidence for a gate a human is about to approve. Refusing costs a re-dispatch; guessing costs a record nobody can trust.

So a contract is written in two registers at once. Some of what it declares is a grammar: literal shapes with regular expressions behind them. Some is a readability rule only a reader can evaluate. Both are normative, both are bounceable, and telling them apart is most of the skill.

What a contract actually declares

There are ten contracts. Each is a short markdown or YAML template whose normative part is its comments and its headings: a leading block governing the artifact as a whole, per-section comments carrying caps and rules of their own, and the ## headings themselves, which are the required-section list. What fills those sections is an example instance. A template can declare up to five kinds of rule, and — the part newcomers over-generalize — most declare only some.

Required sections
The template's own ## headings. Every markdown contract has these, and they are what the validator checks, and only on the artifacts a contract covers.
BUDGET
Concision as a contract property, not a style hope: reference upstream artifacts by number, paste failing output in full and passing output in summary. Seven of the ten declare one.
GRAMMAR
Literal id and heading shapes, marked "normative — tooling parses" and closed by "A deviation is a malformed artifact." Only contracts/spec.md, contracts/plan.md, contracts/verification-report.md and contracts/release-plan.md declare one.
READABILITY
Rules on the sections a gate human reads as prose. Five contracts declare one, and they do not all declare the same one.
AUDIENCE
One line naming the sections that are audit-time evidence rather than decide-time reading, as <section>=audit pairs. extractAudience in validate.ts parses it and Gatehouse folds those sections to their heading. The line is contract meaning rather than a UI setting. spec.md and review-report.md carry one.

Two contracts add a block of their own. review-report.md's VERIFY ROUND fixes the shape a later round uses to disposition earlier findings, and verification-report.md's VERDICT block fixes the one-word verdict line.

The ID grammars, and who parses them

Four GRAMMAR blocks declare eight shapes between them. Requirement headings in a spec are ### R<n> — <short name>. Acceptance criteria are list items whose text begins AC<n>.<m> — . Architecture decisions in a plan are ### ADR-<n>[ (<qualifier>)]: <decision>. Evidence blocks in a verification report are ### E<k> — AC<n>.<m>, with the report's Results table carrying the bare criterion id in its first column and one overall **Verdict:** pass | fail | escalate line. A release plan's preamble fields (**Change released:** and three more bold labels) and its numbered Release steps are the newest two.

Those shapes exist so a run's artifacts can be read as a graph, not as text. buildLexicon in packages/core/src/view-model/lexicon.ts turns a run's own spec.md and plan.md into definitions Gatehouse serves to the browser, and buildEvidenceRollup in the sibling evidence.ts answers one question: for each criterion the spec defines, where does evidence cite it? Both are deliberately non-judgmental: a lexicon entry is the exact artifact bytes, "never a paraphrase", and the rollup computes presence, never verdicts.

That job (locate, never summarize) shows up in small decisions. Nearly every scanner in core skips fenced code blocks, so a heading quoted inside a fence is not mistaken for a real one. scanIds in lexicon.ts deliberately does not, and says why in a comment: evidence blocks cite criteria from inside fences. It is not the only scanner that reads through a fence (the review-mention loop in evidence.ts carries no fence state either, unlike the verification-report loop earlier in the same function), but it is the only one that does so on purpose, with its reason attached. The engine's own verdict reader, parseReviewReport in packages/orchestrator/src/review-report.ts, reads through fences as well. The contract's fenced round-two example carries a **Verdict:** line, so the two verdict readers disagree on that line: verdictLines in core skips it and the engine counts it.

Now the part the reference page cannot fit: the shapes are not equally covered, and the coverage does not line up with what the contracts declare.

Every contract shape against two independent questions: does a contract declare it normative, and does any code parse it? The two columns disagree in both directions.
A two-by-two grid. Columns: declared normative, not declared. Rows: parsed, no parser. Top-left holds the ten shapes the contracts declare and the code parses, including the two review-report finding shapes the VERIFY ROUND block declares. Top-right, marked in the accent hue, holds the two review-report shapes code parses although no block declares them. Bottom-right is empty: the finding heading that sat there is parsed now. Bottom-left holds the READABILITY and BUDGET rules only a human validates, and the release-plan preamble fields a GRAMMAR block declares but no code reads. A dotted tie-line links the two shapes, which both live in contracts/review-report.md. declared normative a normative block says so not declared template text only parsed code reads this shape no parser the contract and the code agree ### R<n> — <short name> lexicon.ts · pr-description.ts AC<n>.<m> — list items lexicon.ts · evidence.ts ### ADR-<n>[ (<qualifier>)]: <decision> lexicon.ts ### E<k> — AC<n>.<m> evidence.ts (not lexicon.ts) Results-table row keyed by bare AC<n>.<m> evidence.ts required ## sections validate.ts work-item.yaml top-level keys validate.ts state.yaml body schema.ts ### F<n> — <severity> — <title> review.ts — declared by VERIFY ROUND, normative from round 2 - **F<n> — resolved|stands** — … review.ts — the two words VERIFY ROUND says tooling reads nothing tells the author # Review Report: <task id> parseReviewReport (orchestrator/review-report.ts) **Verdict:** approve | request-changes | escalate parseReviewReport · parseReview (core/view-model/review.ts) the engine routes on the verdict — no block declares these a human is the validator READABILITY (a)–(e) spec · plan · verification-report · release-plan — five rules READABILITY (a)–(c) review-report.md — three rules; (b) mandates a table BUDGET rules seven of ten contracts **Change released:** … **Rollback exercised:** release-plan.md — a GRAMMAR block, and no parser reads it bounced by a reader, with the rule cited convention only empty: the finding heading that sat here is parsed now only the ## Findings heading is what the validator checks. both live in contracts/review-report.md "normative" and "parsed" are independent properties The same four quadrants stacked for a narrow screen, in the order declared-and-parsed, parsed-but-not-declared, no-parser-and-not-declared, declared-but-no-parser. Each block restates its axis condition and keeps its own header, entries and caption, and the dotted tie-line still links the two shapes that live in contracts/review-report.md. the contract and the code agree declared normative · parsed ### R<n> — <short name> lexicon.ts · pr-description.ts AC<n>.<m> — list items lexicon.ts · evidence.ts ### ADR-<n>[ (<qualifier>)]: <decision> lexicon.ts ### E<k> — AC<n>.<m> evidence.ts (not lexicon.ts) Results-table row keyed by bare AC<n>.<m> evidence.ts required ## sections validate.ts work-item.yaml top-level keys validate.ts state.yaml body schema.ts ### F<n> — <severity> — <title> review.ts — declared by VERIFY ROUND, normative from round 2 - **F<n> — resolved|stands** — … review.ts — the two words VERIFY ROUND says tooling reads nothing tells the author parsed · not declared # Review Report: <task id> parseReviewReport (orchestrator/review-report.ts) **Verdict:** approve | request-changes | escalate parseReviewReport · parseReview (core/view-model/review.ts) the engine routes on the verdict — no block declares these both live in contracts/review-report.md convention only no parser · not declared empty: the finding heading that sat here is parsed now only the ## Findings heading is what the validator checks. a human is the validator declared normative · no parser READABILITY (a)–(e) spec · plan · verification-report · release-plan — five rules READABILITY (a)–(c) review-report.md — three rules; (b) mandates a table BUDGET rules seven of ten contracts **Change released:** … **Rollback exercised:** release-plan.md — a GRAMMAR block, and no parser reads it bounced by a reader, with the rule cited "normative" and "parsed" are independent properties

Normative and parsed are independent properties

Three cells of that two-by-two are occupied, and the empty one is recent. Most shapes are declared and parsed. Some rules are normative with no code behind them: the READABILITY family is the clearest case, enforced by a human reviewer citing the rule. And some shapes are parsed without ever being declared, which is the dangerous quadrant, because nothing tells an author they have broken one. Some artifacts sit outside the grid altogether: retro.md, docs-delta.md and integration-profile.md get no contract back from contractFor, and a null contract validates trivially.

contracts/review-report.md is where this collides. It declares BUDGET, an ESCALATE SCOPE clause, READABILITY, AUDIENCE and a VERIFY ROUND block, and no block labelled GRAMMAR. The F<n> finding shape its own template demonstrates was for a long time a convention read by no code. It is parsed now: parseReview in packages/core/src/view-model/review.ts reads the finding headings and their severities, the **Round:** lines, and the - **F<n> — resolved|stands** dispositions a later round appends, and the evidence rollup uses it to attribute findings to criteria. It is declared now as well: the VERIFY ROUND block, normative from round two, spells out both the finding heading and the disposition line, and says tooling reads exactly the two disposition words. What the engine reads is the header line # Review Report: <task id> and every **Verdict:** line, which no block declares normative; VERIFY ROUND does not mention either.

The cleanest example of a declared shape with no parser now lives in contracts/release-plan.md. Its GRAMMAR block makes the bold preamble fields and the numbered Release steps normative, and nothing in the repository reads either. Only its ## headings are validated.

Those two undeclared shapes carry more operational weight than any declared one. parseReviewReport binds a report to its task by the header, not the filename, because repositories number review files inconsistently, and treats the last **Verdict:** line as the live one, because rounds append and never overwrite. The engine routes on that value (approve moves the task to review-approved, escalate waits for a human), and the count of verdict lines is what review_rounds is reconciled against, the number that decides whether a fourth round becomes an escalation. A human who resolves that escalation grants the loop one more round.

The asymmetry tells you where to route a fix. If an artifact breaks a declared shape, the artifact is wrong and the contract is doing its job. If code depends on a shape no contract declares, the contract is incomplete, and reaching for the parser first only entrenches the gap.

READABILITY is two rule sets, not one

The second thing newcomers over-generalize is READABILITY. It is easy to read one contract's block and assume a house style. It is not one: the rules follow what each section is for.

Four contracts declare the same five-rule set, governing the sections a gate human reads as argument: Context in a spec, Approach and each ADR's Rejected and Consequences lines in a plan, Beyond the happy path and Gaps in a verification report, and CI health, Rollback plan, Verification after release and Blast radius in a release plan.

  1. The first sentence states the takeaway in plain words — no code spans, paths, or parenthetical cites.
  2. One idea per paragraph: at most four sentences and 120 words each.
  3. Three or more parallel items become a bulleted list under a lead-in sentence, never a semicolon chain.
  4. One claim per sentence; never join clauses with a semicolon.
  5. Name before cite: give any id or file a noun phrase on first use, at most one parenthetical file:line cite per sentence, full path at first mention only.

contracts/review-report.md governs one section, Coverage, and declares three different rules:

  1. Open with one plain-words sentence stating overall coverage — no code spans, paths, or parenthetical cites.
  2. Then the Coverage table (shape below): one row per requirement or area checked. The table is the shape — a bullet list or a paragraph in its place is in breach. Cites live in the Where column, one location per row; the Mechanism column says what was checked in a clause, never a chain of clauses.
  3. Name before cite: give any id or file a noun phrase on first use in the opening sentence.

What Coverage does not impose matters as much. It has no one-idea-per-paragraph rule, no four-sentence limit, no three-parallel-items rule, no one-claim-per-sentence rule, and no full-path clause. In their place it prescribes a shape (a four-column table, one row per requirement or area) and moves every cite into a column of its own, one location per row.

That is coherent once you ask what each section is for. A spec's Context is an argument a G0 approver reads to decide whether the problem is understood, so its rules shape argument: one idea, one claim, plain first sentence. A Coverage section is an inventory a G2 approver scans for what was not checked, and an inventory in flowing prose is unscannable, so its contract demands the table and forbids both the list and the paragraph. The two even name their sanctions differently: a spec's breach is "bounced like a grammar deviation," while a review report, having no GRAMMAR block to point at, says a breach is "bounced like a malformed finding."

One consequence: the summary in AGENTS.md (plain-words opening sentence, one idea per paragraph, lists instead of semicolon chains, name before cite) describes the five-rule set accurately and is not true of Coverage. When a summary and a contract disagree, the contract governs.

Evolving a contract without breaking what is committed

A format with committed instances going back through every finished run has an obvious hazard: tighten it and you retroactively invalidate history that must not be edited. Three mechanisms carry the weight.

First, the contract is the authority at read time, not a compiled copy. validateArtifact derives required sections from the target repository's own contracts/*.md, fetched from the default branch by ref. BUILTIN_SECTIONS in packages/core/src/record/validate.ts is a labelled fallback for repositories carrying runs/ but no contracts/ tree, and its docblock hedges that it mirrors the contracts "at the time of writing." Amend a contract and every consumer's required-section list moves with it.

Second, matching is deliberately forgiving where forgiveness is harmless. Section names are lowercased with every non-alphanumeric run collapsed to a single space before comparison, so Requirement → task mapping matches a heading typed with a plain hyphen. Punctuation drift is not a defect worth bouncing; the section being absent is.

Third, where a prose contract states a rule the machine must also enforce, the mirror is explicit and fails closed. docs/DESIGN.md §4.1 defines patch | standard | full as fixed sets; schema.ts mirrors that in PROFILE_GATES and PROFILE_PHASES, and runStateSchema requires every gate the declared profile carries to be present. Presence is what it enforces, not exclusivity: a surplus entry left over from a heavier profile parses without complaint, and profile-aware consumers filter it out. The defaults keep older records meaningful and newer fields additive: a state.yaml with no profile: field parses as full, an absent gate entry parses as undecided and "can never masquerade as approved," and unknown keys pass through untouched.

Spot the malformed artifact

Two cases, in ascending order of how long they take to notice.

Case 1 — a missing section

A spec should carry four sections. Here is the shape, reduced:

# Specification: slug collision handling

## Context

The run scaffolder mints a branch name from the slug and assumes it is free.
Two operators staging runs in the same minute can pick the same slug.

## Requirements

### R1 — Slug collisions fail closed

**Acceptance criteria:**
- [ ] AC1.1 — `gateline new` on an existing slug exits non-zero and mints no branch

## Assumptions

- **ASSUMPTION:** the slug is operator-supplied → resolved as validate at stage
  time, because the branch name is derived from it

## Out of scope

Renaming a run that is already staged.

Now delete the ## Context heading and its two sentences. Nothing looks broken: the requirement is still there, its criterion still parses, and a careless consumer could reconstruct the problem statement from the requirements underneath. That inference is what the system forbids.

What happens instead is mechanical. validateArtifact compares the spec's ## headings against those of contracts/spec.md and returns Context in its missing list. The orchestrator's derivation sees an invalid artifact and emits its D7 action: re-dispatch the analyst, with the missing sections named in the dispatch reason and in the commit subject: state(<slug>): bounced spec.md — re-dispatching analyst (missing: Context). Later ticks read those subjects back to count bounces per artifact. On the third malformed delivery the bounce cap of two is reached and the derivation switches to D8: escalate and pause, on the reasoning that an artifact bounced twice is a contract dispute, not a retry that will converge. Resolving that escalation resets the count, so the next dispute over the same artifact starts with the same two bounces.

Note what the check does not do. It never looks inside ## Requirements, and with one exception it never reads a section's body. The exception is the verification report, whose last **Verdict:** line the validator checks against the three permitted words and reports as missing otherwise. Everywhere else a Context section whose first sentence reads "scaffold.ts mints the branch name (line 115)" satisfies the validator completely and breaches the first READABILITY rule outright, with a code span and a parenthetical cite both barred from that sentence. Only a reader catches it, and the sanction is the same bounce with the rule cited.

Change the artifact class and a missing section is caught somewhere else entirely. The validator does run over review reports, but its result has two readers in the engine, and both look only at the artifacts a gate's producing role owns: spec, plan, work items, verification report, release plan. The reviewer is not among them. A review report missing ## Coverage but carrying **Verdict:** approve therefore advances its task to review-approved with no bounce at all.

What closes that gap is the frontend, not the engine. Review files are part of the G2 gate packet, and a malformed one turns that gate's inbox card into Packet malformed — bounced, not reviewable, with the missing section named. So the same word names two mechanisms at two layers: for a producer's artifact, engine-side re-dispatch, automatic and counted; for a review report, a human gate declining to open. Know which half is mechanised before you go looking for a bounce commit that will not exist.

Case 2 — the wrong dash

This one is a single character. A spec writes:

### R1 - Slug collisions fail closed

An ASCII hyphen where the contract's grammar specifies an em dash. The required-section check passes, because ## Requirements is present and the heading inside it is never inspected. Nothing bounces. The pull-request description generator still renders the requirement, because requirementNames in pr-description.ts accepts an em dash or a hyphen and requires exactly three hash marks. But R_HEADING in lexicon.ts, which accepts any heading level and only an em dash, does not match, so R1 has no lexicon entry, and Gatehouse cannot show its definition.

Do the same to an acceptance criterion (AC2.1 - …) and the damage travels further, because the evidence rollup seeds its criteria from the lexicon. An unparsed criterion is not "uncited"; it is unknown. If the verification report's Results table cites AC2.1 by its bare id, the rollup creates the row anyway and marks it defined nowhere. That flag is the tell: a criterion cited by the record but present in no spec almost always means a grammar deviation upstream.

Case 1 is a contract doing its job. Case 2 is the shape of every bug in this area you will chase: a rule with no validator behind it, breached by an author who had no way to find out.

Maintainer note

I should be straight about how much of the bounce path is proven. The machinery is designed, implemented and covered by fixtures, but across the three human-orchestrated runs that took the pipeline end to end, not one artifact arrived malformed. The mdtoc retro records "no D7 bounce was exercised," and dupefind's "three runs, zero D7 bounces — bounce handling remains fixture-only evidence." Supported is not exercised, and I would rather say which one this is.

Check your understanding

  1. contracts/review-report.md declares no GRAMMAR block. Name the shapes code parses anyway, and say which contract now holds a shape declared normative that no code reads.
  2. A spec writes ### R1 - Slug collisions with an ASCII hyphen. Which parser sees the requirement, which does not, and what bounces?
  3. The Coverage section of a review report: which of the five READABILITY rules does its contract not impose, and what does it demand instead?
  4. An artifact is bounced twice and arrives malformed a third time. What does the engine do, and why is that better than a longer retry?

Further reading

  • Contract grammar reference: the exact shapes and the contract-by-contract table.
  • contracts/ in the framework repository: ten files, all short. Read spec.md and review-report.md back to back; the contrast is the whole lesson.
  • docs/DESIGN.md §5 for the contract model, and the "Invariants — check before editing" section of AGENTS.md.
  • The co-written state file: the commit-message grammar as evidence for a human, not input to a decision, and the two parsers in the codebase that read commit subjects.
  • How a run works for where each artifact sits in the G0–G3 lifecycle.