# Job stage observability — design

audience: AI coding agents first. slug: `job-stage-obs`
source request: owner 2026-08-16 — the request card's stage strip (spec → build → verify → land → deploy → live) renders "Stage not recorded" because nothing writes the stage. Add the observability for which stage each job is at.

## Contract

One stage vocabulary, machinery-written, board-read. The card's existing strip lights the current chip; "Stage not recorded" stays as the honest fallback for rows with no stage evidence — never guessed.

**Stage vocabulary (fixed, owner-language):** `spec | build | verify | land | deploy | live`. Stored as a `receipt_trail` entry `kind="stage"`, `meta.stage=<value>`, one entry per TRANSITION (edge-triggered — re-stamping the same stage is a no-op, same idempotency rule the registry transitions use). Current stage = latest stage entry.

## Writers (the machinery, not agent goodwill — registry L2 principle)

1. **spec** — stamped when a plan/spec doc is registered for the row (intake plan-ref set, or /brainstorm writes the doc) and no later stage exists.
2. **build** — factory run claim: the factory's run start (the notify pipeline's `factory.run.start` event carries adw id; the dispatch/claim path already stamps `claimed`) writes stage=build for the bound row. Manual lanes: `od-requests claim` implies build when the row has a plan-ref (else leaves stage untouched).
3. **verify** — the factory's verify/test phases (same seam as the phase trace; emit on first verify phase), or the land-queue gate STARTING for the row's branch (queue mirror event `gating`).
4. **land** — queue mirror verdict `pushed` / the lander's `landed` stamp (exists in registry L2) also writes stage=land.
5. **deploy** — the deploy watcher's `deployed` stamp (L2) also writes stage=deploy.
6. **live** — the row's proof recorded (proof_url set / shipped state) writes stage=live.

Implementation seam: one helper in the collector (`stampStage(requestId, stage)`) called from the SAME code paths that already write the corresponding registry stamps — no new pollers, no new daemons. Where a writer lives outside the collector (finish-branch.sh, deploy watcher), it goes through the same fail-open HTTP the existing stamps use.

## Reader (UI)

RequestsBoard/RequestDrawer already render the strip: replace "Stage not recorded" logic with: latest `stage` trail entry → highlight that chip + all earlier chips as done; no stage entry → keep the honest fallback. Drawer's activity story shows stage transitions as timeline entries (they are trail entries — free). No new components; Astryx/deck-ui rules apply.

## Error handling

Out-of-order arrivals (deploy stamp lands before a missed verify): current stage is the latest by timestamp, earlier chips render done regardless of gaps — never blocks. Unknown stage value in a trail entry → ignored by the reader, journaled by the writer validation (collector rejects unknown vocabulary at the API). Fail-open everywhere: a stage write failure never affects the operation that triggered it.

## Testing

Collector: stampStage idempotency, vocabulary validation, transition append. Writers: fixture per seam (factory start → build; mirror pushed → land; deployed → deploy; proof → live). UI: strip render for each stage + no-stage fallback + out-of-order case. Live acceptance (owner-visible): one real request driven through a landing shows its card strip advancing spec→…→live with no agent writing stages by hand; a row with no stage still reads "Stage not recorded".

## Architecture decisions

- Stage as trail entries, not a new column: the drawer timeline gets transitions for free; current-stage is a cheap latest() read; no migration.
- Writers piggyback existing stamp paths — a separate stage-tracking service was rejected (second truth, drifts).
- Registry backend dependency: receipt_trail is in the land queue (wt/registry-recover-v2). Build against its schema; if unlanded at land time, land DARK (UI keeps fallback until the table exists) — same rule drawer D1 used.
