# The API contract snapshot

`test/contract/` proves the API *behaves*. It cannot prove the API *still looks
the same*: a route quietly renamed, a route's request field dropped or retyped,
an error code's HTTP status moved, a wire field dropped or retyped, a request
field added or its type or allowed values changed. Those are precisely the
changes that break a client, and none of them fails a behavioural test that was
written against the old shape.

`scripts/verify/contract.mjs` closes that gap. It reads the live surface and
writes it down as JSON — a **snapshot** — which is committed and compared on
every push and every pull request. A change to the surface must be accompanied
by an explicit snapshot update; drift is a failure, not a warning.

## What is frozen

| Section | Source of truth | Frozen |
|---|---|---|
| `routes` | `registerApiRoutes` in `src/server/index.mjs` (the same function `createTaskd` calls), plus the endpoints served outside the JSON router (`/health`, `/meta`, `/api/v1/events`, attachment bytes) | `METHOD /path`, and the route's **declared request schema** (`{query, body}`) down to `type`, `enum`, `items`, `anyOf`, `properties`, `required` and `additionalProperties` — recursively |
| `errors` | `ERROR_CODES` in `src/foundation/errors.mjs` | the code and its HTTP status |
| `wire` | every `*ToWire` projection in `src/foundation/wire.mjs` | the name **and JSON type** of every field each shape emits, including the nested `task.assignee` / `task.reporter` / `task.report_waiver`, and the `sla_alert` / `sla_config` shapes `GET /api/v1/sla` publishes |

The config-file codes `CONFIG_FILE_INVALID` and `CONFIG_FILE_INSECURE` are in the
same `errors` table, and `--env-file` is a frozen global CLI flag. A change to either
surface is a snapshot update, not a documentation edit.

The acceptance baseline (0032) is part of the same surface: `task.acceptance[]` and
`task.acceptance_revision` on the wire, `acceptance` / `acceptance_drop` /
`acceptance_revision` / `reason` on `PATCH /api/v1/tasks/:ref`, and the new refusal codes
(`ACCEPTANCE_*`, `REPORT_NOT_APPLICABLE_NOTE`) in `errors`. The epic rollup (0033) adds
`report.is_rollup` and its refusal codes (`ROLLUP_REQUIRED`, `ROLLUP_INVALID`, `ROLLUP_STALE`).
The question protocol (0034) adds `question` to the `comments.kind` enum, `question` to the
MOVE request body, and `QUESTION_REQUIRED` to `errors`. Both snapshots change exactly when
`node scripts/verify/contract.mjs --update` says they do.

Nothing is a second copy of a list. The routes are *enumerated* by registering
them against a stub router (registration only records `{method, pattern, handler,
request}` — no handler runs), the error codes are read from `ERROR_CODES`, the
wire names and types from the projections themselves, and each route's request
shape from the declaration that same registration carries. A route therefore
cannot be added to the server without appearing in the snapshot, nor can one of
its request fields move without the snapshot saying so.

The wire **type** is read off a probe projection: each `*ToWire` function is
called with one subject that populates every field it reads, so the emitted type
is the type a client actually receives when the field is present (`task.version`
is a `number`, `task.labels` is an `array`). A projection that starts emitting a
string where it emitted a number moves the snapshot; an empty probe would have
frozen "everything is `null`" and nothing more.

Bumping the snapshot's own layout (not a section) bumps `schema_version`. It is
**4** as of the route request shapes described below; **3** added the route
request shapes and **2** added the wire field types; **1** was the original
routes/errors/wire-names snapshot.

The number itself is `CONTRACT_SCHEMA_VERSION` in `src/foundation/constants.mjs` — the
generator **reads** it rather than keeping a second copy — and the runtime
publishes the same value:

```bash
taskctl --version --json     # {"ok":true,"data":{"version":"<release>","schema_version":4}}
```

