0051 — A human's Slack thread has one voice: the session that owns it, and a finding reaches that owner as a signal (retired for the Agent Smith bot, 2026-11-02)
- Status: Accepted, AMENDED 2026-10-12, then RETIRED FOR THE AGENT SMITH BOT 2026-11-02 by Gera — "Let sessions post anywhere": any session may post into any thread as Agent Smith; only the as-Gera thread guard remains.
(The renderer's badge takes only this first line — hence the whole caveat on
it. Two amendments are at the foot of this file: 2026-10-12 narrowed the
ownership predicate by about 4x, and 2026-11-02 retires the one-voice
refusal for the Agent Smith bot identity — read the second one first; the
rest of this document is the history of a rule that no longer binds the
bot.)
Decided by Gera in the thread itself on 2026-10-12 and
written the same day, at decision time, per
ADR-0004 — before any of the mechanism
merged. The five implementing action items are in flight on dock
one-thread-voiceand are named by row below; no PR number is cited here because none existed when this was written, and nothing had merged. - Date: 2026-10-12
- Deciders — split on purpose, because two different kinds of authority are
at work here and attributing a machine's inference to a person is the same
defect class as a post going out under his name:
- The decision is Gera's — a human's thread has one voice, only the owner
posts, a lane hands its finding up. Spoken 2026-10-12 in the thread the
failure happened in (
C0C50G2EBMK/1791764799.838879) and quoted verbatim at the top of The problem. - The narrowing — what makes a thread "a human's" — is the Driver's, ruled
2026-10-12 from measurement, not from his words: 23.6% / 24.9% of
<slug>-reply-<C…>-<ts>records carry a person-row, over denominators of 1455 and 1479 (see the Amendment). Gera never saw that number before the narrowing was made. It is surfaced to him in the Driver's own report so he can overrule it, and that channel stays open — this ADR records a Driver's ruling, not his ratification of it. - The
unknown-lookup answer is the Driver's, ruled on dock rowthread-post-gateas ruling 2 (see Amendment → theunknowncase). - Written up by the
adr-who-may-speaklane of theone-thread-voiceDriver, which wrote each ruling as given and reported its disagreements rather than editing them.
- The decision is Gera's — a human's thread has one voice, only the owner
posts, a lane hands its finding up. Spoken 2026-10-12 in the thread the
failure happened in (
- Systems: agent-smith
src/agent_smith/slack_post.py(thesmith-slack-postCLI),src/agent_smith/slack.py(post_messageand the outbound ledger),src/agent_smith/activities/slack_send.py(the owner's post/update activity),src/agent_smith/activities/conversation_log.py(log_message),src/agent_smith/thread_phase.py, the task-mint path ingera-propflow/local-bin, and the lane-finding inbox that row 5 builds.
The problem
Gera, 2026-10-12, in the thread:
why is there four sessions, and not just 1 with subagents? and id say only the main session can post, if subagents have something to say, they tell the main, and main can wait till it has a post opportunity to post meaningful and concise updates.
That thread (channel C0C50G2EBMK, thread_ts 1791764799.838879) ran to 28
messages. Four separate /driver lanes posted into it, all under the one Agent
Smith bot identity, so from the reading end it was one speaker contradicting
itself. Gera counted 17 posts from those lanes and 10 of them read as
corrections, retractions or un-retractions. The outbound ledger records 16
posts through the CLI in that thread. Both figures are kept here with their
instrument rather than reconciled: 16 is the number a tool can re-derive, 17 is
what the person reading the thread counted, and the difference does not change
the decision.
The measurement. Joining the outbound ledger to the conversation log, with both classes present so the instrument can discriminate (read-only):
sqlite3 ~/.claude/smith-state/outbound-ledger.db \
"SELECT source, kind, COUNT(*) FROM posts
WHERE thread_ts='1791764799.838879' GROUP BY source, kind;"
| source | kind | in the conversation log? | n |
|---|---|---|---|
activity:smith_slack_send |
post | yes | 7 |
activity:smith_slack_send |
update | yes | 4 |
activity:smith_thread_phase_render |
post | no | 1 |
activity:smith_thread_phase_render |
update | no | 1 |
cli:smith-slack-post |
post | no | 16 |
The join is on message TEXT, not on msg_ts, because the conversation log
stores no Slack timestamp. A first attempt joined on msg_ts and read
0-for-everything; the smith_slack_send rows are the positive control that
caught it, and any re-run needs to keep them.
Where each reference lives, since none of it is in this repo and a later
reader has to be able to check it: the ledger and the conversation log are
SQLite/JSONL under ~/.claude/smith-state/ on the mini; every src/… path
in this document is PropFlow-Technologies/agent-smith; the mint path is
gera-propflow/local-bin; the Fable receipt is ~/.claude/jobs/fable/ on the
mini; the dock and its rows are the one-thread-voice board. This repo holds
only the decision.
Three things that table establishes:
- The lane path is the CLI. Every unattributed post went out through
cli:smith-slack-post→slack.post_message, which records to the outbound ledger and nothing else. - The owner path already logs correctly —
activity:smith_slack_sendis 11-for-11 in the conversation log. The log works; the CLI simply never reaches it. "The ledger already records it" does not satisfy the conversation-log invariant: they are two different stores, written by different code. - The owner's identity is already on disk. Every conversation-log row the
owner wrote in that thread carries
workflow_id: smith-reply-C0C50G2EBMK-1791764799.838879. No new owner registry has to be invented.
And the duplication is at the MINT, not at lane dispatch. One Gera message
minted three tasks: one-thread-voice at 10:09:18Z (armed, with a dock),
session-posting-policy-fix at 10:16:32Z and held-card-oncall-mention at
10:16:43Z (both bare). The four sessions Gera was looking at are likewise four
minted tasks, still live in tmux. The failure recurred on this very
question while a Driver was already armed on it. One of those siblings stood
down and routed its delta to the live Driver rather than becoming a fifth voice
— receipt ~/.claude/jobs/fable/f1ec70287.json, task: session-posting-policy-fix, verdict: RESOLVED, held under option-order
reversal.
The decision
Verbatim as the Driver stated it — and the wording is the Driver's, not
Gera's. Gera's own words are the quote at the top of The problem; the
record name, the edit-in-place preference and the one-question-one-session
clause are the Driver's formulation of that ruling. Reproduced here without a
word changed, because placing a decision is not the same thing as restating it.
The checkable source is the Driver's own brief for the adr-who-may-speak
action item on dock one-thread-voice, and the ruling behind it is the thread
named above (C0C50G2EBMK / 1791764799.838879) — neither of which is in this
repo, which is the honest limit of what a reader here can verify:
A human's Slack thread has exactly one voice: the session that owns it. A thread is OWNED when a
smith-reply-<channel>-<threadTs>record exists for it — the Smith reply workflow for that thread. Into an owned thread, only the owner posts. A lane, a driver or a subagent session that has something to say hands it to the owner and the owner decides whether, when and how to say it; the owner prefers editing its own live message over adding a new post when what it has is a correction. A thread with no owner record is a bot-rooted work thread and is unaffected — the rule is about a human's thread, not about every thread. Every post that does go out is recorded in the conversation log, so the record of what was said to a person is complete by construction rather than by anyone's diligence. And one question gets one session: a second lane on a question that already has an open lane routes to the existing one rather than becoming a second voice with its own opinions.
⚠️ Two spellings in that block are superseded by the Amendment at the foot
of this file, and the block is kept unedited because it is the record of what
was decided, not the current predicate. "a smith-reply-<channel>-<threadTs>
record exists" is ~4x wider than intended — measured — and is replaced by an
inbound row from a person; the literal smith-reply- is replaced by
agent_ident.REPLY_WF_ID_PREFIX. Everything else in it stands exactly as
written.
If you are a system that wants to post into a thread
- Ask whether the thread carries an inbound row from a person — not
merely whether a reply workflow has run in it. The reply-workflow record is
found under
agent_ident.REPLY_WF_ID_PREFIX, never the hardcodedsmith-reply-. See the Amendment for the predicate and for whylive_runs.is_human_senderalone does not express it. - No person-row → bot-rooted work thread. Post. Nothing here applies to you, however many times a cron, a webhook or a sibling agent has woken that thread.
- Cannot tell → post, and say loudly on stderr that you could not tell,
naming the channel. Ruled 2026-10-12 (dock row
thread-post-gate, ruling 2); it covers both a failed record lookup and an unreadable conversation log. This answer expires on a condition, not a date: once row 5's inbox is live, an unperformable lookup DIVERTS to the inbox instead of posting. See Amendment → theunknowncase. - A person-row exists and you are not the owner → you may not post. Write your finding to the lane-finding inbox. It reaches the owner as a file on disk, not a call into it — rungs integrate by signals (ADR-0003), and a sub-agent has no address of its own to be called back on (ADR-0002).
- The owner decides whether, when and how. When what it holds is a
correction, it edits its own live message rather than adding a post —
the same
chat.updatediscipline ADR-0024 established for the phase pointer, applied to prose. - Whatever goes out is recorded in the conversation log by the posting path itself, not by whoever remembered to.
- If the thing you are about to start is a question that already has an open lane, route to that lane instead of opening a second one. The precedent is ADR-0036 decision 3 — a second owner on an identity that already has one is refused, not run in parallel — moved from the dock to the question and applied at mint time.
What this document does NOT decide
Questions the review of this ADR surfaced that were the one-thread-voice
Driver's to settle — recorded so that an implementing row could not pick a
side by accident and have the choice read as doctrine afterwards. All three
were ruled on 2026-10-12 and none is open. They are kept, struck rather than
deleted, because "this was open and then was answered" is a fact about the
decision, and an item that vanishes reads as though it was never asked.
The third ownership case:RULED — see Amendment → theunknown.unknowncase: an unperformable lookup allows the post, loudly, and flips to divert once row 5's inbox is live.Whether row 1 landing before row 5 is a merge precondition.RULED: it is NOT. Row 1 may land first, and that is the design of ruling 2 rather than an oversight in it — the permissive answer is correct because the inbox does not exist yet, so holding the gate back for the very mechanism whose absence justifies it would invert the reasoning. It is a CONDITION, not an ordering: the flip is keyed on row 5 being live, which is why it is a procedure step rather than prose. Nothing blocks row 1 on row 5.Who owns theRULED: row 2 owns it — see the inventory below for its state, which is owned and in flight, not closed.smith_thread_phase_rendergap.
Options considered
- A. Instruct the lanes to be polite. Ruled out: that is exactly what the thread already had. Four lanes each posting carefully still produced 16 posts and ten corrections, because no lane could see the other three. An instruction with no refusal behind it is the status quo with a document attached.
- B. Throttle or batch the CLI. Ruled out: fewer bad posts is still bad posts, and it leaves four voices with four opinions. Gera's ask names a decider ("they tell the main, and main can wait till it has a post opportunity"), not a rate.
- C. Dedupe at lane dispatch. Ruled out by the measurement above: the duplication happens at the MINT, three tasks from one message, and a dispatch-level guard cannot see a sibling task that does not exist yet.
- D. Delete the CLI post path. Ruled out as destructive before the replacement lands (ADR-0004's inventory-then-fence; agent-smith's constitution §6). Bot-rooted work threads and channel posts are legitimate CLI traffic. The refusal is keyed on the ownership predicate precisely so that it declines one case and leaves the rest working. (Amended: the predicate is a person-row, not the bare owner record — and the measured difference between those two readings is ~370–384 posts across 35 bot-only threads that the record reading would have refused for nothing.)
- E (this decision). One voice per owned thread, the owner decides, the finding travels as a signal, and the posting path keeps the record.
Inventory — what implements the old way, and who replaces it
Per ADR-0004, a decision is not finished until the surface it supersedes is
listed. Rows are action items on dock one-thread-voice, all in flight;
none had a PR when this was written.
| Surface | Disposition |
|---|---|
cli:smith-slack-post posting into an owned thread — i.e. one carrying a person-row, per the Amendment — (slack_post.py) |
Refused — row 1 thread-post-gate. In flight. |
slack.post_message recording to the outbound ledger only (slack.py) |
Also records to the conversation log — row 2 thread-post-conversation-log. In flight. |
| The task mint, which creates siblings on one question (local-bin) | Routes to the open lane — row 3 one-lane-per-question. In flight. |
The owner adding a post where it should edit (activities/slack_send.py) |
Edits in place for a correction — row 4 owner-edits-in-place. In flight. |
| A finding the gate refuses, with nowhere to go | The lane-finding inbox — row 5 lane-finding-inbox. In flight. |
activity:smith_thread_phase_render — 1 post + 1 update, absent from the conversation log |
Row 2's, ruled 2026-10-12 (it was recorded here as unowned until then, rather than silently folded into row 2). The fix is in agent-smith #925, which is OPEN and not merged as of 2026-10-12 — verified, not assumed: state: OPEN, mergedAt: null. So this gap is owned and in flight, not closed. Its shape is worth keeping: #925 touches only src/agent_smith/slack.py and its test — not activities/thread_phase.py — because the fix sits at the transport and the phase render reaches it through post_markdown → post_message. The gate never refused this path anyway; only the log invariant was at stake. |
Consequences
- Latency is now by design. A finding waits for the owner's next post opportunity instead of landing immediately. That is the trade Gera asked for, stated as a cost rather than a benefit.
- A refusal with no inbox is silent data loss — today the finding reaches the person badly; refused with nowhere to go it reaches nobody at all. Row 5 exists for this. ⚠️ AMENDED — this bullet originally concluded "the ordering between rows 1 and 5 is load-bearing", and that conclusion is superseded. The hazard is real and it is exactly why ruling 2 makes an unperformable lookup allow rather than refuse; ruling 2 is what disposes of it, so row 1 landing before row 5 is explicitly NOT a precondition (ruled 2026-10-12). Holding the gate back for the mechanism whose absence justifies the permissive answer would invert the reasoning. What is load-bearing is the condition — the flip to divert, keyed on row 5 being live — not the merge order.
- Ownership keyed on a record means a missing record reads as unowned. A thread whose reply workflow was never created, or whose record has aged out of retention, is indistinguishable from a bot-rooted work thread, and the gate lets everyone in. What "a record exists" means operationally — running, closed, closed-and-still-readable — is not settled here and is the first thing an implementing row has to pin. ⚠️ AMENDED — this bullet described the ORIGINAL, record-existence reading and it is kept because its shape survives the narrowing: the predicate is now a person-row, so the failure is "the person-row is missing" instead of "the record is missing", and it still fails toward unowned, everyone may post. The retention question is no longer about Temporal at all; it is about the conversation log. See the Amendment.
- Two agents share one Temporal namespace, so "exactly one voice" can be
two. The prefix is
<AGENT_SLUG>-reply-(agent_ident.py): Smith and Clara run the same checkout under different slugs, and a thread both reply in has two owner records. Per agent the rule holds exactly; the cross-agent case is named, not decided. - A thread can change class mid-life — and under the Amendment this is
the mechanism of ownership rather than an edge of it: the thread becomes a
human's thread at the person's first message, which is the row the predicate
now reads. The decision equates "no owner
record" with "bot-rooted work thread", but the record is created by
dispatcher.dispatch, which start-or-signals the reply workflow for each inbound message Smith should act on, keyed on(chat_jid, thread_root). So a bot-rooted work thread acquires an owner the moment a person replies in it, and becomes governed from that reply onward. A gate that caches its answer — or a lane that checked once at dispatch — will be wrong about which kind of thread it is in. (This is also the thread on which ADR-0008 binds a Driver — still Proposed, so cite it as a proposal and not as settled; that Driver is not thereby a second voice either — it hands to the owner like anyone else.) - Edit-in-place erases what the person already read. Once a correction is a
chat.update, the conversation log is the only surviving record of the earlier text, so "complete by construction" holds only if the log keeps the pre-edit text as well as the post. That is the interplay between rows 2 and 4, and the completeness claim depends on it. - The owner becomes the single path to a person. A wedged or expired reply
workflow is now an inbox filling up with nobody listening — the inert
mechanism ADR-0004's liveness clause is about. The gate and the inbox each
owe a
signalsentry inSYSTEMS.md. Not in this ADR, and no row claimed it as of 2026-10-12. - "One question, one session" is a similarity judgement. A false positive routes a genuinely new question into an old lane, which inherits that lane's context and its assumptions. Today's failure is the opposite error, so the trade is deliberate — but the failure mode moves rather than disappearing.
- Reversal is cheap for the refusal and should not be for the record. The gate is one refusal keyed on one lookup; removing it restores today's behaviour. The conversation-log coverage is additive and worth keeping even if the rest is reversed, because it is what made this decision measurable in the first place.
Amendment — 2026-10-12: owned means a PERSON is in the thread, not that a reply workflow ran there
- Status of this amendment: Accepted, same day as the decision it amends.
- Raised by: the lane implementing row 1 (
thread-post-gate), which measured the set the original predicate actually selects before building the refusal on it, and reported the number instead of quietly narrowing the rule in code. Recorded here because an ADR that shows its own correction, with the measurement behind it, is worth more than one that reads as though it were right the first time — and because a lane that silently "fixes" a Driver's wording leaves the ledger saying something false. - Authority: the Driver's, whose wording the original was — and the test
for "is this mine to rule or Gera's" was applied out loud, which is the part
worth copying. Two limbs, both had to hold:
- Is the narrowing within his stated plan? His words are "only the main session can post, if subagents have something to say, they tell the main". The thread he complained about carries 4 inbound rows, all 4 from a person, and is OWNED — therefore refused — under the wide rule and under the narrow one. The narrowing does not reach his case at all; it stops us gagging bots in the ~75% of records no person ever spoke in, which he never asked for.
- Is it reversible? Yes — a code change and an ADR amendment.
Both limbs hold, so the Driver ruled it and nobody was paged. The number
and the narrowing are surfaced to Gera in the Driver's own report so he can
overrule, and this ADR says so rather than presenting a machine's inference
as a human's decision. This lane wrote the original as given, flagged the
adjacent
<AGENT_SLUG>-reply-defect and the provenance problem in its reports, and is now placing the Driver's correction.
What was wrong
The decision says a thread is OWNED when a smith-reply-<channel>-<threadTs>
record exists. That record does not mean a person is there — it means a reply
workflow ran there. dispatcher.dispatch writes the inbound conversation row
for every trigger it handles (the log_message call in
dispatcher.dispatch — src/agent_smith/dispatcher.py:360 at agent-smith main
a030d600; cite the symbol, not the line, if it has moved, under the
comment "so the inbound row exists regardless of how the dispatch resolves"),
and a cron wake, a GitHub event, a task_goal wake and a sibling agent's
message are all triggers. So the original predicate selects every thread Smith
has ever woken in, and calls them all a human's thread.
The measurement
Two independent runs, kept apart rather than averaged, over the population the
gate can actually reach — a Slack-id channel and a <10>.<6> thread root.
(A wider 1518-record figure circulated during this work; it includes
WhatsApp-era jids the gate can never touch, and is not the denominator for any
claim here.)
| row 1's lane | this lane, re-derived from ~/.claude/smith-state/conversations/*.jsonl, 2026-10-12 |
|
|---|---|---|
reachable <slug>-reply-<C…>-<ts> records |
1479 | 1455 |
| inbound row from a person | 368 — 24.9% | 344 — 23.6% |
sibling-bot inbound only (U0BEM6NPK26) |
734 — 49.6% | 734 — 50.4% |
| synthetic only (cron / github / wake) | 377 — 25.5% | 377 — 25.9% |
| no inbound row at all | 0 | 0 |
The two bot classes agree to the record. The totals differ by exactly 24, all of them person-owned, and the direction of that delta is itself the diagnosis: this run is the LATER of the two and has FEWER records, which a clock cannot produce if records only accumulate. So the two runs did not enumerate the same set — the interval alone cannot account for it, and an enumeration difference must be present. (Precisely: the interval can only add records, so a net loss of 24 requires an enumeration difference of at least 24; the two causes are not exclusive and the interval may be masking a larger one.) This run derives the set from the conversation-log files on disk, which can only see a record that has a row in a channel file it read. It is recorded as a known difference in instrument rather than smoothed, and neither run changes the finding: the predicate is about four times wider than the thing it is trying to name — on the lane's run 1111 of 1479 records, and on this one 1111 of 1455, are threads no person ever spoke in.
And in outbound volume, undeclared in-thread posts via cli:smith-slack-post,
by the owner class of the thread they landed in:
| thread class | row 1's lane | this lane, re-derived |
|---|---|---|
| person-evidenced — the defect this ADR exists to stop | 548 | 539, across 27 threads |
| bot-only — the over-refusal the wide wording causes | 370, across 35 threads | 384, across 35 threads (375/32 sibling-bot + 9/3 synthetic-only) |
| no owner record at all — refused by neither reading | — | 50, across 12 threads |
The bot-only row is stated pooled so the two runs are comparable: the thread count is 35 in both, which is what says they are looking at the same set of threads, and the later run carries 14 more posts in it — a live store between two readings, this time in the direction a clock does produce.
Positive control on both of this lane's runs: the thread this ADR was written
about (C0C50G2EBMK / 1791764799.838879) classifies as person, with
exactly the 16 cli:smith-slack-post posts the original measurement found.
A classifier that could not see that thread would have proved nothing.
The amended rule
A thread is owned, for the purpose of this decision, when it carries an inbound row from a person — not merely when a reply workflow has run in it.
How the two halves connect, stated so they cannot be implemented
independently and disagree: the predicate is one lookup, not two. It reads
the conversation log for the thread and asks whether any row with
direction == "in" has a person as its sender; the reply-workflow id (under
agent_ident.REPLY_WF_ID_PREFIX) is how those rows are addressed —
(chat_jid, thread_root) is the same key by another spelling, and the log rows
carry both — a conversation-log row carries chat_jid, sender, direction
and workflow_id (written by conversation_log.log_message, called from
dispatcher.dispatch; src/agent_smith/activities/conversation_log.py at
agent-smith main a030d600). That is exactly how both measurement runs below
enumerated the set, so the number and the rule describe the same thing.
Everything else in the decision stands unchanged: one voice per human thread, only the owner posts, a lane hands its finding up and the owner decides whether/when/how, the owner prefers editing its own live message for a correction, bot-rooted work threads are unaffected, every outbound is recorded in the conversation log, and one question gets one session.
The implementation trap — stated because an unimplementable rule is worse than a wide one
live_runs.is_human_sender ALONE CANNOT EXPRESS THIS. It is
^[UW][A-Z0-9]+$ (live_runs._HUMAN_SENDER_RE, src/agent_smith/live_runs.py:89
at agent-smith main a030d600), and the sibling agent's
uid U0BEM6NPK26 is U…-shaped, so it matches. Verified by running that
exact regex against that exact uid: True. On the measured population that
single hole is 734 records, 50% of the set — i.e. a narrowing built on
is_human_sender alone would still be about half wrong, and would look right
in every test whose fixture used a non-bot uid.
A correct predicate is therefore a conjunction:
- the sender matches
is_human_sender, and - the sender is not in the agent roster —
config.sibling_uid_names(), plusconfig.AGENT_BOT_UIDwhen that is set (this spelling is row 1's, in flight; what is measured below is the function's return value, not the call site — row 1's gate is not on disk at agent-smitha030d600), and - the sender is not a synthetic producer (
cron,github,wake, …), which falls out of (1) for today's spellings and must not be relied on to.
Condition 2 names sibling_uid_names() and NOT
config.AGENT_SIBLING_BOT_UIDS, and the difference is the whole point.
Measured by calling both: sibling_uid_names() → {U0BE08K74M7: "Agent Smith", U0BEM6NPK26: "Trinity"}, while AGENT_SIBLING_BOT_UIDS → {U0BEM6NPK26}
alone. (U0BEM6NPK26 is the agent this document calls Clara elsewhere —
clara is the slug, "Trinity" is the presented name the roster prints. Same
uid; do not read the two names as two agents.) Two residuals the sibling constant had, and which the roster closes:
- It covers self as well as sibling. The sibling allowlist is the set this
agent admits as siblings, so it never contains the agent's own uid —
U0BE08K74M7would have passed condition 1 and read as a person. - It does not empty.
AGENT_SIBLING_BOT_UIDShas a deliberate off switch (a set-but-empty env value means "admit no sibling"), so turning the sibling protocol off would have silently emptied the subtraction and made the predicate 50% wrong again.sibling_uid_names()starts from_SIBLING_DEFAULT_UID_NAMESand merges an override on top, so it cannot empty that way.
What is still open, narrowed rather than dropped — two residuals, mirror
images of each other. First: the roster is an enumeration of our agents, so
any other U…-shaped app or integration user still passes condition 1 and
reads as a person. Second, and it is the "cannot empty" property seen from
the other side: _SIBLING_DEFAULT_UID_NAMES is a literal dict of two uids,
so it cannot empty and it does not grow by itself — a third agent seat
reads as a person until its uid is added either to that literal or to the
AGENT_SIBLING_UID_NAMES JSON override that sibling_uid_names() merges on
top. (Stated that way deliberately: the override means this does not need a
code change, and claiming it did would be a false mechanism in the paragraph
about false mechanisms.) Same direction
(over-refusal, the safe one), and worth naming now so that the day a new seat
appears this reads as a known residual rather than a stale ADR. Both are
bounded by measurement,
not by hope: the only distinct inbound senders on the whole reachable set are
U0BEM6NPK26 (2221 rows), github (1812), and three real people
(U0BDRQA9F43 923, U0BDW2G61B8 404, U0BDY54RECS 17) — so the roster
covers 100% of the live bot traffic, and the remainder is a hole in the
shape of the rule rather than in today's data. It fails toward
over-refusal, which is the safe direction.
Considered and dropped: subtracting the Sentry bot uids. An earlier draft
of the predicate — row 1's, per the Driver; this ADR never carried it, so
this paragraph is an addition and not a correction of anything above — also
subtracted config.SENTRY_BOT_UIDS. That subtraction is unreachable, and the
reason attached to it was false, which is the worse half (Constitution §5). The trace, resolved in source at
agent-smith main a030d600: a Sentry post is admitted only through
slack_socket._route_sentry_alert (slack_socket.py:2275); the alert dispatch
computes wf_id = alert_workflow_id_for(...) (dispatcher.py:605) and its
single log_message writes the inbound row under that id —
agent_ident.ALERT_WF_ID_PREFIX, i.e. smith-alert-<chan>-<ts> — and never
a smith-reply- id. The predicate only reads rows whose workflow_id is the
reply id, so a Sentry uid cannot reach it. Recorded here with the trace
precisely so nobody re-adds the subtraction on the wrong basis; the row-1 helper
that enforces the "equals the reply id" half is in flight and is that row's to
land.
Required test cases, because this hole is invisible to a well-meaning fixture. Row 1's tests must include every case below, each with its expected outcome stated so the test documents the rule rather than the hope:
| Inbound sender | Must come out | Why this case and not a generic one |
|---|---|---|
U0BEM6NPK26 (the sibling bot) |
unowned | It is U…-shaped and passes is_human_sender. This is the 734-record, 50% hole; without this case the narrowing can regress silently. |
U0BE08K74M7 (this agent's own uid) |
unowned | The case the old AGENT_SIBLING_BOT_UIDS spelling could not have covered — an allowlist of siblings never contains self. It passes if the roster is spelled wrongly, and nothing else in the suite would notice. |
A real person's uid (e.g. the U0BDRQA9F43 shape) |
owned | The positive control. A predicate that refused everything would pass both rows above. |
| A bot-shaped uid not on the roster | owned — i.e. read as a person | This pins the remaining hole as it is, not as we wish it were. Today it over-refuses, which is the safe direction; if someone later makes it come out unowned, that is a behaviour change that should break this test and be argued for. |
A fixture using an arbitrary U… uid and nothing else passes against a
predicate that is 50% wrong on this population.
The second spelling fix rides here because it is the same defect one size
smaller: the gate resolves the record through agent_ident.REPLY_WF_ID_PREFIX
(f"{AGENT_SLUG}-reply-"), never the literal smith-reply-. A gate keyed on
the literal reads a Clara-owned thread as unowned and lets every lane post into
it.
The unknown case — ruled 2026-10-12, with the flip that supersedes it
The original left this open (What this document does NOT decide, item 1) and
the narrowing added a second shape of the same question. Both shapes get one
answer, by the Driver — stated 2026-10-12 and recorded on dock row
thread-post-gate as ruling 2. The row name is resolvable (the task's
claimed-items.txt); the ruling text itself lives on the rendered board, which
is not in this repo, so that is the limit of what a reader here can check:
An ownership lookup that cannot be performed ALLOWS the post, and says so LOUDLY on stderr, naming the channel.
That covers "the lookup failed" and "the conversation log could not be read" — they are the same state, and giving them different answers is how a gate ends up with two behaviours for one condition.
The reason, and it is deliberately NOT the first reason given: refusing on unknown is silent data loss until row 5's inbox is live. A stray post is recoverable — it is visible, it is in the ledger, it can be edited or retracted. A dropped fire-and-forget finding is simply gone. The loud stderr line is what keeps "allowed because we could not tell" from being indistinguishable from "allowed because the thread is a bot's".
The reason first offered was that refusing would silence the owner's own reply. That was wrong, and it is recorded here rather than quietly replaced: row 1's lane measured that the binding check returns BEFORE the log read, so an owning turn never depended on the lookup at all. The lane was right not to write the wrong reason at the call site.
The flip is already decided, and it is sequenced, not hypothetical. Once
row 5's inbox is live, unknown must DIVERT rather than allow — at that
point nothing is lost by staying quiet, so the argument above expires with the
condition that created it. The trigger is row 5 (lane-finding-inbox) being
live, not a date and not a judgement call. An ADR that records only today's
answer gets read as permanent, which is how a temporary default becomes
doctrine.
Where the old wording lives, and what happens to each site
This table is the only thing that says where the old wording lives, so it reports every site the amendment touched — including the ones found by the reviews of the amendment, which is exactly the failure this table exists to prevent. No count is given here on purpose: a number in this sentence is a second source of truth that the table below can contradict, and it did exactly that twice (it read "eight sites" against nine rows, then "ten" against eleven). The last two rows are not wording sites at all — they are the implementing row and a bullet that is still true — and they are listed anyway rather than leaving a reader to wonder whether they were missed.
| Site | Disposition |
|---|---|
| The verbatim decision block | Kept unedited — it is the record of what was decided. Carries a pointer to this amendment. |
| The procedure, steps 1–4 | Rewritten in place: step 1 is the person-row predicate and the prefix constant; step 2 says a bot-woken thread is unaffected however often it was woken; step 3 is new — the unknown branch and its flip; step 4 now reads "a person-row exists and you are not the owner" instead of "a record exists". The list was renumbered 1–7, because the first draft of the new step was written 2b., which is not a list marker in Markdown and rendered glued into step 2 as literal text. |
| Options considered, option D ("the refusal is keyed on…") | Rewritten: "keyed on the ownership predicate", carrying the measured over-refusal the record reading would have caused. |
The inventory row for cli:smith-slack-post |
Rewritten: "an owned thread — i.e. one carrying a person-row, per the Amendment". |
| The "Ownership keyed on a record" consequence bullet | Kept, with an AMENDED note, because its shape survives: the failure is now "the person-row is missing", it still fails toward unowned, and the retention question moved from Temporal to the conversation log. |
| The change-class consequence bullet | Rewritten: this is now the mechanism of ownership — a thread becomes a human's at the person's first message — rather than an edge of it. |
| What this document does NOT decide, items 1, 2 and 3 | All three struck through, not deleted, each with its ruling: unknown allows-then-diverts; 1-before-5 is not a precondition; the thread_phase_render gap is row 2's. "This was open and then was answered" is a fact about the decision. |
| The "refusal with no inbox" consequence bullet | Kept, with an AMENDED note. Its hazard is why ruling 2 chose allow; its original conclusion — "the ordering between rows 1 and 5 is load-bearing" — is superseded, because the ordering is not a precondition. What is load-bearing is the condition, not the merge order. |
The inventory row for smith_thread_phase_render |
Rewritten: row 2's, ruled 2026-10-12, fix in agent-smith #925 which is OPEN — owned and in flight, not closed. |
Row 1 thread-post-gate (in flight) |
Implements the amended predicate and ruling 2; it was the row that measured both. |
<AGENT_SLUG>-reply- consequence bullet |
Still true, now partly discharged: the prefix constant is named in the rule rather than only in the costs. |
Consequences of narrowing
The over-refusal is what the narrowing buys: on these numbers, ~370–384 posts across ~32–35 bot-only threads keep working instead of being refused for nothing. The ~539–548 posts into person-evidenced threads are still refused, which is the point.
BOTH FAILURE DIRECTIONS, side by side, because the narrowing swaps one for the other and only the pair shows the trade:
the old predicate (record exists) the amended predicate (person-row) Fails toward over-refusal — a lane is gagged in a thread no person is in under-refusal — a lane posts into a thread a person IS in Measured size ~370–384 posts across 35 bot-only threads, i.e. the default for ~75% of records bounded to threads whose log write failed; the row is logged "best-effort, non-fatal" by design (the comment above that same log_messagecall indispatcher.dispatch—dispatcher.py:357-358at agent-smith maina030d600)Cost when it fires a finding is refused and, until row 5, lost a stray post, which is visible and retractable The swap is accepted with eyes open: the new failure needs a failed write to occur, where the old one was simply the normal state of three quarters of all threads. It is also the same direction the
unknownruling above chose, for the same reason — a recoverable stray post over a silent loss.Ownership does not decay. A thread a person spoke in once stays owned forever. That is the safe direction and it is deliberate; nothing here expires it.
The
unknowncase had a second shape, and both are now answered. Narrowing moved the question from "the reply-workflow lookup failed" to "the conversation log could not be read" — the same question about a different store, which is why it gets the same answer: allow, loudly, and divert instead once row 5's inbox is live. See theunknowncase above. Row 1 is no longer blocked on it.The narrowing does not touch the case Gera complained about, and that is checkable, not asserted. Measured on his thread (
C0C50G2EBMK/1791764799.838879): 4 inbound rows, all 4 from a person (U0BDRQA9F43). It is OWNED under the wide rule and OWNED under the amended one — the verdict on the thread that caused this decision is identical either way. What the narrowing changes is the ~75% of records no person ever spoke in, which is not a thing he asked for.
Amendment — 2026-11-02: sessions post anywhere as Agent Smith
- Status of this amendment: Accepted. Decided by Gera, 2026-11-02, in the maintenance coordinator session, answering a question the coordinator put to him after its own post was refused: "Let sessions post anywhere." Written the same day, at decision time, per ADR-0004.
- What it decides: any of our Claude sessions may post into any Slack thread — a person's or a bot-rooted one — as Agent Smith (the bot identity). The one-voice-per-thread rule above (Gera 2026-10-12) and its 2026-10-12 narrowing are retired for the bot identity. A session no longer has to be the thread's bound reply turn, a named mechanical emitter, or attributed, to speak in a thread as the bot.
- What it does NOT change:
- Posting under Gera's own name. The as-Gera MCP thread guard
(
~/.claude/hooks/guard-mcp-as-gera-thread.py, dotclaude) is a different door and stays exactly as it is: athread_tson the Slack MCP send is refused unless Gera named that thread in the session, and a sub-agent may not thread as him at all. The bot is the answer when that guard refuses. - Every other outbound rule. The needs-input-queue refusal, the bare-mention refusal, the #engineering-bridge mention rewrite, the burst gate, the bouncer, and the transport's secret/PII scrubs all still apply.
- The finding inbox (
thread_findings,smith-thread-finding) stays as an optional hand-off to a thread's owner. It is no longer the only route a session has into a person's thread, so a stranded finding there is no longer a finding that could not be said. - ADR-0071 (one driver per Smith/Trinity thread) is about which SEAT answers a human follow-up, not about whether a session may post; it is untouched.
- Posting under Gera's own name. The as-Gera MCP thread guard
(
- Mechanism, retired rather than switched off (one path, never a switch):
agent-harness (formerly agent-smith) deletes both doors that enforced this
ADR — the
smith-slack-postCLI'sthread_post_refusal/MECHANICAL_THREAD_SOURCES(part 1) and the transport'sslack.thread_owner_refusal/ThreadOwnerRefused(part 2) — in flight as PropFlow-Technologies/agent-harness#1844 and #1845 when this was written; until both merge, a door may still refuse.--sourceremains ledger attribution only. - Why the old reasoning no longer holds, in the decider's terms: the
2026-10-12 rule existed because lane posts in his thread were noise he had
not asked for. On 2026-11-02 the cost ran the other way: a coordinator with a
real answer for a thread could not give it. Gera chose reach over the gate.
The noise risk inside a thread is now carried by each session's own
judgment alone. Nothing rate-limits thread replies: the burst gate
(agent-harness
slack.burst_refusal,BURST_MAX_TOP_LEVEL/BURST_WINDOW_Sinsrc/agent_harness/slack.py) counts TOP-LEVEL posts only and exempts replies by design. - Stale citations left on purpose, named so nobody reads them as live:
SYSTEMS.md rows that describe the thread owner as "the only actor allowed to
post (ADR-0051)" (the Gera-messenger relay and the finding-inbox probe) and
~/.local/bin/task-event's comment that its mirror source exists to pass the gate. Each still works; their wording predates this amendment.