0001 — Narrow the Architect to an enforced observe+spawn singleton
- Status: Accepted ⚠️ 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-08
- Deciders: Gera; verdict C, returned at high confidence by a tiebreaker evaluation holding both prior positions in full.
Why this ADR exists at all
Two evaluations of the Architect role disagreed. A tiebreaker was run with both positions in front of it and came back C — narrow the role to observe+spawn, and enforce the singleton at high confidence.
This file records that verdict with its evidence, because the losing position's central plank was an artifact of when somebody happened to look, and a future evaluation reading the same ledger a week later will find the same clean surface and re-argue from it. The point of writing it down is to make the next evaluation argue against the measurement rather than against the residue.
Context — the two facts the verdict turned on
1. The zero was manufactured, minutes earlier, by hand
The competing position's load-bearing claim was:
"
blocked adopt --listshows zero orphans, so Operators already handle cross-task blocks."
That was false as framed. The zero existed because the incumbent Architect hand-adopted two blocks minutes before the command was run. Measured off disk on 2026-08-08:
| file | which field | value | state |
|---|---|---|---|
jobs/blocked/cc4ad587-…/b86054955.json |
file mtime | 14:55:03 | adopted, adopted_by: 0d84ff5f-… |
jobs/blocked/cc4ad587-…/b86069107.json |
file mtime | 14:55:44 | adopted, adopted_by: 0d84ff5f-… |
jobs/blocked/0d84ff5f-…/b86054955.json |
adopted_at |
14:55:03 | open, adopted_from: cc4ad587-… |
jobs/blocked/0d84ff5f-…/b86069107.json |
adopted_at |
14:55:44 | open, adopted_from: cc4ad587-… |
⚠️ The column used to be headed mtime for all four rows, and that was a
false attribution — one worth fixing precisely, because an ADR arguing that a
claim needs an input that could have falsified it will be checked by someone
running stat on its own citation. Two things the split makes visible:
- The live copies' mtimes are not 14:55. Measured 16:50:44 — those files
have been rewritten since, by the hand publish-workaround described under
Consequences. Their
adopted_atis what carries 14:55, and it is immutable in a way an mtime is not. The tombstones are the rows whose mtime genuinely records the adoption, because nothing has touched them since. - The tombstones' own
adopted_atreads2026-08-06 23:34:36/23:34:58, not 14:55 — because it records the previous generation (9a7a3194 → cc4ad587), not this one. The same field name means a different hop on each row. That is the three-generation chain showing up inside the evidence table, and reading the column as one uniform fact would have hidden it.
blocked adopt --list reports open blocks whose owner is provably dead. Two
rows had been moved onto a live owner seconds earlier, so the command could not
have returned anything else. A claim graded on an input that could not have
falsified it is unverified, not confirmed. The general form, which is the part
worth keeping: a green reading taken after somebody cleaned by hand measures
the cleaning, not the system.
⚠️ The same class shows up as ABSENCE, and that is the harder half to see. A
missing reading is not a good reading. Live instance on the day of this ADR:
session dd7d479d was measured at 95.6% context by self-compact while
/api/roster served contextPct: null for the same session — two
instruments, one number and one silence. null there means nobody could look;
it does not mean healthy, and it does not mean zero. Any surface that renders it
as either has re-created the manufactured zero without anyone having to clean
anything by hand. The rule this ADR asks future readers to carry is therefore
symmetric: a claim needs an input that could have falsified it, and an absent
input never could.
2. The class of block has no owner in an Operator+Worker-only model
The same ledger shows three generations of coordinator-to-coordinator adoption on those two ids:
9a7a3194 → cc4ad587 → 0d84ff5f
These are cross-task questions. They belong to no single task, so no Operator owns them, and:
blockedroutes an answer back to the raiser's sid-keyed ledger (blocked checkreads the CALLER's ledger). A row in a dead session's ledger is unexecutable by anyone.- The Supervisor is a launchd process. It can detect an orphan; it cannot execute an answered decision — that requires judgement, and judgement requires a live model session.
So removing the Architect does not remove the class of block. It removes the only thing that could ever act on one. That is the argument for keeping the role, and — because the ledger is keyed by sid — it is also the entire argument for making it a singleton.
3. Independent corroboration, from the ledger side
The two facts above come from the tiebreaker's reading. They were corroborated
the same day, from a different vantage point, by session propflow-40 · 0d84ff5f — the incumbent Architect itself, which was holding the adopted rows
and reported what it could see from inside its own ledger. It observed the same
14:55 adoption and the same three-generation chain directly, where the
tiebreaker had inferred it. Two independent observers, one event.
It also surfaced something the tiebreaker did not: adoption transfers the row and nothing else.
blocked adoptcopies the row into the new owner's ledger and marks the originaladopted. It does not republish the decision page under the new sid, and no CLI does —blocked publishis not a subcommand, so it falls through tocmd_listand prints the list.- Verified on disk: the tombstone rows at
9a7a3194andcc4ad587still carry the original raiser'surl(…/blocked-9a7a3194.html), two generations after ownership moved. - It reported the drive-gate refusing its stop — "1 hard block(s) NOT properly
raised: blocked-0d84ff5f is not on origin" — while
blocked verifysaid "OK: 2 open, 0 not fully raised".verifyreads the local ledger; the gate reads origin. When two instruments disagree, the local one is the optimistic liar, and the gate's own wording overstates the consequence: it says "no decision surface can show it to a human" when the original raiser's page is on origin and shows exactly that.
⚠️ One half of that did not reproduce, and is recorded as such. Re-checked
later on 2026-08-08 against origin/main in propflow-docs: all three of
artifacts/blocked-0d84ff5f.html, …-9a7a3194.html and …-cc4ad587.html were
present. So the specific "not on origin" reading was a moment in time. What is
structural, and will recur, is the disagreement: a local-ledger check and an
origin check answer different questions and one of them is optimistic. Reporting
only the half that still reproduces would flatter the fix; reporting only the
original claim would assert something now false.
Decision
The Architect is an enforced observe+spawn singleton.
It holds no task and never drives work. Its one active power is minting Operators via
start-operator, and it authors the--criterion.~/.claude/skills/architect/SKILL.mdstates this in its header and in §0e.The raw-tmux worker-spawning recipe (§2b) is deleted. It bypassed
start-operator, whose header says "EVERY DOOR LANDS HERE… so 'how work starts' cannot drift into three answers." A session started by hand has notask.json, no criterion, no STATE.md, no board row and no Supervisor respawn — it is a different thing that looks like the right one for about ten minutes.The §0e delegate table is deleted. A table listing what the role owns is an invitation to own more of it. The role is now defined by one power, so there is nothing adjacent to drift into. The old carve-out — "loop infrastructure stays here" — is retired with it: a carve-out on a rule about drift is exactly where drift lives.
The singleton is enforced by SID, in code. The sid is pinned at
~/.claude/jobs/architect/SID;~/.local/bin/start-architectrefuses while that sid is alive, mirroringstart-operator's existing refusal pattern. Resume or attach is the only way in.blockedgainedblocked alive <sid>— 0 alive / 1 dead / 2 undeterminable — sostart-architectreuses the fleet's single liveness predicate rather than adding a fourth copy of it.A successor's FIRST action is
blocked adopt --list→ adopt → check. Operator §4b's "adopt; never re-raise" is generalised into the architect skill (§2d) as the reason the role exists.⚠️ THE LEDGER IS THE AUDIT PREDICATE, AND
task.jsonMAY NOT BE USED AS ONE.readTasks(supervisor/src/tasks.ts) rebuilds a task file from an explicit allowlist — task, criterion, workerSid, operatorSid, state, repo, createdAt, operatorRespawns, lastSupervisedAt, and a handful of opt-ins. Any other field is silently erased on the next supervisor write. Measured on this very task:startedFrom: "tmux"was present at mint and had vanished by the next read, with nothing logged.The consequence is a trap that has already nearly closed live lanes twice: a field like
parkedOnis not durable, so "parked with no block id, therefore nothing is waiting" reads an ERASED field as an ABSENT question and closes a lane around a live block. That is the same absence-reads-as-fact error this ADR opens with, arriving through a different door — and it is why the successor step above namesblocked adopt --listandblocked checkrather than anything derived. The ledger directory is the record;task.jsonis derived and lossy.⚠️ And §2d states the limitation rather than implying wholeness. Adoption transfers the ledger row; it does not republish the decision page under the new owner's sid, the original raiser's page can remain the live human-visible surface, and no CLI closes that gap today. Writing this step as a clean "adopt and you are whole" instruction would be a documentation-only guarantee — the exact failure class item 3 of this decision exists to remove, and we do not get to commit it inside the change that names it. Adopting is still mandatory: an unadopted block has no live executor, which is strictly worse than one with a stale page.
What is deliberately NOT promised
Name reservation. Nothing reserves the name architect, and saying
otherwise would be a documentation-only guarantee — the exact class of failure
this change exists to remove. Verified 2026-08-08:
/renameis a harness builtin with no hook to gate it.claude -n/--namesets any display name freely.The live roster held, in one
claude agents --jsonsnapshot, 158 rows / 141 distinct sids / 141 distinct names, 17 of those names duplicated — and zero rows named exactlyarchitect, because the incumbent Architect was calledpropflow-40. One name was a 184 KB pasted JSON blob.⚠️ Rows, not sessions — and this ADR is the wrong document in which to blur that. An earlier draft said "162 sessions".
claude agents --jsonemits rows, one session can hold several (see below), and 26 of these rows carried nostatusat all, so "live" overstates them too. Treat every figure here as one snapshot of a moving roster: re-counted hours apart the same day it gave 158 and 154 rows. What is stable across every snapshot is the shape, which is all the argument needs — names duplicate, and nothing is namedarchitect.
A second observation, and it is deliberately stated narrowly. The pinned
Architect sid 0d84ff5f-00e0-4b4e-aeb9-8ea47743befe resolved, in one
claude agents --json snapshot, to two rows at once — propflow-40 (idle,
pid 93109) and propflow-c2 (busy, pid 99329).
⚠️ This is not a new discovery, and an earlier draft of this ADR wrongly
presented it as one. The primary source is dedupeBySession in
relay/src/roster/roster.ts, which exists because of this shape: "claude agents --json can emit the SAME sessionId twice — observed live for
cc4ad587… both rows alive, identical staleness, but one says status: busy and
the other status: idle (they also carry different pids)… A count that lies is
worse than a count that is merely doubled." It resolves the conflict by
preferring alive, then busy. The observation above is a second live instance
of a documented shape, not a finding.
So the claim this ADR rests on is the narrower one that survives scrutiny:
- Sid-keyed reads are already mitigated.
dedupeBySessioncollapses the rows and picks the busy copy. Nothing is owed here. - Name-keyed reads are not, and cannot be by that mechanism. The dedupe
keys on
sessionId; a reader resolving by name never reaches it, and the two rows carry different display names with contradictory liveness. So a name-keyed liveness check answers differently depending on which row it hits.
That is a weaker statement than "a session's name is not even singular", and it
is the one that is actually true. It still supports the only use made of it:
keying identity on the pinned SID, which is unaffected by either problem. It
is also the same hazard that forced session_alive to stop taking hit[0].
⚠️ Said precisely, because an earlier draft of this ADR contradicted itself
about it. One sentence here treated a SID-keyed /agents architect tab as
though it existed, while Consequences correctly called it unbuilt. It does not
exist — searching relay/src finds it only in comments. What is enforced
today is start-architect and the pinned SID file; the tab is a proposal.
And the surface that actually resolves "the Architect" right now is not
SID-keyed at all: relay/src/ask/ask.ts hardcodes DEFAULT_TO to
cc4ad587-… — the dead predecessor, which holds zero roster rows. So the
system's live default addressee points two generations back, at the very
session whose death produced the orphaned blocks in the evidence table above.
That is owned by task default-addressee-fix (ee8915e4). It is cited here
as the fix rather than used to soften the claim: the rule is right, the
implementation is one route behind it, and naming which is which is the whole
discipline this ADR is arguing for.
⚠️ AND IT IS NO LONGER LATENT — IT HAS SWALLOWED A HANDOVER. Measured
2026-08-10: a completed Operator (dock-partial-degradation) sent its entire
closing report to "the architect", which resolved to cc4ad587. From its own
delivery-status section:
Attempted msg to the architect (cc4ad587) at 2026-08-10T04:32:18Z.
REFUSED: 'architect [cc4ad587] has no live pane — it is not running'.
msg does not park for a non-running session, so this report was NOT
delivered.
Every step behaved correctly. msg refused honestly rather than parking into a
dead session's inbox. The sender drew the only conclusion available to it — that
the Architect was not running — and wrote the report to a file. The live
Architect (0d84ff5f, five live processes at the time) received nothing, and
had no signal that anything was missing. The report sat undelivered for six
hours and would have sat there forever: a closed Operator has stopped its loop,
so nothing was left to retry.
⚠️ Note the shape, because it is the ADR's own thesis pointed at the routing layer: the sender saw a clean, actionable refusal and the recipient saw silence. Neither side could distinguish "nothing was sent" from "something was sent to a corpse". A name-shaped default addressee fails exactly the way a name-shaped identity does — which is the argument for the pinned SID, arriving as an incident rather than an argument.
⚠️ AND THE SAME CLASS REACHES THE HUMAN — an instruction typed into a composer and never submitted is INVISIBLE. Measured 2026-08-10, on this task: Gera had typed "merge #45 then check #42 under the real gate" into this Operator's composer line and it was never submitted. It does not queue, does not retry, and never appears in mail. He had every reason to believe he had given the instruction; the Operator had no way to know one existed. A second lane was hit identically the same night, with "latch it — hold once per episode, then go quiet" parked the same way, so it is not a one-off.
The second half is the structural part, and it is worse than the loss:
recovering the instruction changes who it came from. The Architect found the
text, captured the pane before clearing it, and re-sent it byte-identical — and
it arrived stamped [msg from unknown via drive-send · 0d84ff5f], because the
relay strips the original author. So the only available repair downgrades the
authority of the thing it repairs. Here that inversion decided the outcome:
#45 touches .github/*, the human-merge floor, where "merge it" from Gera
is authorisation and the identical words from an agent are an agent pushing
a peer across a human-only gate. The rescue turned the first into something
indistinguishable from the second, and the Operator correctly refused and asked
for the author's own words.
The lesson generalises past this ADR: unsubmitted human input should be surfaced to its author, not rescued by a third party. Any repair that routes a human's instruction through an agent converts authorisation into hearsay, and no amount of care by the relayer — this one captured the pane first and did not paraphrase — can restore what the envelope no longer carries.
The name is not the identity and never was. So impostor handling is described
honestly as reactive detection: start-architect --impostors reports live
sessions named architect that are not the pin, and its own output says it is
detection rather than prevention. It cannot stop one appearing.
claude --resume semantics — tested, not assumed
The "resume, never create" rule only holds if resuming preserves the sid, so it was measured rather than read off the flag's help. A throwaway session was pinned to a known uuid:
| run | result |
|---|---|
claude --resume <sid> |
same sid — one transcript file, grown by the new turn |
claude --resume <sid> --fork-session |
new sid — a second transcript file appeared |
The fork run is the positive control: it proves the instrument (transcript-file
enumeration) can see a new sid, so the plain run's single file is evidence and
not a blind spot. Consequence: the pin survives a resume, and --fork-session
is the one flag that would silently orphan the ledger.
Consequences
One session may hold the cross-task block ledger, so an answer always has a live executor. A forced replacement inherits the rows by adoption rather than by re-asking Gera a question he has already answered.
start-architectfails closed: it refuses onaliveand onundeterminable. The two mistakes are not the same size — refusing when nothing is running costs one--force; minting a second Architect splits the ledger into two halves that cannot see each other.The Architect can no longer start work any way but
start-operator. Ifstart-operatorcannot do something, that is a bug to fix there, as a task — not a recipe to re-open in the skill.session_aliveinblockednow matches a full sid exactly (it previously truncated every caller's id to 8 hex characters) and tests every matching roster row rather than the first. Both were live defects:claude agents --jsonwas observed listing one sid twice, with different pids and different names, so the old code's answer depended on roster ordering — and the wrong half of that coin flip isdead, the answer that hands a live session's open blocks to somebody else.Not covered here, and stated so nobody reads it as done: there is no periodic sweep flagging impostors. Detection is on demand (
start-architect --impostors). Wiring it into a Supervisor sweep or the /agents architect tab is separate work with a separate owner.Also deliberately not fixed here, only documented (§2d and above):
blocked publishis not a subcommand and silently falls through to the list; adoption leaves the decision page under the previous owner's sid; andblocked verify(local ledger) can contradict the drive-gate (origin).These are KNOWN BUGS WITH AN OPEN DESIGN QUESTION — not properties of adoption, and not accepted behaviour. The open question is whether adoption should republish under the adopting sid or whether the gate should accept the original raiser's page; those are different systems with different owners, and settling it opportunistically inside this change risks proving neither. Decided by
fable-decide, exit 0 RESOLVED and held under reversal — receiptf25f16c5f(~/.claude/jobs/fable/f25f16c5f.json). It is cited here because the next reader will otherwise re-ask why a change that documents a defect did not fix it.⚠️ The stated cost if that call is wrong is that the limitation calcifies into accepted behaviour, and wording is most of what decides that. Hence the phrasing above and in §2d: a bug awaiting a design decision, with a named owner. That owner is task
adoption-republish-truth(operator sid35def666-fb67-4d34-aea5-cb02019c20e1, goal at~/.claude/jobs/goals/adoption-republish-truth.md). Its criterion carries all three defects as one unit — republish-on-adopt, theverify/gate contradiction, and the gate's inaccurate wording — because fixing any one alone leaves a liar in place. It also carries a fourth, found while unwedging:blocked publishis not a command, so unknown verbs fall through tocmd_listand silently print the list, which reads as "it ran and did nothing wrong".⚠️ The design choice was deliberately handed on open rather than pre-resolved, and the unwedging that made the two rows publishable was a hand-patch on those two rows, not a working mechanism. Neither the unwedged state nor this ADR should be read as evidence the gap is closed.
Concentrating every start into one door concentrates its defects too, and one is now fleet-wide. This decision removed the alternative spawn path and made
start-operatorthe only way work begins. That is the right trade, and this is its bill.Measured 2026-08-08: eight of ten live Operators had their pane working directory in the same shared checkout,
~/code/PropFlow/agentflow— and that checkout sits on whatever branch was last active in it, which at the time of writing was this ADR's own branch.start-operator --repo <path>points every operator it mints at that one directory; there is no per-operator worktree and no branch pinning, so whoever last checked something out decides what eight other Operators are standing on.⚠️ State the scope honestly, because the headline overstates it. Nothing was contaminated: the checkout was clean (
git status --porcelainempty), and the real work is already isolated — the repo carries twelve worktrees, each on its own branch, and every affected PR lives on one. What is exposed is the pane cwd only: a baregit commit,git addorgit checkouttyped without-Clands on a branch belonging to someone else. Latent hazard, observed damage none.⚠️ UPDATE 2026-08-09 — "latent" was too kind, and the sharper case is a DIFFERENT repo:
~/.claude. The same shape there is not latent at all, because that repo's working tree is the running system.skills/*/SKILL.mdis the doctrine every agent loads andhooks/*is code the fleet executes — both tracked files, so whichever branch is checked out decides how the fleet behaves.Measured:
~/.claudewas sitting onfix/drive-gate-stop-prescribing-a-harmful-compact, a branch whose last commit was 29 hours old, and had diverged frommainin both directions at once:- the branch carried a live
hooks/drive-gate.py(−157 lines, removing advice whose harm had been measured) and a rearm-hook fix — executing fleet-wide, unmerged; maincarried this ADR's own §2d edits — merged, not live. Two commits vanished from the live skill without anything being reverted or lost; they were simply on the other branch.
So the failure needs no mistake by anyone. Nobody typed a wrong command; two parties committed correctly to different branches of one checkout, and the fleet ran a mixture of both. ⚠️ And the recovery is not
git checkout main— that would restore the doctrine by reverting another session's shipped harm-reduction out of the running hooks, which is the same defect in the opposite direction. The only correct exit is to merge, then return the checkout tomain; until then every reader of a skill file is reading whatever branch someone left behind.This makes the "refuse to spawn into a checkout that is not on
main" option above look less like a convenience and more like the load-bearing one — and it should arguably apply to~/.claudebefore it applies to a code repo, because there a stale branch changes documents, and here it changes behaviour.⚠️ UPDATE 2026-08-09, later — "observed damage none" is now FALSE, and a third repo is the one that proves it:
~/.local/bin. It has no remote, so every session commits straight to one local history. Observed there:aeaa9d2 "nudge: a watcher must not retire on a transient liveness read" nudge +152 ← the author's own change ccswitch +430 · decisions-dock +235 · blocked +87 ← three OTHER sessions' workOne session's commit swallowed three other sessions' in-progress files under a message describing none of them. It was self-corrected and nothing was lost — but "nothing was lost" is the outcome of someone noticing, not a property of the arrangement. At the time of writing that checkout has 10 dirty tracked files from several sessions, and a single
git commit -apublishes all of them under whatever message the committer happened to be writing.So the three instances form a progression, and it runs the wrong way:
~/code/PropFlow/agentflow— exposure confined to pane cwd, latent;~/.claude— the working tree IS the running system, the fleet ran a mixture of two branches;~/.local/bin— a cross-session commit actually happened. The first draft of this section called the hazard latent on the strength of the first instance alone. A hazard measured in the one place it happens to be harmless is not a hazard that has been measured.So the gap is narrower than "add worktree isolation" — that pattern exists here and works. The door simply points at the repo ROOT rather than at the worktree for the task it is minting. Two candidate fixes, recorded open for the same reason as the adoption gap above: create a worktree per Operator off freshly-fetched
origin/main, or refuse to spawn into a checkout that is not onmain. The second is cheaper and fails closed; the first matches the standing rule that every worktree starts from freshorigin/main.⚠️ This is RECORDED, not scheduled. No owner was minted, deliberately: all six accounts were at 96–100% of the weekly cap and spawning had been stopped, with ten lanes already at risk of dying at it. Capacity is a real constraint on when a known defect gets fixed, and pretending otherwise is how a backlog becomes a lie. The finding is here so the next reader inherits it rather than rediscovers it — and the operative warning until then is that the next person to check out a branch in that directory moves the ground under eight Operators.
- the branch carried a live
Alternatives considered
- Delete the Architect; let Operators handle everything. This was the competing position. Rejected: its evidence was the manufactured zero above, and the three-generation adoption chain shows the class of block it assumed away. Operators own one task each; a cross-task question has no Operator, and a Supervisor cannot execute a decision.
- Keep the role as-is. Rejected: §2b's raw-tmux recipe was a live second answer to "how does work start", and the delegate table licensed exactly the hands-on drift the role exists to avoid.
- Reserve the name
architect. Rejected as unenforceable — see above. A guarantee that can only be written in a document is the failure mode being removed, not a mitigation of it. - A forwarder stub, as was done for
god→architectandchild→worker. Rejected: those worked because they were renames onto a same-shaped successor. Forwarding "drive these to completion" into operator doctrine ("you own ONE task") is a semantic mismatch, and/architectremains the live trigger for the narrowed doctrine.