0007 — The phase table crosses by snapshot: Smith derives, the board displays
- Status: Accepted, with one clause superseded. ⚠️ AMENDED 2026-09-11 by ADR-0024: the Consequences clause "Slack keeps its pinned table" is superseded — Slack keeps its pinned MESSAGE (same anchor, same D1 gate, render still always happens), but that message carries ONE LINE and ONE
/agentslink since agent-smith #371 (2026-09-04). Everything else here — the transport, the one-resolver rule, §3's canonical close-up — is unchanged and is what made the deletion possible. (Appended toStatuson purpose: this adds NO line, so every line-number citation into this file stays true.) ⚠️ VOCABULARY: the role this ADR calls the Operator is the Driver since 2026-09-10 —operatoris the product's word for a PM company. Decision text below is unchanged; ids, binaries, trigger phrases and theOperatorTaskWorkflowtype that still sayoperatorare live invocations and deliberately lag. See ADR-0021. (Appended toStatuson purpose: this adds NO line, so every line-number citation into this file stays true.) - Date: 2026-08-30
- Deciders: Settled while moving Smith's Slack kickoff table onto the
/agentsclose-up page (agent-smith #348 → agentflow-relay #83 → propflowai #6644), recorded here by thekickoff-table-to-agentflowworker because ADR-0004 says the decision is not finished until it is written — and this one changes how two systems relate, which is exactly the class that dies in conversation.
The rule
- Agent Smith writes a versioned JSON phase snapshot, one per task, at
~/.claude/jobs/tasks/<slug>/phases.json(agent-smith/src/agent_smith/phase_snapshot.py, #348). Atomic tmp+rename. It never creates the task directory — a missing task dir is a real state (the thread binding outlived its task, or session-gc took it), andmkdir -pwould answer that by manufacturing a row on the board. - The relay transports it as two additive fields on rows it already
serves —
phasesandphasesUnreadableonTaskSummary(relay/src/digest/phases.ts, wired indigest/digester.ts, #83). No new route. The board reads it through the existing/api/feedpayload. - The board displays rows; it never computes them. The Phases card
(propflowai #6644) maps a row's
stateto a glyph and nothing else. There is no phase resolution in TypeScript. - The canonical close-up is
/agents/[sessionId]. Not?item=<sid>— that opens the drawer and survives only for old bookmarks. Any doc or plan that says otherwise is stale; this line is the authority.
Why the transport was FORCED, not chosen
This is the part worth keeping. It reads like duplication — Smith renders a table, and now a file carries the same table — so the reason it is not duplication has to be on the record, or the next session will "clean it up".
The board physically cannot re-derive these rows. The receipt, run in this repo on 2026-08-30:
$ grep -rn SMITH_STATE_DIR relay/src supervisor/src
$ echo $?
1
Nothing. The relay reads ~/.claude/jobs/tasks/<slug>/ — task.json,
events.jsonl, acceptance.json. But half of what a phase row is derived
from — pr_url, review_at, merged_at, deployed_at, preview_url —
lives in Smith's own evidence store under SMITH_STATE_DIR, which no process
the board can reach has ever opened. The two share no store. That is why the
board has never shown a phase, and it is a structural fact, not a missing
feature.
And the fix is not to teach the relay to read that store. It would need a
second implementation of agent_smith/phase_table.py, whose entire value is
being the ONE resolver over those facts — that module was rewritten precisely
to end two rows answering one question from two files (the header said live
while the verdict row said closed, for eight hours). A relay that re-derived
the rows in TypeScript would be that same incident with a network hop in the
middle, and the disagreement would be between Slack and the board, where
nobody can see both at once.
So: derive once, in the process that owns the facts; ship the
already-rendered rows. If a row is wrong, phase_table.py is wrong, and both
surfaces move together.
Why a file and not a call (ADR-0003)
The obvious alternative — the board asks the mini for a task's phases over HTTP — is the thing ADR-0003 forbids: rungs integrate by signals, never by calls. A live RPC from Vercel into the mini would put a laptop-resident agent process on the request path of a page render, and make a Smith restart look like a board outage. The snapshot is a signal in exactly ADR-0003's sense: the writer emits, the reader consumes if it is there, and neither knows whether the other is running.
It also satisfies agent-smith's Constitution §1 (DRY): one resolver, in
Python, and phases / states ride along inside the document as the
vocabulary the rows were built against — so a consumer in another language can
assert it renders the whole row set without keeping its own copy of a
derived list.
The contract: versioned envelope, and refuse the whole table
Two repos deserialize this document and they deploy independently, so the reader is deliberately strict.
What is inherited and what is new, stated precisely — the precedent is
relay/src/blocks/stars.ts, and it is a narrower precedent than it is
tempting to claim. readStars contributes the unreadable discriminant
and the absence-is-not-unreadable split (ENOENT is an empty set; EACCES,
EPARSE and a wrong shape are unreadable). It does not version-check —
it writes { version: 1, … } and never inspects version on the way back in —
and it drops a malformed row with continue rather than refusing the set.
Version-pinning and whole-table refusal are new here, introduced by
phases.ts in #83, because this document crosses a repo boundary and a stars
file does not.
| condition | result |
|---|---|
no file (ENOENT) |
undefined — the ordinary case. Most tasks are not Smith threads; "this task has no phase table" is true. |
EACCES / EIO / truncated / unparseable |
phasesUnreadable — the table could not be read |
version this build does not know |
phasesUnreadable (EVERSION) — refused, never parsed optimistically for the fields that happen to look familiar |
| one row of N unreadable | phasesUnreadable — the WHOLE table is refused, never the N−1 survivors |
The last two rows are the ones people would soften, and they are the two
stars.ts does not have. A table with a hole must look
like one: serving the survivors renders a shorter table that looks complete,
and a card told "no phases" would show a blank where there is a live one.
Absence of a readable value is not a value — the rule blocks.ts carries
the four incidents for.
Adding a field is not a version bump (readers ignore what they do not know). Removing or re-meaning one is.
What a future change must not do
- Must not re-derive phases on the board side — not in the relay, not in the card, not "just for the one row that is missing". The moment a second resolver exists, Slack and the board can disagree, and the eight-hour incident above returns split across two surfaces.
- Must not add a live RPC from the board to the mini for this or anything like it. Different rungs integrate by signals (ADR-0003).
- Must not make the writer create the task directory. A row on the board is a claim that an Operator made a task; a sidecar write must never mint one.
- Must not best-effort an unknown version or a partial table. If the strictness ever becomes annoying, the answer is a version bump on both sides, not a widening reader.
- Must not add a new route for this. It is a field on rows the feed
already serves; a
/api/phaseswould be a second way to ask one question.
Consequences
- Both surfaces render one derivation. Slack keeps its pinned table (#348
withholds it only when every row is a gap — decision D1 there; the render
still happens, because workflows branch on
states/done), and the board now shows the same rows without Slack being the only place they exist. - The neighbouring decision is
ADR-0006 (One session-state
classifier: six states, a wake path, and
nullfor everything else), which answers the other question a task row carries — what is this session doing? — with one classifier inrelay/src/pulse/that decides and never acts. The contrast is the point, and it is what makes this ADR's forcing argument concrete: that question is classified on this side, because everything it reads is state this side can already reach. How far has this task got? is not, and that is the whole reason a snapshot has to cross. - The phase vocabulary has exactly one owner. A new phase or row-state is a
phase_table.pychange and appears on both surfaces from one edit. - The transport's liveness is declared in
SYSTEMS.md(phase-snapshot), per ADR-0004's inert-mechanism clause — a mechanism ships with its production liveness proof, because tests prove the rule and cannot prove the rule is ever reached.