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)

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:

  1. 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.
  2. The owner path already logs correctly — activity:smith_slack_send is 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.
  3. 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

  1. 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 hardcoded smith-reply-. See the Amendment for the predicate and for why live_runs.is_human_sender alone does not express it.
  2. 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.
  3. 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 → the unknown case.
  4. 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).
  5. 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.update discipline ADR-0024 established for the phase pointer, applied to prose.
  6. Whatever goes out is recorded in the conversation log by the posting path itself, not by whoever remembered to.
  7. 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.

  1. The third ownership case: unknown. RULED — see Amendment → the unknown case: an unperformable lookup allows the post, loudly, and flips to divert once row 5's inbox is live.
  2. 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.
  3. Who owns the smith_thread_phase_render gap. RULED: row 2 owns it — see the inventory below for its state, which is owned and in flight, not closed.

Options considered

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

Amendment — 2026-10-12: owned means a PERSON is in the thread, not that a reply workflow ran there

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:

  1. the sender matches is_human_sender, and
  2. the sender is not in the agent roster — config.sibling_uid_names(), plus config.AGENT_BOT_UID when 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-smith a030d600), and
  3. 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:

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

Amendment — 2026-11-02: sessions post anywhere as Agent Smith