so a client can read the generation the provider actually speaks **at runtime**
instead of hard-coding one, which is what an ACL adapter's drift gate needs: the
structural leg (a field's presence and type, frozen in `wire`) catches a change
that moved a field, and this number is the version leg beside it. It is answered
before any board is contacted, so the negotiation does not depend on the board it
is about. Note what the number does and does not track: it moves with the
snapshot's own *layout*, not with every contract change — adding a route changes
`routes` without moving it — so the structural leg remains the primary detector
and this is the coarse generation marker beside it. The bare `taskctl --version`
line is unchanged.

## What is deliberately *not* frozen

Error **messages** and **hints**, route handler internals, and JSON-Schema
`description`s (and any other prose key). Those are prose — they are expected to
change, and freezing them would turn every wording edit into a snapshot chore.
Only what a client binds to is frozen.

## Routes: the declared request shape

Every route registration carries a **declarative request schema** — `{query,
body}` — as a third argument (`src/server/requests.mjs` owns the vocabulary):

```js
router.post("/api/v1/tasks/:ref/move", handler, { body: MOVE_BODY });
```

The snapshot freezes it down to `type`, `enum`, `items`, `anyOf`, `properties`,
`required` and `additionalProperties`: each route gains `request.query` /
`request.body`, and a facet of it can be diffed by name
(`POST /api/v1/tasks/:ref/move.body.to.enum`). A `null` side is itself a fact
worth freezing: *this* route takes no body, *that* one takes no query.

Three things follow, and the third is the point:

- **The vocabulary is the domain's.** `STATUSES`, `PRIORITIES`, `RELATION_TYPES`,
  `COMMENT_KINDS` and friends come from `src/core/domain/enums.mjs` — the same
  literals the SQL `CHECK (... IN (...))` clauses and the domain validators use —
  so a status added to the domain widens the declared request shape too, and the
  gate reports the drift rather than letting the two disagree.
- **Query values are strings.** `?limit=50` reaches a handler as `"50"`, and
  `?status=a&status=b` as an array, so a query schema declares a string where the
  handler will parse an integer. Declaring `number` would describe a value the
  route is never handed.
- **It is declared, not enforced.** Handlers still read `query.get(...)` /
  `body.…` and unknown fields are still ignored — which is why the declared
  objects are `additionalProperties: true` and why adding a declaration changes
  nothing a caller can observe. The schema is the shape the route *promises*, and
  the snapshot is what keeps a promise from changing silently. Validating against
  it would be a behavioural change and belongs in a card that says so.

Two consequences worth naming, because the snapshot freezes the field and not the
behaviour behind it:

- **A declared enum is not a validator.** `GET /api/v1/tasks?kind=…` declares the
  task-kind vocabulary, but the handler refuses an unknown value itself
  (`VALIDATION_FAILED`) — the schema is never run. The declaration tells a client
  what the route means; it does not make the route check it. The read also takes
  `kind=review`, which is a **filter over the review marker**
  (`review_of_id IS NOT NULL`), not a third kind: create/update still accept only
  `task|epic`, and `kind=task` keeps returning review cards. Each card's wire
  carries the derived `role` (`task|epic|review`) for the readable word.
- **A declared field does not imply the check on every path.**
  `POST /api/v1/tasks/:ref/claim` declares `check_blockers`, but only
  `issue claim --check-blockers` re-reads the dependency graph
  (`BLOCKED_BY_PREDECESSOR`). The default claim trusts the caller, because the
  candidate scan (`issue candidates`) already withholds blocked cards; the opt-in
  exists for a caller that did not read candidates.

`test/verify/contract.test.mjs` pins the premise in both directions: every route
must carry such a declaration, and each one is checked down to its facets — and
the drift tests retype, drop and widen a route request field and assert the gate
goes red (exit 1) and back green.

## The hole the snapshot leaves: is the declaration *true*?

The snapshot freezes the declaration. It cannot tell whether the declaration is
what the handler does — the declaration is a **second description** of the
request, written beside the handler from it by hand, so the two can drift. A
handler that starts reading `?stale=` while the declaration still lists only
`?assignee=` ships a request field no client can see; a declared filter nothing
reads ships a promise that does nothing. Neither moves the snapshot: the
declaration did not change, only the handler did.

`scripts/verify/route-request.mjs` closes that at the **mechanism** level rather
than by re-auditing each handler. It reads every route's handler *source*
(`Function.prototype.toString()` — no second copy of the route list, no
line-number table), statically extracts the fields it reads

- query: `query.get("x")` / `query.getAll("x")`;
- body: `body.x`, `body["x"]`, `body[x]` over a literal loop, `const { x } = body`;

and compares that set with the declared `properties`, in **both** directions:

| Finding | What moved |
|---|---|
| `undeclared_read` | the handler reads a field the declaration omits — a caller sends what the frozen contract does not admit |
| `unread_declaration` | the declaration names a field the handler never reads — the contract promises a filter that does nothing |
| `unresolvable_read` | a read the lint cannot resolve (`body[key]` with a non-literal key, `{ ...body }`, `Object.keys(body)`, passing `body` on as a whole) |
| `stale_refusal` | a documented "read only to be refused" entry that no longer applies |
| `uncovered_endpoint` | an endpoint outside the JSON router that declares a request schema the lint cannot reach |

`unresolvable_read` is a failure, not a shrug, and that is the point: a read the
lint cannot see is exactly the read that could drift unnoticed, so the lint
refuses to pass a route it cannot prove. A new read form is therefore a red gate
and a two-line addition to the checker — never a silent hole.

`PATCH /api/v1/tasks/:ref` is the one place a name-matching lint cannot be
right: it reads `body.status` in order to throw `INVALID_TRANSITION`, and the
declaration correctly omits it (a shape declares what a route *accepts*, not what
it recognises in order to reject). That read lives in `REFUSED_READS` — an
explicit, reasoned list, not a silent skip list — and every entry is itself
checked, so a refusal whose field is also declared, whose handler stopped reading
it, or whose route is gone is *itself* a finding. `GET /api/v1/events` is the
mirror case on the other side: its reader is `revisionFromRequest` in
`sse.mjs`, outside the router, so the endpoint is acknowledged in `OUT_OF_LINT`
with that reason and the acknowledgement is checked too.

`test/verify/route-request.test.mjs` is the red→green proof, and it never edits
`src/`: the *live surface* must pass, and synthetic handlers — an undeclared
read, an unread declaration, a computed key, a destructured read, a literal loop
— must go red and then green. It also pins the surface's exemption count to
exactly one, so a second one cannot be added quietly.

```bash
node scripts/verify/route-request.mjs          # lint the live surface; 0 or 1
node scripts/verify/route-request.mjs --print  # per route, declared vs read
```

## Commands

```bash
node scripts/verify/contract.mjs            # compare live vs committed
node scripts/verify/contract.mjs --update   # rewrite the committed snapshot
node scripts/verify/contract.mjs --print    # the live snapshot, to stdout
node scripts/verify/contract.mjs --base <dir>   # compare against another tree
node scripts/verify/contract.mjs --file <name>  # a non-default snapshot path
```

Exit codes: **0** the tree matches the snapshot (or `--print` / `--update` ran);
**1** drift — the API moved and the snapshot was not updated; **2** bad usage, or
the snapshot file is missing, unreadable or not valid JSON.

The snapshot lives at `test/fixtures/contract/api.snapshot.json`. Node builtins
only — no `npm install`, no network.

## Determinism

The snapshot is deterministic by construction: keys are sorted at every level,
`enum` and `required` lists are sorted, properties are ordered by key, every
other list has an explicit sort order, and the file ends in one newline. Two runs
on the same tree are byte-identical, so a diff always means a contract change and
never a reordering — reordering a `STATUSES` array in `src/` is not a contract
change and must not turn the gate red.

The checker enforces this rather than assuming it: a file that is semantically
equal but hand-reordered or re-indented is reported as a `format` change and must
be regenerated with `--update`. (That is why the check is stronger than a
`diff` against freshly printed output — it pins *this* file, not a second copy.)

## Where it runs

| Door | What it runs |
|---|---|
| `.githooks/pre-push` | the contract check and the route-request lint on **every** push (the API ships in every commit, not only a tag), then the tag↔CHANGELOG rule |
| `.githooks/pre-tag` | the same three rules, by hand, *before* `git tag` |
| the `contract` job in `.github/workflows/check.yml` | both API checks on every pull request and every push to `dev` |
| `.github/workflows/version-gate.yml` | the contract check at tag time, next to the changelog gate |
| `docker/verify-in-container.sh` | both API checks inside the full container suite (`npm run verify:docker`) |

The `contract` job is the pull-request-time authority; `version-gate.yml` carries the same
check at tag time. See
[`.githooks/README.md`](../../.githooks/README.md) for the hook table.

## Changing the API

1. Make the change in `src/` and, if it is behavioural, add the test under
   `test/contract/` that proves it.
2. Read what moved:

   ```bash
   node scripts/verify/contract.mjs
   ```

   A `route_added` / `route_request_changed` / `error_removed` /
   `wire_field_added` / `wire_type_changed` line is the change, spelt out. The
   `route_request_*` lines name the exact facet
   (`GET /api/v1/tasks.query.limit.type`, not just the route), so a widened
   status vocabulary reads as the one value that moved.
3. Confirm the declaration still equals the handler's reads:

   ```bash
   node scripts/verify/route-request.mjs
   ```

   A field added to the handler but not to the declaration (or the reverse) is a
   finding here. Fix the declaration beside the route; if the read is deliberate
   and the field is *refused* rather than accepted, say so in `REFUSED_READS`.
4. If it is intended, refresh the snapshot and commit it **with** the change:

   ```bash
   node scripts/verify/contract.mjs --update
   git add test/fixtures/contract/api.snapshot.json
   ```

If it is *not* intended, the checker has just caught a regression before it
reached a client.

## The CLI contract snapshot

`taskctl --json` is the surface the external loop binds to, and any user script may
too. `test/fixtures/contract/cli.snapshot.json` freezes it separately from the HTTP
snapshot:

- every command group and verb, and each command's positionals;
- every flag's `flag` / `as` / `value` / `required` / `repeat` / `short` (`summary` and
  `usage` prose are **not** frozen — they are documentation, not shape);
- the global flags, in the same shape;
- the **keys** of the success and error envelopes, the error payload, and the
  `--version --json` data. The version number itself is not frozen, so a release does not
  move the contract.

The snapshot is generated from the live registry (`src/cli/commands/index.mjs`), the
envelope helpers and `versionPayload()`, is byte-stable, and lives in its own file so a CLI
change does not bump `CONTRACT_SCHEMA_VERSION`.

```bash
node scripts/verify/cli-contract.mjs            # compare; 0 or 1
node scripts/verify/cli-contract.mjs --update   # refresh the snapshot
node scripts/verify/cli-contract.mjs --print
```

The test suite adds one leg the snapshot cannot: `references/cli.md` must name every frozen
command and every flag. A declared-but-undocumented flag is how a toolkit quietly becomes
guesswork.

When you change a command, refresh the snapshot in the same commit:

```bash
node scripts/verify/cli-contract.mjs --update
git add test/fixtures/contract/cli.snapshot.json
```

## See also

- [`../src/core/README.md`](../../src/core/README.md) — the domain rules the error
  codes name.
- [`publishing.md`](../ops/publishing.md) — the release flow the tag-time gates sit in.
