0015 — Red main has one owner; every other lane holds
- Status: Accepted (the rule). The mechanism that makes the claim the record every fixer reads is in flight — see What exists and what is in flight — and this ADR does not wait for it, because the rule governs sessions today and the incident below happened without it. ⚠️ 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.) ⚠️ AMENDED 2026-09-16 — THE CLAIM IS MIRRORED TO SLACK ASBRIDGE CLAIM v1, BECAUSE THE TWO SWARMS SHARE NO DISK. Trinity's swarm runs on Federicos-Mac-mini and this one on MiniClaws-Mac-mini;~/.claude/jobs/red-main/exists on only one of them, so rule 5's reads the claim first had nothing to read across that seam, and Slack is the only layer both seats can see. The Supervisor now posts ONEBRIDGE CLAIM v1per incident to the bridge channel, heartbeats it by editinguntilin place, and edits it toreleasedon every end path; each seat's fixer reads the OTHER seat's live CLAIM and holds. Rules 1–5 are unchanged in text; the canonical item string, the TTL, theuntilformat and the single-writer rule are settled in Amendment 2026-09-16 at the foot. (Appended toStatuson purpose: this adds NO line, so every line-number citation into this file stays true.) ⚠️ AMENDED 2026-09-17 — A CLAIM IS POSTED TOP-LEVEL, AND A DUPLICATE OWNER IS EXPECTED UNTIL THE SUPERVISOR READS THE BOARD. The sibling seat's scan readsconversations.history, which does not return thread replies — measured on the bridge channel itself: 0 of 21 replies present, the thread root present — so a CLAIM posted in a thread is invisible to the seat it exists to inform, silently. And until the Supervisor READS the board it can still mint a fixer beside the other seat's claim; that duplicate is expected in this window and has a stated resolution rather than a race. Amendment 2026-09-16's nested-author rule also narrows: it binds this seat's event-consuming reader only. See Amendment 2026-09-17 at the foot. (Appended toStatuson purpose: this adds NO line, so every line-number citation into this file stays true.) - Date: 2026-09-10
- Deciders: Gera, 2026-09-10, about 12:40 local, forty minutes after the race below: "how do you keep things in control if main is red: you have to coordinate, and maybe the sub-agents have to be a little more passive… if there's an external blocker, check in with the operator. And then once you get nudged, you assign ONE — 'okay, you do it' — and then the rest will come back and be like 'oh, we're blocked', and it's like 'oh, I already assigned this other session'. So the first one to come back is the one that pings you." Written at decision time by the Worker in the room, because ADR-0004 says a decision that changes how systems relate is not done until it is written here.
The rule
- 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-decideabout the blocker, no "small fix while I wait". Work the blocker does not stop continues. - 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--refpoints at what the owner is doing. - 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.
- 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.
- Everything that opens a fix for red main reads the claim first. Smith's alert-remediation, any human-facing alert (the
#alertspost, the Slack thread that names a red), and the Supervisor's own red-main lane all consultclaim.jsonbefore opening a PR; the Supervisor writes the claim when it mints the lane — or when it adopts a livered-main-*lane a human minted via/operatorafter 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 /operator — red-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 |
Exist — supervisor/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
- ADR-0002 — the report and the "already assigned" answer travel by SendMessage (the Worker's
STUCKline; the Operator'smsg). Nothing here touches a pane. - ADR-0003 — the claim is a signal, not a call. The Supervisor writes it; Operators, Smith and humans read it; no rung asks another rung for permission. The Operator never writes the claim, for the same reason it never rotates a credential.
- ADR-0009 — a missing claim while
incident.jsonis open is not "nobody is fixing it, quietly". It is thered-main-claim-broken/ mint-failed shape, and rule 2 says the Operator names it in its next report rather than every lane noticing on its own. - ADR-0012 — the merge refuses under a person, and this ADR narrows it further while main is red: the assigned fix is the only merge to main any lane queues.
- ADR-0013 — the assignment is a timeline row (
task-event … --kind steered --ref <PR>), so a red that was owned but never fixed is visible after the fact, the way an answered-but-unexecuted decision is.
Consequences
- Three PRs for one red becomes one PR and two holds. The cost moves from ten minutes of triplicate work plus a merge race to one message per extra lane.
- A lane that finds red main gets slower, on purpose. It reports and waits for one answer instead of branching. Gera asked for exactly this: "the sub-agents have to be a little more passive."
- An empty claim during a red is now a reportable defect, not a vacancy. The Operator that meets it records the assignment on its timeline and says the claim is missing; the mechanism PR makes the Supervisor's claim survive as the record so this case becomes rare.
- Smith and the human alert read before they write. Until the in-flight mechanism lands, that reading is a rule the sessions follow; after it lands, it is code.
- What this does not do: it does not merge anything, mint anything, or change what turns a claim red or green. The detector, the mutex and the mint are
redmain.ts's, unchanged.
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
- Amends: rules 2 and 5 gain a cross-seat copy of the record they already name. No rule's text changes: the claim is still the record, the Supervisor is still its only writer, and every fixer still reads before it opens a PR. What changes is where a fixer on the other machine can read it from.
- Date: 2026-09-16
- Deciders: Fede's directive — "always green AND shipping" — carried by Trinity's bridge handoff
red-main-single-driver-protocol-2026-09-16in #engineering-bridge (threadC0C10AS4U15/1789612158.851499), and Smith, settled seat-to-seat in that thread on 2026-09-16 at about 21:30 CT. Gera reviews via the PR that lands this text. - Why an amendment and not an ADR-0028: the decision — one owner, every other lane holds — is unchanged. What was missing was its reach: the record lived on one disk and the second swarm cannot read that disk. ADR-0020's rule applies — two records of one decision is the defect — so the reach is written into the ADR that holds the decision, as ADR-0021's own amendment was.
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
Episode id, not sha. One id from the first red until main goes green:
red-main-<firstSha>, wherefirstShais 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-unknowngets NO CLAIM — a defensive rule, and it is worth saying which.openIncidentopens onsig.sha ?? 'unknown', which reads like a live case and is not one: the red path is reached only pastsig.red === nullandsig.red === false, and the singlered: trueconstructor spreads abasethat always carriesrun.head_sha.slice(0, 9)— so nored: truethis 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 adepsreader: a future or foreign reader that yieldsred: truewith 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 realfirstSha.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 whatslugForShaalready writes for the lane name and what the detector already puts instatus.json(shais 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_ancestorpath 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.15-minute TTL, with heartbeat.
CLAIM_TTL_MSinredmain.tsis the ONE number — 15 minutes by default, env-overridable, anduntilderives 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.Heartbeat = EDIT the CLAIM message's
untilin 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 DROPSmessage_changedtoday (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 amessage_changedevent Slack nests the author undermessage.user; the TOP level carries neitherusernorbot_id. Every own-seat guard in the socket reads the top level —_sibling_okreturns False onuid == self.bot_uid, andis_human_messagedrops onbot_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 OWNuntiledit 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'smessage_changedcase is FLAT —{"user": "U1", "subtype": "message_changed", "text": "edited"}— which carries a top-leveluserthat a real edit event does not have (Slack's own reference payload nests the author undermessageand puts neitherusernorbot_idat 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.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 everyendClaimsite, 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 afterstart-operatorexits. And the internal outcome word is NOT the wire word:endClaimends a claim asreleased,refusedororphaned, while the wire shape has onlyclaimed | released. All of them mirror asreleased, 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 messagets— the post never succeeded — there is nothing to edit: the Supervisor logs that and posts nothing. Andstate: releasedends ownership on its own: the reader's test is the conjunction state is notreleasedANDuntilis not past, so a released row is free whatever itsuntilsays.until— the emit/accept split. Nothing parseduntilbefore this; the reader PR makes it load-bearing and this amendment defines it. Canonical emit: ISO-8601 UTC with seconds in the+00:00offset form —2026-09-16T21:45:00+00:00— which is the shape_now_iso()inbridge_claimsemits, truncated to whole seconds. Accept on read: anythingdatetime.fromisoformatparses on Python 3.13, which includesZand fractional seconds. ⚠️ An unparseableuntilNEVER reads as expired — the rulebridge_claims.is_expiredalready states forclaimed_at: fabricating expiry from a value you cannot read is a bad row silently reopening a live claim.until=heartbeatAt+ the TTL.The wire shape is
bridge_protocol's_FIELD_ORDER["CLAIM"], in that order. Trinity's originalid:/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>What a fixer does on seeing the OTHER seat's live CLAIM —
state: claimed,untilnot 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_echobot drop, which is what lets a sibling's bot post in at all) refusesuid == 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.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 ared-main-*lane and posts the CLAIM; adoption intoclaim.jsonis local to the machine the lane runs on.Fix-forward-in-15-else-revert is Driver policy, not the mutex. It is not encoded here.
Where it is implemented
- agentflow, the Supervisor:
announceRedMain/RedMainAnnouncementextended, no new poller — ontaken, ALSO post the CLAIM to the bridge channel and persist the messagetsON THE CLAIM throughredmain-claim.py updateunder the lock, never a side file; on every sweep that refreshesheartbeatAt, edituntil; at everyendClaimsite, edit toreleased. The plist template gainsSLACK_BRIDGE_CHANNEL; the LIVE plist install and./deploy.share a separate, surfaced step with a receipt, because a merged mirror with no channel ships dark. - agent-smith, the poster's
--update <ts>: a thin flag on theslack_postCLI over the existingupdate_message— the Supervisor's dependency, and its own small PR (agent-smith#581, open as this lands). The other two are not open yet and are described here by repo and content rather than by a number this text would only guess at. - agent-smith, the reader: an inbound sibling CLAIM writes the board (
put_claim);message_changedis admitted for bridge CLAIM posts;read_ownergains the board as a SECOND source after the file, matched byfirstSha/ ancestry, other seat, not released,untilnot past; the stand-down line reads well with no local task and no PR. Tests pin the canonical item, the receiver rule, and theuntilsplit. - A sample CLAIM before the first real red: one
BRIDGE CLAIM v1on the fixed dummy itemred-main-0badc0ffe, seatsmith, humanGera, through the full lifecycle — postedclaimed, oneuntiledit, edited toreleased— in the bridge thread, so Trinity's scan runs against a real post and no scan ever reads a live owner from it. - Trinity's side: the script hands the episode's FIRST failing sha, never the head; TTL text 30 → 15 with the edit-heartbeat.
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
- Amends: Amendment 2026-09-16. It settled what a CLAIM says and did not settle where it is posted, which turns out to decide whether the other seat can read it at all. It also scopes that amendment's
message_changedrule, which is right for this seat and does not bind both. - Date: 2026-09-17
- Deciders: Trinity (Fede's seat) and this Driver, seat-to-seat in the bridge thread (
C0C10AS4U15/1789612158.851499), 2026-09-17, after the first amendment landed and each side read the other's implementation.
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.