0015 — Red main has one owner; every other lane holds

The rule

  1. An external blocker is not the lane's to fix. Main red, a shared service down, a quota wall, a merge a human is holding — a lane that meets one reports it to its Operator (or the coordinator) in one message and holds. Holding means no branch, no PR, no fable-decide about the blocker, no "small fix while I wait". Work the blocker does not stop continues.
  2. Exactly one owner, the first reporter by default. The Operator assigns one lane (or Worker) to the blocker and writes the assignment where every other lane can read it before telling anyone. For red main the record is the Supervisor's claim at ~/.claude/jobs/red-main/claim.json; for anything else it is a task-event on the timeline whose --ref points at what the owner is doing.
  3. Every later report gets one answer: "already assigned to <lane>." The reporter holds. It does not open a PR, cut a branch, or consult Fable about whether its fix would have been better — an assigned blocker is someone else's lane, not an open question.
  4. While main is red, lanes are more passive. No merge to main except the assigned fix — no Operator drives one, requests one, or queues one. When the claim releases (main green), the hold ends on its own.
  5. Everything that opens a fix for red main reads the claim first. Smith's alert-remediation, any human-facing alert (the #alerts post, the Slack thread that names a red), and the Supervisor's own red-main lane all consult claim.json before opening a PR; the Supervisor writes the claim when it mints the lane — or when it adopts a live red-main-* lane a human minted via /operator after the incident opened (agentflow #124) — and nothing else writes it (one writer per artifact — ADR-0003).

Why — 2026-09-10, in local time (CDT)

Main was red through the morning: every push-to-main CI run from at least 11:19 (8138094cd) to 12:14 (e638177e1) concluded failure, and the Supervisor had opened incident inc-mtvpacz7-mtvpaekc-ohhqdtt9 on 748b7366a at 10:47:03.

At 10:47:05 and again at 10:48:04 the Supervisor took the red-main claim and tried to mint the owner; both times start-operator exited 75 — session-cap refused the start, the machine was at 37/35 sessions — and each claim was released within a second (jobs/red-main/history/released-…5dmcibt9.json and …mvv2jswd.json: outcomeWhy: "the mint failed: start-operator exited 75"). Each refusal counted as a consumed owner, so at 10:49:06 the phase hit its mint cap (MAX_MINT_ATTEMPTS=2), logged NOT MINTING … A human needs to look every sweep, and its red-main-mint-cap alarm was delivered to the architect at 10:49:28. Gera minted lanes himself via /operatorred-main-748b7366 at 10:51 and red-main-handoff-fence at 12:16 (both requestedBy: "gera via /operator") — and neither could become the claim, because only the Supervisor's own mint wrote one. From 10:48 to 12:36 the red had no owner on disk.

At 11:51 PR #7509 merged (e0fe65679b) and added the failure the rest of the afternoon fixed — a hand-off write fence in the eval harness reading prose inside a string literal as a write. Three fixes for that one failure then opened within about a minute of each other:

Time PR Opened by What it did
12:26:33 propflowai #7609 Gera's /operator lane red-main-handoff-fence (not a Supervisor mint — both mints were refused) fixed the fence
12:27:16 propflowai #7610 Smith's alert-remediation narrowed the fence's fail-open path
12:27:35 propflowai #7611 a human engineer fixed the fence

None of the three could see the others, because there was no claim to read. The ladder's verdict chose the prose fix; the fail-open fence narrowing (#7610) auto-merged at 12:36:22, before that verdict could be applied; #7609 was closed at 12:36:30 and #7611 merged at 12:36:51. Two fixes for one red landed on main in twenty-nine seconds, and three sessions spent ten minutes on work one of them was going to keep.

This is the failure supervisor/src/redmain.ts was written for on 2026-08-08 ("sometimes multiple agents want to fix it and they step over each other"). The mutex worked as designed — it is atomic, and it released when its mint failed. What was missing is the layer above it: nothing said that a lane which finds red main is supposed to read the claim rather than fix the red, and nothing said who decides when the claim is empty. This ADR is that layer.

What exists and what is in flight

Surface State
~/.claude/jobs/red-main/claim.json, incident.json, history/ Exists — the Supervisor's atomic claim (redmain.ts, since 2026-08-08). Written only by the Supervisor, on mint or (agentflow #124) on adopting a live human-minted red-main-* lane; released on green, on a failed mint, or on a dead owner.
The three red-main alarms — red-main-owner-parked, red-main-mint-cap, red-main-claim-broken Existsupervisor/src/escalate.ts; they reach the ops alarm ledger when the claim cannot do its job.
Operator skill §4d and the §1 wake-table line This decision~/.local/bin/canon/operator-SKILL.md (the file ~/.claude/skills/operator/SKILL.md links to): https://github.com/gera-propflow/local-bin/pull/103
The Worker's report channel, STUCK <name>: <blocker in one line> Exists, unchanged — the worker skill. Rule 1 reuses it rather than adding a second door.
The claim as the record every fixer reads — Smith's alert-remediation and the human-facing alert consulting claim.json before opening a fix; the Supervisor's claim standing as the assignment for the lane it mints Landing — agentflow #124 (the Supervisor adopts a human-minted red-main-* lane into the claim, logs red-main owned by <task> since <ISO> on every tick it is held, and rewrites status.json every sweep) and the sibling agent-smith PR (Smith reads claim.json + incident.json before opening a push-main-red fix and stands down in the alert thread when a live owner exists).
SYSTEMS.md red-main row Lands with agentflow #124: file-age + json-file detail on ~/.claude/jobs/red-main/status.json (300 s) and launchd on com.propflow.agentflow-supervisor. Not claim.json: it is absent for the whole of a green day, and absent must never read as dead. Until the supervisor deploys the row reads unknown on the Systems tab (ADR-0011), never healthy.

How it relates to the ladder

Consequences

Signals

The record is ~/.claude/jobs/red-main/claim.json (present = the red has an owner; absent while incident.json is open = the mint failed or the mutex is unusable — the red-main-claim-broken and red-main-mint-cap alarms in the ops alarm ledger), with ~/.claude/jobs/red-main/history/ as the append-only ledger of every claim and its release. SYSTEMS.md has no red-main row as of this ADR — the row and its liveness signals land with the in-flight mechanism PR, and until they do the Systems tab reads unknown for it. This line is the pointer to the record, not the proof of liveness; the proof is the catalog row — agentflow #124 adds it, reading status.json (rewritten every sweep with ownedBy, ownedSince, incident, red, sha) for liveness and showing the claim through the detail block, because claim.json's absence on a green day is not evidence of anything.

Amendment 2026-09-16 — one owner across BOTH swarms: the claim is mirrored to Slack as BRIDGE CLAIM v1

The gap

The two swarms are on two machines: Smith and the Supervisor on MiniClaws-Mac-mini, Trinity on Federicos-Mac-mini. ~/.claude/jobs/red-main/ is NOT shared, so a fixer on either seat that obeyed rule 5 to the letter read a claim the other seat could not see. Slack is the only common layer, and the bridge channel already carries a parsed message grammar (bridge_protocol in agent-smith), so the mirror is a message in that grammar and not a new channel.

The decision

  1. Episode id, not sha. One id from the first red until main goes green: red-main-<firstSha>, where firstSha is the episode's FIRST failing sha, never the current head — a fix push makes a new sha and the same episode. The FIRST CLAIM posted in the id space names the item; everyone else READS it from the board — rule 2 restated for the seam. Deriving first red after last green from GitHub is the empty-board fallback only. ⚠️ red-main-unknown gets NO CLAIM — a defensive rule, and it is worth saying which. openIncident opens on sig.sha ?? 'unknown', which reads like a live case and is not one: the red path is reached only past sig.red === null and sig.red === false, and the single red: true constructor spreads a base that always carries run.head_sha.slice(0, 9) — so no red: true this detector builds can have a null sha, and the ?? 'unknown' branch is unreachable from here today. The rule is written anyway because the signal arrives through a deps reader: a future or foreign reader that yields red: true with no sha would otherwise mint an id that names no commit, which the other seat cannot match by ancestry and so cannot honour. If one ever does, the Supervisor posts nothing and re-reads next sweep, posting the CLAIM on the first sweep with a real firstSha.

  2. The item string is CANONICAL, not recommended: red-main- + the first 9 characters, lowercase, of the episode's first failing sha. Why — Trinity's measurement on agent-smith (python 3.13.12), verbatim: bridge_claims.claim_key("ci","red-main-cc7f32224")bridge:ci:red-main-cc7f32224; the same episode at the full 40-char sha → bridge:ci:red-main-<full>; KEYS EQUAL: False; red_main_owner._same_commit(short, full) → True. The sha COMPARISON is prefix-tolerant in both directions, but the board KEY is the literal item string, so two seats posting one episode at two sha lengths mint two keys, which is two owners, which is the duplicate-PR failure this ADR exists to end. Nine is not arbitrary: it is what slugForSha already writes for the lane name and what the detector already puts in status.json (sha is sliced to 9 before it is stored). Receiver rule: a NON-canonical item — wrong length, uppercase — is still matched by ancestry through the existing _same_commit / git_is_ancestor path and honoured as an OWNER. A mis-shaped claim degrades to held, never to free; ignoring it would be the §4 masking fallback with a live rival behind it. The agent-smith reader PR pins this with a test.

  3. 15-minute TTL, with heartbeat. CLAIM_TTL_MS in redmain.ts is the ONE number — 15 minutes by default, env-overridable, and until derives from whatever it holds rather than from a second constant. Takeover is licensed only past 15 minutes of NO heartbeat. Trinity's script text said 30; it changes to 15 on their side.

  4. Heartbeat = EDIT the CLAIM message's until in place. Never a new post per beat — Fede's rule is one live status message edited in place, and #alerts/bridge noise is a standing complaint. ⚠️ Smith's socket DROPS message_changed today (slack_socket.py, the noise-subtype filter), so an edit-heartbeat is invisible to this seat until the reader PR admits edits for bridge CLAIM posts. Both seats must admit them — and the admission carries a trap that must be closed in the same change. On a message_changed event Slack nests the author under message.user; the TOP level carries neither user nor bot_id. Every own-seat guard in the socket reads the top level — _sibling_ok returns False on uid == self.bot_uid, and is_human_message drops on bot_id — so on an edit both miss, and today the event survives that only to be dropped by the subtype rule. Admitting the subtype without teaching those guards to read the nested author therefore turns the own-seat check off for exactly the events the heartbeat produces: a seat would read its OWN until edit as a sibling CLAIM. That is this ADR's own failure — one red, two owners — arriving through the door this amendment opens, so the reader PR reads the nested author and pins it with a test. ⚠️ And that test cannot be built on the fixture already in the repo: tests/test_slack_socket.py's message_changed case is FLAT — {"user": "U1", "subtype": "message_changed", "text": "edited"} — which carries a top-level user that a real edit event does not have (Slack's own reference payload nests the author under message and puts neither user nor bot_id at the top level). A test that copies that shape passes while the defect ships, which is the same instrument failure this ADR keeps meeting: a guard that cannot see is worse than no guard.

  5. Release = EDIT the live CLAIM to state: released, never a new post — the same one-live-message rule. On this seat that edit happens at every endClaim site, named by the rule and not by a list — a list here was already wrong once, having missed the late mint reaction that ends a claim after start-operator exits. And the internal outcome word is NOT the wire word: endClaim ends a claim as released, refused or orphaned, while the wire shape has only claimed | released. All of them mirror as released, because the only question the other seat is asking is is this red still owned by you, and for every one of them the answer is no. The mirror ends exactly when the claim does. If the claim carries no persisted message ts — the post never succeeded — there is nothing to edit: the Supervisor logs that and posts nothing. And state: released ends ownership on its own: the reader's test is the conjunction state is not released AND until is not past, so a released row is free whatever its until says.

  6. until — the emit/accept split. Nothing parsed until before this; the reader PR makes it load-bearing and this amendment defines it. Canonical emit: ISO-8601 UTC with seconds in the +00:00 offset form — 2026-09-16T21:45:00+00:00 — which is the shape _now_iso() in bridge_claims emits, truncated to whole seconds. Accept on read: anything datetime.fromisoformat parses on Python 3.13, which includes Z and fractional seconds. ⚠️ An unparseable until NEVER reads as expired — the rule bridge_claims.is_expired already states for claimed_at: fabricating expiry from a value you cannot read is a bad row silently reopening a live claim. until = heartbeatAt + the TTL.

  7. The wire shape is bridge_protocol's _FIELD_ORDER["CLAIM"], in that order. Trinity's original id: / driver: shape does not parse, and they agreed to match this one:

    BRIDGE CLAIM v1
    lane: ci
    item: red-main-<firstSha>
    seat: smith | clara
    human: Gera | Fede
    state: claimed | released
    until: <ISO 8601, = heartbeatAt + 15m>
    
  8. What a fixer does on seeing the OTHER seat's live CLAIM — state: claimed, until not past: it stands down, it holds, and it opens no PR. Rule 3's one answer, already assigned, now has a cross-seat source. On this seat the reader is Smith's alert lane (red_main_owner.read_owner): the file is its own side, the board is the other seat — the Supervisor posts under Smith's own bot user, and the bridge admission path (_sibling_ok, consulted BEFORE the _is_echo bot drop, which is what lets a sibling's bot post in at all) refuses uid == self.bot_uid — so Smith never reads its own mirror as a rival. That guard is an own-uid check on the top-level author, which is why decision 4's edit case has to be closed with it. The Supervisor itself posts and never reads: it extends the announcement it already makes and adds no second poller (Constitution §1). ⚠️ Stated limit: because of that, a red that Trinity's seat claims first, while this Supervisor's own detector also reads red, is still minted here. That is a limit of this amendment's scope, written down so it is measured rather than discovered; it is the next thing to decide if it ever happens.

  9. One writer for claim.json — rule 5, unchanged: the Supervisor. Trinity's lanes never write it. If one of theirs owns a red, it mints a red-main-* lane and posts the CLAIM; adoption into claim.json is local to the machine the lane runs on.

  10. Fix-forward-in-15-else-revert is Driver policy, not the mutex. It is not encoded here.

Where it is implemented

Inventory of the surface that states the old shape (ADR-0004 §2)

Surface State
This ADR's What exists table and Signals paragraph — "SYSTEMS.md has no red-main row as of this ADR" Landed since: the red-main row exists with file-age + launchd signals on status.json. Not edited in place — the body's line numbers are cited elsewhere, and the row's own prose is the near copy.
SYSTEMS.md red-main row ✅ Gains a _note in this commit saying the claim is mirrored to Slack, keyed off the same claim.json. No new signal: a Slack message ts is not liveness.
supervisor/src/redmain.ts RedMainAnnouncement — kinds taken / green, one surface ⚠️ Extended by the Supervisor PR above.
agent-smith slack_socket.py — drops message_changed ⚠️ Admitted for bridge CLAIMs by the reader PR above.
agent-smith bridge_protocol _FIELD_ORDER["CLAIM"] ✅ Unchanged. The wire shape above IS its field order.
Trinity's red-main-claim.sh / bridge-post.sh ⚠️ Their side, their PR: episode firstSha, TTL 15, heartbeat by edit, an expired until reads as free.

Amendment 2026-09-17 — a CLAIM is posted TOP-LEVEL, and a duplicate owner is EXPECTED until the Supervisor reads the board

Rule A — a CLAIM is posted TOP-LEVEL, never as a thread reply

This binds BOTH seats, and it is a protocol rule, not a preference. The sibling seat's scan (red-main-claim.sh) does not consume events at all: it polls conversations.history over a 3-hour window and reads message text. conversations.history returns only top-level messages and thread parents — never the replies (agent-smith's own read_thread_replies docstring states this as the reason that method exists). So a CLAIM posted as a thread reply is invisible to the seat it exists to inform, and invisible in the direction that does the damage: an unseen claim reads as FREE, which mints a second owner.

Measured on the bridge channel itself, not argued: conversations.replies on the bridge thread returns 22 messages — the root plus 21 replies. conversations.history over the same window returns 9 messages, and 0 of those 21 replies are among them. The thread ROOT is present, which is the control that makes the zero mean something: the call worked and covered the window, and still could not see a single reply.

⚠️ Our own Supervisor posts top-level today by ACCIDENT OF ENVIRONMENT, not by assertion — so the Supervisor PR pins it. slack_post threads a post when SMITH_CONV_THREAD_TS is set (slack_post.py: --thread-ts defaults to that variable). That variable is absent from the Supervisor's launchd environment — verified: 0 hits in launchctl print and 0 in the live plist, against a positive control that finds PATH in the same output. An absence nobody has stated is a default waiting to change: the day something sets that variable in the daemon's environment, every CLAIM silently moves into a thread and the mirror goes dark while still looking healthy. The Supervisor therefore passes the thread argument explicitly empty rather than relying on the variable being unset.

Stated residual, from slack_post's own help: inside a night-queue job, a post with no effective thread still lands under the run banner via slack.py's deliberate SMITH_QUEUE_THREAD_TS inheritance, and that mechanism "is not overridable from here." It does not bind the Supervisor — that variable is injected only into a job subprocess's environment, never the worker's — but any future caller that posts a CLAIM from inside a night-queue job would thread it, and would not be able to stop it at the CLI. Such a caller needs a different door, not a flag.

Rule A′ — the message_changed rule binds THIS seat only

Amendment 2026-09-16 decision 4 tells the reader to admit message_changed and to read the author from the nested message object, because on an edit Slack puts neither user nor bot_id at the top level. That finding stands unchanged and its guard must not be weakened — it is what stops this seat reading its own heartbeat edit as a sibling CLAIM.

What narrows is its SCOPE. It was written as something both seats need, and the sibling seat never sees that subtype at all: a history poll returns an edited message as an ordinary message, author at the top level, plus an edited field. The subtype is an artefact of consuming the event stream, so the rule binds whoever consumes events — us — and is simply not a both-seats rule. The heartbeat itself still works for them, because they re-read the message text and see the new until.

Rule B — a duplicate owner is EXPECTED in the interim window

The asymmetry from Amendment 2026-09-16 decision 8 is now accepted with eyes open rather than merely noted. Once the reader lands, the sibling seat stands down on our CLAIM. Our Supervisor posts and does not read, so it can still mint its own fixer on a red the other seat has already claimed.

So a Supervisor-minted fixer appearing beside the other seat's claim is EXPECTED in this window. It is not a protocol violation, and it is not a broken claim — nothing should alarm on it, and nobody should go looking for the bug.

The rule when it happens: whoever notices says so in the bridge channel, naming BOTH owners, and the PR that is LESS FAR ALONG closes. Never both drive to merge. ⚠️ This is not a licence for two fixers to race — it is the cheapest correct handling of a known one-directional gap, and the expensive part (two green PRs for one red, landing seconds apart) is exactly what rule 3 exists to prevent.

This clause dies when the Supervisor reads the board. It is scoped to the window in which the mirror is write-only from this side, and it should be deleted — not amended — the day that changes.

A known dependency on the sibling seat, recorded as theirs

conversations.history's limit defaults to 100 messages. On a busy bridge channel a 3-hour window can push a live CLAIM off the first page, and an unseen claim reads as FREE — the same failure direction as Rule A, reached by a different road. Their scan should paginate, or bound the read by ts, rather than trust one page. Recorded here because it is a dependency this protocol has on their implementation, not because it is our work.