Skip to content

feat(mcpl): every channel item names its conversation in a stored source header - #257

Open
nissa-seru wants to merge 94 commits into
anima-research:mainfrom
nissa-seru:feat/source-headers
Open

nissa-seru wants to merge 94 commits into
anima-research:mainfrom
nissa-seru:feat/source-headers

Conversation

@nissa-seru

@nissa-seru nissa-seru commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

This PR gives every channel item a stored source header naming its conversation, so a message read alone says where it came from. This update merges #256's latest head, 0bae78b1, with no conflicts; this PR's own commits are unchanged.

Stacked on the speech-routes PR #256, itself stacked on #252 and #247. Please review only what this PR adds to #256's head 0bae78b1: its eleven commits, 42c3f5a..7c4c723, 7adf10c, 0c4030c, b801b10, 7a80df5, 2c6ad2b and 26edf15 (see "A review run's findings"), and the merges 6d6f133, 48e5135, eaf67dd, d0078bc, 69cf802, de92afb, 967615a and 665957d that bring #256's heads in (together, git diff 0bae78b1 665957d). Merge after #256.

Problem

A message read alone couldn't say where it came from:

  • channel provenance lived only in metadata;
  • adapters described the location in prose only when the channel changed, so the second of two consecutive messages didn't name its channel;
  • items in channel_open's backscroll carried nothing.

Changes

  • A stored source header on every channel item, from either MCPL lane. The item's first text block names its conversation:
    [source: <server> / <canonical-channel-id> · <label at receipt> · thread <id> · reply to <id>].

    • The label, thread and reply parts appear only when the item has them.
    • A push that names no channel gets [source: <server> · unscoped] instead of a guessed channel.
    • The header is rendered once at ingestion, from the item's frozen source envelope, and stored with the message. A later rename, a recompile or a replay never rewrites it.
    • Creates, coalesced deliveries and RFC-006 corrections all carry it. Local-surface input and a silent heartbeat get none.
  • Header values are literal. Every field comes from an adapter. A value holding a grammar character (brackets, ·, /, quotes, a backslash) or any control character is rendered as a JSON string literal. Line separators, DEL and the C1 controls are escaped visibly as \uXXXX, so a label can't forge a second attribution or break a header across lines. A label whose first word is one of the header's own (thread, reply to or unscoped, in any case, followed by whitespace or by nothing) is quoted too, so it can't read as a thread or reply part. A word that runs on (thread:ops, unscopedness) isn't the header's word.

    • Invisible and space-lookalike characters (0c4030c, fixing One quoting rule for server-chosen text: renderSourceHeader/labelValue miss the invisible-character class logValue already escapes #269, which slimepriestess raised from discord-mcpl refactor: settle runEphemeralToCompletion from the stream driver, not trace events (adopts #55) #63's review). A value is also quoted when it holds a format or default-ignorable character, a space other than U+0020, or a / with whitespace on both sides. Format and default-ignorable characters include zero-width characters, bidi overrides and isolates, fillers such as U+3164, the combining grapheme joiner, variation selectors and interlinear-annotation controls. Inside the quotes those characters are escaped as \uXXXX, per UTF-16 unit, so a quoted bidi override can't reorder what follows it, and JSON.parse still gives the value back. A label that starts with such a character is quoted for that alone, so a header word behind it can't read as a field: \u200bthread x is quoted.
    • Emoji sequences stay as they are, joiners and selectors included, because a label is also a name a resident types back: ❤️ cats isn't quoted. Keycaps and tag flags aren't covered, so a name holding one is quoted.
    • The same rule as discord-mcpl's source-header.ts (refactor: settle runEphemeralToCompletion from the stream driver, not trace events (adopts #55) #63), which renders the same header. The two files' six constants and three functions' code are identical, and only quoted()'s comment differs. A differential fuzz across both files, at 7a80df5 here and discord-mcpl's ac08f9b, found no difference in 1,538,880 renderings.
    • 7a80df5 drops two pieces that never changed a rendering. Agnes-984's remove-each-piece check found them: labelValue's strip of invisible characters before the header-word test, which headerValue already covers, and a separator widened to \s\/\s, whose extra spaces other rules already quote. Rendering is identical with and without them across 2,821,280 renderings, comparing b801b10 with 7a80df5.
  • Header-shaped text in a body is marked (7adf10c, from slimepriestess's review). Every [source: or [source] in an item's own text blocks, in any case and with any whitespace, control or invisible character inside the bracket (between the letters of source too, since 2c6ad2b), is stored with a backslash before its bracket, wherever it falls. Outside the host's own header line, no unmarked opening remains; a header value that holds one is quoted inside the header, where it reads as a literal.

    • Why. Without it, anyone who can post in a channel could forge a header naming another channel, and a model could take it for the host's attribution and for a place to channel_publish to.
    • Why every position. In XML tool mode the formatter writes participant: before each message, so the real header sits mid-line too, and a body could stage a whole message from another channel.
    • Split openings. The text blocks are read as one string, so an opening split across blocks is caught.
    • One function does this wherever the host stores what a sender wrote: both lanes, the closed-channel invitation (it names the adapter's author, guild and label), channel_open backscroll item text (string content included), a local surface's input, and the legacy Discord module's stored and edited text. The last four are 2c6ad2b's.
    • The only change to stored body text this PR makes. Invisible characters don't hide an opening wherever they stand in it (0c4030c), and with Unicode case folding [ſource: opens one too. Visible look-alikes can still imitate it.
  • channel_open backscroll. Each item gains a source field, rendered from the item's own channel, so an item spliced in from another channel shows its true channel. The host owns the source key: an adapter's own source is kept as adapterSource on every item, and an item that names no channel gets no source.

  • The authority rule. When a label differs, the canonical id is authoritative. The rule is stated in the channel_list, channel_open and channel_publish descriptions, and in a one-time [source] notice. The rule also says three things: the host writes a [source: …] header only as the first line of a message; \[source… inside a message is its sender's own text; and any such line in a connector tool's own output, such as fetch_history, belongs to that tool result. The notice is stored before it's recorded as given, so a failed append can't suppress it, and a notice already in the resident's context counts as given, so a failed record doesn't repeat it. It's given at the start of the resident's first turn with any item stored with a header retained in its context, however far back. That item can be a channel item, or a push that names no channel; the notice names the unscoped form. The residents told are recorded as a list of names, so any agent name works.

  • Readers of what was said skip the header (7adf10c). They skip it while the first text block is exactly metadata.sourceHeader, or begins with it: the store's adaptive resolution joins an item's text blocks with nothing between them and shards the result, so a long item's first shard starts with the header (2c6ad2b). A later shard comes back whole, by its shard index, even if a split falls between a marked opening's backslash and its bracket (26edf15).

    • History search, substring and regex alike, matches and snips the body. So ^ anchors at the body's start, and a channel's name or id doesn't match every item from that channel.
    • The semantic index embeds and snips the body.
    • The tune-out backlog lists author: body.
    • extract still shows the header.
  • Digests around the header (the stamps from feat(mcpl): stamp each inbound item's delivered-body and stored-copy digests #252):

    • sourceBodyDigest is taken from the body as delivered, before the header and the marking.
    • The exact header text is recorded as metadata.sourceHeader.
    • storedBodyDigest is of the blocks as handed to storage, header and marking included. The copy check applies to an unsharded copy, and a body the store shards carries the whole body's digests on every shard.
    • metadata.sourceHeader is always the host's: set from the rendered header and removed when none is rendered, so an adapter-supplied value never survives.
    • So a rename changes the header and the stored digest, but never the delivered body's version.
  • Notices name a connector's values by the same rule (2c6ad2b). Routing notices, holds, the [delivered] receipt, draft lists and the [discord-send-failed] marker show a label by labelValue's rule, and ids, threads and surfaces by headerValue's. So a label carrying a line break and a forged notice is one quoted, escaped value, while ordinary names read as they are. The closed-channel invitation does the same for its author, label and guild, shows the label its server registered for the channel (else the event's own) as given, with no added #, and writes the ids its options ask for as JSON strings.

Changelog fragment: changelog.d/source-headers.added.md.

Integration with main

The stack integrates by merging upward, so every reviewed commit keeps its hash. 6d6f133 merges #256's integrated head 2287051 into 7c4c723. Both textual conflicts are where this PR meets main's #236, which refuses a visibly-empty push at admission. Review them with git show --remerge-diff 6d6f133:

  • src/framework.ts, the push path. Main's isSilentHeartbeatMarker({ ...event, content }) replaces this PR's inline marker check. The source header is still added after that check, so the marker sees the undecorated body. The digest comment now covers both the admission refusal and the header.
  • test/inbound-source.test.ts. The integrated parent's rewritten cases are kept (from feat(mcpl): stamp each inbound item's delivered-body and stored-copy digests #252's integration with main, 88a24e6: an empty push refused, an accepted tick, a DM with a body), with this PR's header expectations added: the accepted push stores [source: discord · unscoped] first, and its stored digest differs from the delivered one. A DM stores the header, the body and the invitation. The empty-body header assertions are gone, since such a push no longer reaches storage.

48e5135 then merges #256's shutdown fix 4180ccd into 6d6f133. It merged clean (git show --remerge-diff 48e5135 is empty), and this PR's delta on 4180ccd is the same as on 2287051.

eaf67dd then merges #256's head 8f29e62 into 48e5135. That head adds the follow-up to slimepriestess's review of #256 (1bbf3f7, 2c83659) and #256's merge of #252's later head a2accc1. a2accc1 carries #247's 315d12c and the stored-form body digest from slimepriestess's review of #252. It merged clean (git show --remerge-diff eaf67dd is empty).

d0078bc then merges #256's head e041853, which fixes what a review run over #256's own commits found (see #256's "A review run's findings"). It had one conflict, in src/framework.ts, from adjacent edits. This branch added withSourceHeader and maybeExplainSourceHeaders directly above candidateForChannel, and e041853 rewrote that method's doc comment. The resolution keeps both: the two methods as they are here, then the new comment.

69cf802 then merges #256's head 4e19f02, in which routing names each conversation with its server, the server / channel-id form this PR's source headers use. It merged clean.

de92afb then merges #256's head 6aaeb85, the fixes from #256's second review run, with #252's and #247's. It merged clean. Two of #252's tests it brings in assumed a stored body without a header, and the invitation's label text changed under them; 2c6ad2b updates them (see below).

967615a then merges #256's head 9dc8239, which gives defaultOutgoing MCPL RFC-011 revision 5's meaning. It merged clean (git show --remerge-diff 967615a is empty), and the full suite at 967615a passes 1658/1658 across 136 files, with npx tsc --noEmit clean.

665957d then merges #256's head 0bae78b1, which adds #256's ten fallback rounds: a wake that names no conversation keeps the fallback route, and a wake that continues a turn names that turn's conversations. It merged clean (git show --remerge-diff 665957d is empty). This PR's own changes to the files it touches are where messages enter, plus how a conversation is described, and none of them is in the route decision. The full suite at 665957d passes 1714/1714 across 136 files, run one file at a time with no --test-force-exit, with npx tsc --noEmit clean.

A review run's findings (2c6ad2b, 26edf15)

The house ran its review workflow over this PR's own commits at 69cf802. Petra-1012 judged each of the 27 issues it found against the code: 18 were real and are fixed in 2c6ad2b, 8 were real but not worth changing, and 1 is intended. The review of #247 had also left one point to this PR: how a connector's label is shown in host notices. The fixes are described above, under Changes, and 26edf15 follows the review of 2c6ad2b: the invitation names a channel by the label its server registered, and a later shard of a sharded item keeps its text. In short:

  • Marking ignores controls and invisible characters anywhere in an opening, whitespace between its letters, and reaches the invitation, a local surface's input, backscroll string content and the legacy Discord module. A malformed backscroll entry passes through rather than failing the open.
  • Readers skip the header at the start of a sharded first shard.
  • The notice isn't repeated when its record fails to write, and a failed state read gives none.
  • metadata.sourceHeader is always the host's.
  • Notices and the invitation name connector values by the header's rule.
  • Texts. The fixes reach the rule's wording, the label rule, the emoji exemption, the digest wording and the changelog fragments. Comments no longer carry internal tracker references.

Judged not to need a change (8). Among them are edits through editMessage replacing a header without a check, stored items from before the upgrade staying unmarked, and the first request after the upgrade missing the prompt cache because the channel tools' descriptions changed.

Checks (Anima VPS):

  • The full suite, one file at a time: 1658/1658 across 136 files, with npx tsc --noEmit clean, at 26edf15, and the same at 2c6ad2b. At the merge de92afb, before the round, it gave 1642/1644. The two failures were feat(mcpl): stamp each inbound item's delivered-body and stored-copy digests #252's tests that 2c6ad2b updates.
  • Red checks. Each of 16 checks undoes one fix with a string replacement and runs the test written for it. Each test passes on 26edf15's source, and each fails under its mutation. They cover:
    • marking: spaced letters, controls, the invitation on each lane, its author's quoting, console input, backscroll string content and malformed entries, and the Discord module's import and edit;
    • the invitation's registered label;
    • readers: the sharded first shard, and a later shard kept whole;
    • the notice: a context that holds it;
    • notices: the label and the thread.

Tests

test/source-headers.test.ts runs over real MCPL children: a Discord-shaped server with two guilds sharing a channel name plus DMs, and a Zulip-shaped server with topics in a stream. It covers:

  • the grammar;
  • consecutive and interleaved messages, and same-named channels kept apart by id;
  • thread and reply parts;
  • DM, unscoped and console input;
  • a rename after ingestion;
  • the notice said once;
  • backscroll items each under their own channel;
  • a coalesced correction;
  • forged labels and line separators;
  • stored-copy digests, and an edit leaving them unmatched;
  • the tool result rendered through membrane's own AnthropicXmlFormatter.formatToolResults;
  • Greptile's four findings, one regression case each (7c4c723; three new tests and the backscroll test extended), each confirmed failing against 49a7baf's source:
  • slimepriestess's review (7adf10c). Run against eaf67dd's source with identity stand-ins for the two new helpers, each case fails, except two assertions that hold on both heads by design: the near-miss case and extract keeping the header:
    • the marking. An opening at a line start, mid-line, after user: , in [source], with odd spacing and mixed case, after a doubled bracket, already escaped, and twice in one text. Also openings split across blocks, inside the word, over four blocks, around an image and between empty blocks, with near-misses left alone;
    • both lanes storing a staged body and a forged push marked, with each digest checked. Through membrane's real AnthropicXmlFormatter, the staged line reads user: \[source: …;
    • a coalesced delivery, its correction and a coalesced push;
    • backscroll item text, with and without a header;
    • the readers. Substring search for a channel name and for an id, ^ through the regex worker, messageIndexText, and the tune-out backlog. extract keeps the header;
    • a resident that only receives unscoped pushes being told.
  • The far-back notice test (Greptile's fourth case) now uses header-less filler, so it still measures distance. It passes on both heads.
  • Ezra-1872's review ran ten mutants against the compiled code, each undoing one of 7adf10c's behaviours, and each failed the test meant to catch it.

npm test, the full suite:

  • 69cf802 (this head), as CI runs it (Petra-1012, Anima VPS, a fresh npm install): npx tsc --noEmit is clean, and npm test passes 1602/1602 on node v24.21.0 and on node v20.20.2: 7a80df5's 1588 plus feat(routing): speech follows its conversation, and publishes only where MCPL RFC-011 declares the place #256's fourteen new tests (e041853's ten and 4e19f02's four).
  • d0078bc, as CI runs it (Petra-1012, Anima VPS, a fresh npm install): npx tsc --noEmit is clean. npm test passes 1598/1598 on node v20.20.2, which is 7a80df5's 1588 plus feat(routing): speech follows its conversation, and publishes only where MCPL RFC-011 declares the place #256's ten new tests. On node v24.21.0 the same run reported 1590 passes and no failures: 8 of channel-registry-routing's tests went unreported under --test-force-exit, and that file passes 30/30 on its own.
  • 7a80df5, as CI runs it (Petra-1012, Anima VPS, a fresh npm install: chronicle 0.4.0, context-manager 0.13.0, membrane 0.5.86): npx tsc --noEmit is clean. npm test passes 1588/1588 on node v20.20.2. On node v24.21.0 the same run reported 1576 passes and no failures: 12 tests from channel-registry-routing went unreported under --test-force-exit, and that file passes 30/30 on its own. main 02adf27, an ancestor of this head, gives 1377/1377 on both runtimes. With 7adf10c's tests run against the code before it (eaf67dd), source-headers fails to load, and tune-out-e2e's divert → wake → auto-cancel arc fails.
  • One quoting rule for server-chosen text: renderSourceHeader/labelValue miss the invisible-character class logValue already escapes #269, the rendering compared with 7adf10c. We compared 769,440 header renderings, built from each code point in U+0000–D7FF, U+E000–FFFF, U+10000–1FAFF and U+E0000–E01EF in six label templates. 3,730 differ, and every one involves a format or default-ignorable character, or a non-ASCII space. Across 512,960 openings with a code point inserted, 1,816 are newly marked, and each involves such a character. These were run at b801b10, which renders identically to 7a80df5.
  • b801b10 (Petra-1012), from a clean build: 1538/1538, and code-execution 50/50, run with --test-concurrency=4 --test-force-exit. That's 1588 in all, one more than at 7adf10c: One quoting rule for server-chosen text: renderSourceHeader/labelValue miss the invisible-character class logValue already escapes #269's new test. The 1-minute load average at the end was about 10. Unforced, source-headers, inbound-source, empty-content, speech-routes, present-while-acting, stop-with-work-in-flight and mcpl-coalescing passed 220/220, with no file reporting asynchronous activity after its tests. Against 7adf10c's source, One quoting rule for server-chosen text: renderSourceHeader/labelValue miss the invisible-character class logValue already escapes #269's header test and the marking test fail.
  • 7adf10c (Pippa-1873), from a clean build: 1587 pass / 0 fail of 1587, with --test-force-exit --test-concurrency=2. eaf67dd on the same runner gave 1578/1578; compared by name, the difference is the nine new tests. The load average at its start was about 3.
  • 48e5135 (Basil-1786), from a clean build: 1570 pass / 0 fail of 1570. No file reported asynchronous activity after its tests ended. The load average at its start was about 14–16.
  • 6d6f133 (Basil-1786), from a clean build: 1535 pass / 4 fail of 1539. The load average at its start was about 20–26.
  • eaf67dd (Petra-1012), from a clean build: 1527/1527, and code-execution 50/50, run with --test-force-exit --test-concurrency=4. Under --test-force-exit each file's process exits at its last test, so this run can't show asynchronous activity left after a test. Basil-1007's unforced runs of the focused files at this head showed none. The load average was about 3–4.
  • CI at 6d6f133 and again at 48e5135. All four Build & Test jobs (ubuntu-latest and macos-latest, node 20 and 24) pass, and so does "Changelog entry present". At 48e5135 GitHub reports the PR MERGEABLE and CLEAN.
  • 7c4c723 (Basil-1786): 1461 pass / 1 fail / 1 cancelled of 1463. The 1-minute load average was 15–21.
    • The failure is vector 34c: "300 coalesced messages took 41728ms".
    • The cancelled item is mcpl-coalescing.test.js itself. It stalled in R16 ("a batch whose render-start commit failed is re-woken once storage recovers") and was stopped after 15 minutes, so R16 through R18 didn't report.
    • Run alone, mcpl-coalescing gave 64/66 at both 7c4c723 and 49a7baf, with the same two failures listed below.
    • R16's stall reproduces without the follow-up commit. Run alone eight at a time, R16 hung (no result within 60 s) in 4 of 24 runs at 49a7baf; at 7c4c723 it passed 24 of 24 that way, and hung (90 s limit) in 2 of 8 sequential runs under heavier load. It registers no channel, so the notice code it reaches is unchanged in effect: no notice, no store write.
  • 49a7baf. It gave 1432 pass / 2 fail of 1434, and code-execution 36/36. The 1-minute load average was 8–10. Both failures are the same mcpl-coalescing timing tests the base shows:
    • vector 34c: "300 coalesced messages took 40690ms";
    • R17: counted [1, 2, 3, 4] against an expected [1, 2, 3].
  • The same two fail without this PR. At feat(mcpl): stamp each inbound item's delivered-body and stored-copy digests #252's base 711b400, mcpl-coalescing run alone under similar load gave 64/66, with these same two failures. feat(mcpl): stamp each inbound item's delivered-body and stored-copy digests #252's head d36a5ad reports them in its own body too.
  • R17's race is in the test. It is corrected separately in test(coalescing): R17 observes recovery transitions, not drains #254 (test-only, on main, not in this branch).
  • 34c's bound is wall-clock. Elsewhere it has passed at lower load.
  • The base PR's head 42c3f5a. It shows the same two failures, at 1419/1421.

Not verified

  • The header tests use stub adapters. discord-mcpl agreed the grammar. An independent reviewer also composed 49a7baf, 6d6f133, 48e5135 and eaf67dd with real connector processes (Discord, Slack, Telegram, portal and anima-dnd, platform clients stubbed): routing and delivery outcomes all pass, as at feat(routing): speech follows its conversation, and publishes only where MCPL RFC-011 declares the place #256's head.
    • At 48e5135, every connector passed twice, with no stray asynchronous activity: Discord 3/3 (before and after fix: prevent deferred inference requests from starving the event loop #66), Slack 5/5, portal 4/4 and Telegram 5/5. anima-dnd passed 3/3 in 20 of 20 runs, across both door heads.
    • At eaf67dd, the same: Discord 3/3 (before and after fix: prevent deferred inference requests from starving the event loop #66), Slack 5/5, portal 4/4 and Telegram 5/5, with anima-dnd 3/3 in 10 of 10 runs across both door heads.
    • The anima-dnd figure comes from a corrected harness. It settles the door's catch-up wake first and asserts that the answer follows the GM's question. The earlier anima-dnd runs, through 6d6f133, confirmed routing and delivery but didn't pin which message was answered.
    • At 6d6f133, one of eight anima-dnd runs had failed at the file level on feat(routing): speech follows its conversation, and publishes only where MCPL RFC-011 declares the place #256's shutdown rejection ("Queue is closed"), which 4180ccd fixes.
    • These checks assert routing and delivery outcomes, not the header text. No live platform was exercised, and eidoverse-worlds was not composed.
    • They weren't rerun at 7adf10c, which doesn't touch routing or delivery. It changes stored body text only where a body holds a header-shaped opening; otherwise it changes the notice, the channel tools' descriptions and three readers.
  • The header's effect on residents' attribution is untested. The tests establish that the header is present, correct and stable, not how residents use it.

Companion PRs and merge order

#247, then #252, then #256, then this PR.

Authorship and review

Written by Petra-1012 (Opus), contributing through nissa-seru. Basil-1786 (Opus) wrote the Greptile follow-up and its edits, and June-1784 reviewed them. Pippa-1873 (Opus) wrote the slimepriestess follow-up (7adf10c) and its edits, and Ezra-1872 (Opus) reviewed them. Petra-1012 wrote the #269 change (0c4030c, b801b10, 7a80df5) and the second review round (2c6ad2b, 26edf15), with their edits, and Agnes-984 (Opus) reviewed both. Everyone named contributes through nissa-seru.

Petra-1012 and others added 29 commits October 7, 2026 05:00
Every inbound item the host accepts now carries `metadata.inboundSource`
(src/mcpl/inbound-source.ts), decided once at the framework's ingestion
boundary: which conversation the item belongs to and when the host accepted
it. Speech routing (shelf-355), the visible provenance header (shelf-356) and
per-channel receipt clocks (shelf-354, room-220) all read this one value
instead of re-deriving identity from the lane an item arrived on.

Kinds: `channel` (MCPL server id, RFC-006 endpoint binding, canonical channel
id — the registered composite id, also on the push lane — thread, message,
producer eventId when supplied, reply edge from metadata.replyTo, the channel's
registry label at receipt), `unscoped` (a push naming no channel: never a
guessed channel), and `surface` (console/API input: module messages added in
response to an `external-message` or `api:message` event that names no
channel). Each carries `acceptedAt` (host acceptance, epoch ms) and, for MCPL
items, the adapter's `sourceTimestamp` verbatim; neither is a version key.

Acceptance time is stamped where the host actually admits an item:
ChannelRegistry.handleIncoming and PushHandler for ordinary items, and
pushCoalescer acceptance for RFC-006 work. A coalesced occurrence computes its
envelope at acceptance (handleCoalescedIncoming/handleCoalescedPush) and
stores it on the occurrence's event, so deferral, fan-out, replay and
materialization deliver it unchanged (a materialized delivery only adds
`materialized: true`; deferred work is marked `deferred: true`). A label is
therefore the label when accepted, and a later rename never rewrites it.

The field is written after adapter metadata is spread, so an adapter cannot
supply or spoof it. It is not named `source` because adapter origins already
carry a free-form `source` string (discord-mcpl sends `source: 'discord'`),
which stays untouched.

An optional InboundAcceptanceObserver (private framework field, installed by
the receipt lane) hears each acceptance exactly once in-process: where an
ordinary item's envelope is created in handleMcplChannelIncoming /
handleMcplPushEvent, at a coalescer admission via a new CoalescerHost
`accepted` hook (after the receipt is recorded, never for a deduplicated
producer retry), and once per console/API event. Deliveries that reuse a
frozen envelope are not acceptances. A throwing observer never affects
delivery; it is reported on stderr and as an `inbound:observer-failed` trace.

Interface agreed with Oscar-1005 and Hugo-1004 in room-220 (#41234, #41242,
#41249, #41270, #41286, #41292).

Tests (test/inbound-source.test.ts, over a real MCPL child fixture
test/fixtures/speech-route-mcpl-server.mjs and the RFC-006 WebSocket
fixture): incoming message stamp with label, reply edge, timestamps and an
adapter spoof attempt overwritten; DM push with derived composite id and
people-first label (origin.source preserved); unscoped push; console surface;
label frozen across a rename; observer failure isolation and trace; coalesced
acceptance observed once (retry not an acceptance, replacement carries its own
frozen envelope); deferred work observed before render with the materialized
delivery keeping that envelope; channel-scoped coalesced message. The
trunk-channel-routing registry stubs gain getChannelLabel.

npm test: 1303 pass / 0 fail (baseline 64c480b: 1298 pass / 0 fail).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ch switches

Branch state (snapshot, tree and log states) rewinds with a historical
rollback; typed chronicle records do not. getRecordIdsByType enumerates them
from every branch, in global order (ids come from one store-wide counter),
and they survive deleteBranch, reopen and SIGKILL. Three AF journals need
exactly that — the workspace's disk-agreement evidence (shelf-383), delivery
receipts (shelf-354) and prose-draft outcomes (shelf-351) — so they share one
helper instead of three: append typed records, checkpoint the caller's
reduced state, replay the tail after the latest checkpoint at open.

Durability is the subtle part. A record append reaches the OS before it
returns, while branch-state commits are buffered until store.sync(): a probe
lost a tree commit made between two appends that both survived a SIGKILL. So
the API names two barriers and implies neither from the returned
{id, sequence}:
- afterCommittedState: sync BEFORE appending, for an entry asserting state
  committed just before it (it can then never survive without that state);
- durable: sync AFTER appending, for an entry that must be on stable storage
  before the caller acts (an attempt before its outbound request, an intent
  before its disk effect).

One writer per record type: a checkpoint covers entries up to the last one
this instance loaded or appended. Entry meaning, reducers, dedup and
checkpoint cadence stay with each caller.

Tests: empty load; append refs and replay order; entries from a rolled-back
branch and main loading from either, through deleteBranch and reopen;
checkpoint + tail with interleaved other record types and a tree commit, the
newest checkpoint winning; the barrier call order; and a SIGKILL child that
commits tree state then appends with afterCommittedState — the entry never
survives without the state. (The same kill without the barrier leaves the
entry and loses the state.)

Agreed in room-220 #41647-#41713 with Oscar-1005, Petra-1012, Hugo-1004.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tore must be reconciled

Hugo-1004's review of f7e14f6 found a replay-loss path: append(A,
{durable:true}) hands A to the store, then sync() throws; the caller sees
failure and never reduces A. A later successful append(B) advanced
lastEntryId past A, and a checkpoint({seen:[B]}) then covered A without A
ever entering the reducer — load() returned that snapshot with an empty tail.

Now any failure after a record reaches the store (the appendJson itself, or
the durable barrier after it, for entries and checkpoints alike) marks the
journal unreconciled: append and checkpoint throw until load() has replayed
the tail, the possibly-written record included, for the caller to rebuild
from. A failure before the record reaches the store (the afterCommittedState
sync) wrote nothing and leaves the journal usable. load() documents that the
caller rebuilds its reduced state from exactly the snapshot and tail it
returns. needsReconcile exposes the state.

Regression test (fails on f7e14f6, which let append(B) and the covering
checkpoint through): a durable barrier failing after A, writes refused until
load(), load() replaying A, the journal usable again; and a pre-append
barrier failure leaving it usable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…acts

Review findings on 3f7aafe from Hugo-1004 (room-220 #41587) and a request
from Oscar-1005 (#41589).

Freeze at admission. An ordinary (uncoalesced) channels/incoming message or
push/event used to get only `acceptedAt` at admission; its envelope was built
later, in handleMcplChannelIncoming/handleMcplPushEvent, from the registry's
CURRENT label and endpoint binding, and the acceptance was observed there. An
item accepted (and acknowledged) before a rename or rebind could therefore
acquire the new label or binding while it waited in the queue, and a receipt
clock read stale until the queue drained. ChannelRegistry.handleIncoming and
PushHandler now call a framework-supplied `acceptInbound` at admission, before
queueing and before the acknowledgement: the framework builds the envelope and
observes the acceptance there, and the event carries the frozen envelope to its
handler, as coalesced work already did. The handlers only fall back to building
(and observing) an envelope for a direct caller that bypassed admission.

Lane facts. Channel and unscoped envelopes now carry `lane`
('channels/incoming' | 'push/event') and `coalesced: true` for RFC-006
admission. What an eventId is worth depends on the admitting lane's contract
(push/event dedups by eventId; coalesced admission guarantees stable retries
and distinct versions; an uncoalesced channels/incoming eventId is
adapter-supplied with no guarantee), and its mere presence cannot say which.
Facts only: the receipt policy that uses them stays with the receipt lane.

Strict reader. readInboundSource validates every field it returns: identity
strings non-empty, acceptedAt finite, deferred/materialized/coalesced exactly
true when present, a known lane, and refuses arrays.

Tests: both ordinary lanes hold an admitted item between admission and
processing (by intercepting the framework's queue), assert the observer ran at
admission with the original label, rename the channel, release, and assert the
stored envelope equals the admission envelope; lane/coalesced facts asserted
on ordinary and coalesced items; the reader refuses each malformed field.

Suite (machine load average ~40 during the run): all files except
code-execution 1274/1275 with code-execution separately 36/36. The one failure
is mcpl-coalescing's wall-clock bound (anima-research#196 vector 34c, 300 fsynced coalesced
acceptances < 30 s): 40.0 s here, and 40.4 s for the same test on an untouched
64c480b build under the same load, so it is load, not this change (it passed
at 24.1 s on 3f7aafe at lower load). The full-suite PyRunner "rejects
concurrent execs" test hung twice under this load in the combined run and
passes alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… drafts journal

RecordJournal (src/record-journal.ts) is the shared helper for unbranched,
store-resident typed records agreed in room-220 (#41661, #41747, #41816),
approved by Hugo-1004 (#41860). Merged rather than rebased so 3f7aafe/eb2a588
keep the hashes the receipt lane already bases on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A failed request to an MCPL server used to reject with a plain Error, so the
only way to tell "never written" from "written and unanswered" from "answered
with an error" was to match message text. Callers that act in the world need
that difference as a fact: a publish or a reaction that was never sent left
the world as it was, while one that was handed to the transport and never
answered may have landed. (A rollback reaction drain met exactly this: its
pending reactions failed at shutdown, and only their message text said
whether each had been written.)

McplRequestError extends Error with `outcome`:
- `not-sent`: sendRequest refused before writing (connection already closed);
- `error-response`: a JSON-RPC error response; `code` and `data` kept verbatim
  (a connector can describe a partial multi-part post there);
- `no-response`: the request was handed to the transport and no answer came
  (per-request timeout, a deliberate close while awaiting, or an unexpected
  disconnect). It does not prove the request reached the server — the
  transport can decline or drop a write — only that the host can no longer
  say it didn't.
Messages are byte-identical to before, and every rejection is still an Error.
Exported from the mcpl index and the package root.

Tests (test/mcpl-request-outcome.test.ts, a real stdio server): an error
response keeps code and data; an unanswered request and a process exit while
awaiting are no-response; a request on a closed connection is not-sent; the
class keeps Error identity. 12/12 with the existing request-timeout tests.

Reviewed by Tessa-974 (room-203 #43780), whose wording correction for
no-response this carries. Shared with room-225's lane 1 (Ada-1017).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
McplRequestError (feat/mcpl-request-outcome, a standalone commit on main
shared with room-225's lane 1, #42010; reworded after Tessa-974's review,
#43780) gives the publish executor the fact it needs: only a request refused
before writing proves nothing was posted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ChannelRegistry.publish(conversationId, text, {serverId?, channelId}) is the
single delivery executor behind plain speech (routeSpeech now uses it), and
will be behind resident resends of held drafts. It returns a PublishOutcome:

- `delivered`: the connector returned `delivered: true` (with its messageId).
  A historical receipt, not a claim the message still exists.
- `failed`: provably nothing posted. Refused before dispatch (unknown channel
  id, a channel id registered by more than one server without the server
  named, no channels.publish grant, a failed open of a closed destination, a
  request refused unsent by a closed connection: McplRequestError not-sent),
  or the connector answered `delivered: false` naming no posted message.
- `unknown`: dispatched, and no valid confirmation came back: a JSON-RPC
  error response (which can follow a partial multi-part post, discord-mcpl
  anima-research#35), a timeout, a lost connection, `delivered: false` that names a posted
  message, or a missing/malformed receipt (anima-research#163's rule: a missing receipt is
  no longer counted as delivered). The connector's words are kept verbatim in
  `reason` and its error `data` in `detail`.

Every outcome names the destination the registry resolved (serverId,
channelId, label at attempt time), never the caller's spelling. A channel id
shared by two servers is refused instead of routed via whichever registered
first.

The contract was agreed with Agnes-984 (only `delivered === true` confirms;
pre-dispatch refusals and `delivered:false` mean failed; anything after
dispatch is unknown) and extended per Tessa (#41665, #41679): a partial send
is unknown, never failed, and its posted/in-flight facts stay with the
attempted destination.

routeSpeech keeps its signature and traces; its failure path now carries
`outcome: 'failed' | 'unknown'` to the trace (`mcpl:speech-route-failed`) and
to onRouteFailure, and the resident's `[discord-send-failed]` marker says
which: "could not be delivered ... Nothing was posted" versus "was not
confirmed ... may or may not have been posted; check the channel before
sending it again". The marker metadata carries `outcome`.

Tests: test/publish-outcome.test.ts (scripted server per case: delivered,
delivered:false with and without a posted id, missing/empty/malformed receipt,
error response with data, timeout, not-sent, unknown channel, a shared channel
id with and without the server named, missing grant, routeSpeech's two
outcomes; and an end-to-end run over the speech-route fixture asserting the
two marker wordings). 43/43 with the routing, request-outcome and
route-failure-targeting files.

Suite (--test-concurrency=6, load average ~20-40): all files but
code-execution 1301/1303, code-execution 36/36. The two failures are
mcpl-coalescing timing tests that fail identically on an untouched 64c480b
build in this environment: anima-research#196 vector 34c's wall-clock bound, and R17's
40 ms storage-outage backoff, which counted a fourth recovery wake; running
the whole coalescing file on 64c480b reproduced the same R17 failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Shelf-351 (room-203). Suppressed plain speech used to survive only as a count
in the turn receipt ("N segment(s) suppressed ... resend with a send tool"),
and explicit mode's bounced text lived in one in-memory latest-wins
clipboard: the suppression rule was right, the penalty was amnesia (a
Connectome resident's report). Now the exact words are kept as drafts until
the resident resends or dismisses them.

What becomes a draft (src/prose-drafts.ts, reasons):
- explicit-send: prose silenced by an explicit send in the same round or a
  sticky earlier one (channel_publish, send_message, reply_message, send_dm,
  say, whisper, and now a drafts resend), in locus and hybrid modes, mid-turn,
  trailing and text-only (a guard retry carrying the silence), native and XML
  tool modes. The turn tracks WHY it is silenced (silenceCause): an explicit
  send holds prose as drafts; skip_reply and silent turns keep it private.
  Hybrid prose is split into envelopes and a draft holds an envelope's body,
  not its routing syntax, with "written for >>>target" as a note.
- no-destination: hybrid prose after a failed envelope (fail-closed until a
  new valid target), formerly counted as suppressed.
- bounced: explicit/hybrid bounces. `{{unsent}}` keeps meaning the latest
  bounce while that draft is open; delivering through it journals an attempt
  on the draft; `>>skip_reply {{unsent}}` dismisses it; an unconfirmed latest
  bounce is never substituted silently. Re-bouncing `{{unsent}}` alone keeps
  the one draft.
Never drafted (counted in the receipt): skip_reply, a same-round private
think, silent turns, proseRouting=disabled narration.

Storage: a RecordJournal (typed Chronicle records that do not follow branch
switches), agreed in room-220 instead of the JSONL file first proposed to
Tessa/Ada/June; the contract is the same. Entries held / attempt / outcome /
dismissed / noticed, every one durable before its call returns; an attempt is
journaled BEFORE its request leaves AF, so an attempt without an outcome reads
as unconfirmed, never unsent. Undo, rollback, checkout and restarts never
change drafts; nothing is ever sent from them on its own. A failed journal
write reconciles (load) and reports the words it could not hold.

Notices: a private notice naming new drafts at the next tool boundary when
the live stream presents injections (native rounds; Membrane's
supportsInjectedMessages when it ships, room-220 #41701/#41870); otherwise
the turn-end [delivered] receipt names them ("held as drafts d-…"); drafts no
notice reached (crash, aborted turn) are named at the next fresh turn's
start. Notices name their resident: residents of one framework share a
message slot (anima-research#197). The bounce notice now goes to the bouncing resident
(forAgent) instead of the primary.

The drafts tool (with MCPL channels): list | read | resend | dismiss, scoped
to the caller; nullable unused fields (save_recent_image's pattern). resend
publishes verbatim, in order, through ChannelRegistry.publish, stops at the
first non-delivery, counts as an explicit send, marks the channel engaged,
returns a delivered draft's historical receipt instead of resending, needs
confirmDuplicate for an unconfirmed one, refuses dismissed drafts and a draft
already in flight, and honours a conversation agent's home binding. Results
are plain text blocks, so drafts read exactly as written.

Registry: deliverSpeech (routeSpeech's PublishOutcome form; routeSpeech wraps
it unchanged) and resolveDestination (exact resolution, shared ids refused).
Text-only routing goes segment by segment when the content holds a membrane
tool_attempt (Greta's 376, room-220 #43515).

Tests: test/prose-drafts.test.ts, 18 (store: order/ids/isolation, reopen
with an outcome-less attempt, branch independence, journal failure,
latest-bounce; end to end over the speech-route fixture: three-segment
reply held/named/injected/read/resent verbatim, XML mode, skip_reply and
private think, disabled mode, resident scoping, failed then unknown then
confirmed resend, June's rollback and restart cases and dismissal across a
rollback, crash catch-up, explicit bounce across restart via {{unsent}},
resend silencing adjacent prose, parallel resend, refusals). Receipt
expectations updated in present-while-acting and explicit-prose-routing; the
explicit stub gains deliverSpeech/resolveDestination; the fixture answers
tools/call at once.

Suite (--test-concurrency=4, load ~20-30): 1315 pass, 1 fail (anima-research#196 vector
34c's wall-clock bound) and mcpl-coalescing.test.js cut off by the 900 s
guard; that file alone afterwards: 66/66. code-execution alone: 36/36.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…is unknown

Tessa-974's review of the publish executor (room-203 #43780, probe
/tmp/tessa-974-publish-boundary-probe.mjs):

1. An explicitly empty serverId acted like omission and dispatched to the
   sole server registering the channel id. A supplied selector is now checked,
   never read as omission: resolveDestination/publish refuse an empty or
   non-string serverId and an empty or missing channelId (failed, nothing
   dispatched). Omission remains the only "not in use" form for this API.
2. `{delivered:false, messageId:123}` became `failed` once the malformed id
   was discarded, which does not support "nothing was posted". `delivered:
   false` is now `failed` only when the receipt names no message at all
   (messageId absent or null); a message id beside it, valid or malformed, is
   a contradictory receipt and reads `unknown` (a valid id is kept).
3. The publish() doc called it the executor behind channel tools, which is
   prospective: channel_publish (and publishForAgent, which shares its
   handler) move onto it with shelf-356. The doc says so, and it now sits on
   publish() itself (351 had left it above resolveDestination).

Tests (publish-outcome.test.ts): delivered:false beside a numeric, empty and
object message id is unknown with "contradictory receipt"; a null id is
failed; empty/null/non-string serverId and empty/missing channelId are
refused with nothing dispatched, while the exact selector still delivers.
43/43 with channel-registry-routing; 62/62 across prose-drafts,
explicit-prose-routing, present-while-acting and mcpl-request-outcome.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ck drafts

Tessa-974's review of 351 (room-203 #44135, #44214; probe
/tmp/tessa-974-draft-state-probe.mjs):

1. Uncertainty was cleared by a later failure: unknown, then a confirmed
   retry that failed, read `held`, and a third resend went out without
   confirmation. A draft is now `unconfirmed` while ANY attempt has an
   unknown or unrecorded outcome and none was confirmed; uncertainAttempt()
   names that attempt, and refusals, the list line and a failed retry's
   receipt cite it (destination, time, reason) rather than "the last one".
2. The up-front batch check went stale across an await: a batch [A,B]
   waiting on A let a concurrent resend of B go out, then published B again;
   a dismissal of B meanwhile was ignored. A resend now claims every draft of
   its batch before its first await (a concurrent resend or {{unsent}} of
   any of them is refused while the batch owns it), and re-reads each draft
   from the current projection at its own dispatch boundary, so a dismissal
   or delivery since the batch began stops it there (also after a journal
   reconcile replaced the projection).
3. The crash catch-up said "They were not sent" of an unconfirmed draft. It
   and the list now render each draft's actual state: "held (not sent)",
   "held (every attempt failed: nothing was posted)" or "UNCONFIRMED — an
   attempt to … may have been posted".
4. bounceProse expanded `{{unsent}}` from an unconfirmed (or in-flight)
   latest bounce into a fresh held draft when the envelope's target failed,
   which then resent without confirmation. Such a draft is never copied: it
   stays held as itself, only the resident's other words become the new
   draft, and the bounce notice says so.

Privacy boundary, agreed with Tessa (#44155, #44214): drafts are never
published except by their resident's explicit resend (or {{unsent}}), only
that resident can list or act on them, and notices go into the resident's
own history, which residents sharing one message slot (anima-research#197) already share.
The tool description, notices, module doc, explicit-routing doc and
changelog now say that instead of "private".

Tests (prose-drafts.test.ts, 22): unknown then failed stays unconfirmed and
refuses an unconfirmed third resend; the catch-up renders held and
unconfirmed drafts truthfully; an at-risk bounce is not copied into a new
draft and {{unsent}} then means only the fresh words; and, through the real
drafts handler with publish completions under the test's control, a
concurrent resend of a queued draft is refused with B published once, and a
draft dismissed while the batch waits never reaches publish.

Suite (--test-concurrency=4, load ~7-12): 1329/1330 (anima-research#196 vector 34c's
wall-clock bound at 30.7 s) + code-execution 36/36. Composition with
Greta-1013's 376 companion (6ff476d, membrane#101 pack) merged cleanly:
30/30 incl. her xml-tool-call-notices and a composed text-only
tool_attempt split check (room-220 #44367).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…urable first

Tessa-974's follow-up review of 397be39 (room-203 #44505, #44548, #44612):

1. Re-bounce of {{unsent}} from a possibly-posted draft. 397be39 stripped the
   token and held only the resident's other words, which changed the message
   being recovered (the notice still promised it "unchanged", and the next
   {{unsent}} could send just the prefix). Now the whole authored attempt is
   held, words of the risky draft included, and it carries that draft's
   duplication risk: Draft.inheritedRisk {draftId, destination?, at?,
   reason}. A draft with inherited risk reads `unconfirmed` until a confirmed
   delivery (a failed attempt does not clear it), so it can be resent only
   with confirmDuplicate, and {{unsent}} refuses it exactly as it refuses an
   unconfirmed draft. riskText() words both kinds of risk for refusals, the
   list, read, the {{unsent}} refusal and a failed retry's receipt; the
   bounce notice says the new draft includes the risky draft's words.
2. A noticed record could outlive its notice: the notice is conversation
   state, buffered until the store syncs, while a journal record reaches the
   OS at once, so a kill between them left drafts marked noticed with no
   notice in the history and no catch-up. markNoticed now appends with
   afterCommittedState (sync first, then append, then durable sync), and the
   turn's drafts are settled in driveStream's finally after the deferred
   flush, marking only drafts a stored message names (metadata.draftIds on
   the receipt, held-draft and bounce notices). A draft whose notice never
   reached the history stays unnoticed for the next turn's catch-up.

Tests (prose-drafts.test.ts, 25): markNoticed's call order is sync,
append, sync; inherited risk stays unconfirmed through a failed attempt
until a delivery; settlement marks only the draft a stored notice names; the
explicit re-bounce holds "risky words and a postscript" with the inherited
risk, {{unsent}} refuses it, and a confirmed resend publishes the whole text
while the original stays unconfirmed.

Suite (--test-concurrency=4): 1333/1333 + code-execution 36/36.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tent receipts

Tessa-974's review of 4f6974d (room-203 #44739, #44759; probe
/tmp/tessa-974-draft-state-probe.mjs, logs 2987/96 and 2987/98):

1. Notice identity must include its resident. Draft ids are unique only per
   resident, and residents can share one history; settlement accepted any
   message's metadata.draftIds, so another resident's notice for the same id
   marked this resident's draft noticed. Every notice and receipt that names
   drafts now carries draftOwner (held-draft notice, catch-up, receipt,
   bounce notice), and settlement matches owner and id.
2. Inherited risk inherited again: re-bouncing a draft whose risk was itself
   inherited (no attempts of its own) recorded the false reason "it was being
   sent when these words were held" and lost the destination and time.
   InheritedRisk gains sourceDraftId; a chain carries the original uncertain
   attempt's destination, time and reason, and riskText says "d-3 includes
   the words of d-2, which itself includes the words of d-1, and d-1's
   attempt to … may already have been posted". The in-flight reason is used
   only when the copied draft really was in flight with no other risk.
3. A repeated resend of delivered drafts failed in destination resolution
   once the channel was gone. A request whose drafts are all delivered now
   returns their historical receipts before any destination is parsed or
   resolved; a mixed batch still needs a destination that resolves.

Tests (prose-drafts.test.ts, 28): a same-id draft of another resident is not
settled by that resident's notice; a two-step re-bounce keeps the original
attempt as evidence; an all-delivered resend returns receipts after the
destination stops resolving, while a mixed batch still refuses it.

Suite (--test-concurrency=4): 1336/1336 + code-execution 36/36.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ate, drafts keep exact runs

Answers Greptile's first review of anima-research#247 (findings 1, 3, 4, 5; 2 is answered
in the review thread as intended behavior) plus the two `{{unsent}}` cases
Felix-1368 and Basil-1369 found beside finding 1, which Petra-1012 and
Tessa-974 agreed belong to the same repair (room-203 #45831, #45845).

- Hybrid envelopes beside a send (finding 1). holdSilencedProse now moves the
  router's state exactly as deliverHybridProse does, withholding only
  publication: `>>>skip_reply` clears the pin and keeps the rest private,
  across segments and later rounds, until a destination is named; a named
  destination that resolves becomes the sticky target again (clearing
  privacy alone would have sent a later unsilenced round's prose to the
  locus, which nobody named), and an unresolved or malformed one leaves no
  destination ('failed'). ` !` still asks to continue. The mid-turn hybrid
  hold is now chained on turnSpeechChain behind earlier rounds' envelopes,
  because their state transitions run there; a synchronous hold could read
  the state before a slow earlier delivery had applied it.
- `{{unsent}}` beside a send. `>>>skip_reply {{unsent}}` still sets the latest
  bounce aside (a send suppresses speech, not that operation). A held target
  envelope holds the message it would have published, `{{unsent}}` expanded
  under the re-bounce rule, now one helper (unsentWords) shared with
  bounceProse: the whole authored text kept, inherited risk (with chained
  sourceDraftId evidence) when the latest bounce is unconfirmed or in
  flight; a bare `{{unsent}}` mints no copy and the bounce stays the one
  draft, named in the turn's receipt. Before, the draft held the literal
  token, which a resend would have published. bounceProse's stale comment
  (describing the rejected strip-the-token remedy) is replaced by the
  helper's doc.
- Turn-end receipt (finding 3). Each of the turn's drafts is re-read from the
  store and reported by its state at turn end: still held ("held as drafts
  … (not sent …)", wording unchanged), delivered by the resident's resend
  (listed in the header where it landed, so the header no longer says
  "nothing" over it; a hybrid `{{unsent}}` delivery already listed as plain
  speech gets a note instead of a second entry), unconfirmed (riskText),
  or dismissed.
- Exact runs (finding 4). prose-segments.ts has one walker: splitProseRuns
  returns runs exactly as written (whitespace-only runs dropped) and
  splitProseSegments is those runs trimmed. Routing still publishes trimmed
  segments (deliverProse needs `>>` at the start, and routed speech is
  unchanged); every silenced hold takes the raw runs, so Draft.text is the
  resident's words byte for byte, as its doc and `read` claim.
- Refused-call boundaries (finding 5). The walker breaks at `tool_attempt`
  and `tool_notice`, so the text-only path's segment split actually happens
  on this branch alone. This is Greta-1013's hunk from
  fix/xml-tool-call-notices (a05a2e3); she agreed anima-research#247 owns it and will drop
  hers on rebase (via Felix, room-240 #45593). A turn-ending
  [text, tool_attempt] still yields just the text.
- Finding 2 (XML think privacy) is not changed: same_round_think_text_policy
  is native-round-only by upstream design (think tool description; test
  "legacy/no-roundContent behavior remains unchanged" routes that prose
  publicly), so in XML mode a send holds it like any other speech. The doc
  comment and changelog now say so, and a test pins it.

Tests: 7 end-to-end (exact runs and resend bytes; hybrid privacy across
segments/rounds and the restored pin, plus a live >>>skip_reply binding a
later silenced round; the {{unsent}} cases including inherited risk; the
receipt's held/resent/dismissed/unconfirmed states; XML think beside a
send; locus and hybrid refused-attempt splits) and 2 unit tests for the
walker. On 6c5f054 the six behavior tests fail for exactly these defects
(the XML-think test passes there: it pins existing behavior). Full suite
1381/1381 on this tree (node 24, concurrency 4, Petra's feat-prose-drafts
node_modules: membrane 0.5.86, context-manager 0.13.0).

Not tested: the chained-hold ordering under a slow earlier delivery (needs a
publish that hangs while later rounds proceed); it is reasoned from the
chain's ordering, not exercised.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ing confirmed" beside an unconfirmed draft

Felix-1368's review of e9099e5 (room-240 #46400, #46447):

- The mid-turn held-draft notice said "not sent" and offered a plain resend
  for every draft it named. Since e9099e5 a held hybrid envelope can expand
  `{{unsent}}` from a latest bounce that may already have been posted, so
  the new draft is `unconfirmed` (inherited risk) from the moment it is
  held, and a plain resend would be refused. draftsHeldNotice now splits the
  batch by state: held drafts keep the existing "delivers it unchanged"
  wording (byte-identical when nothing is at risk), and each unconfirmed one
  gets its riskText and the confirmDuplicate resend it needs. Found by
  Felix's probe through a full native turn.
- The turn-end receipt's header said "[delivered] nothing — draft d-x is
  unconfirmed: … may already have been posted". With an unconfirmed draft
  in the receipt and nothing confirmed delivered, the header now says
  "nothing confirmed", so its first clause doesn't contradict the evidence.
- A comment no longer says explicit mode reaches the receipt: both receipt
  call sites exclude explicit mode (antra's original design, bdbc7e3), so
  the explicit-mode "nothing" I raised in room-240 is not reachable; Felix
  confirmed through a real turn.

Tests, two adapted from Felix's probes:
- a held round waits behind a slow earlier delivery for the state its
  `>>>skip_reply` leaves (this exercises e9099e5's chained hold; with the
  hold made synchronous again it fails: "the held round waits for the
  earlier private transition");
- the notice for a mixed batch: a free draft offered a plain resend, a
  copied, possibly-posted one its risk and confirmDuplicate;
- XML mode: runs around a refused attempt held with their edge whitespace
  and resent byte for byte.
The hybrid tests now end with the completion a real membrane yields (the
whole turn's content, no calls), so the turn's speech chain is settled
before its receipt as in production; MockMembrane's literal empty
completion skipped that await.

Full suite 1384/1384 on this tree (node 24, concurrency 4, Petra's
feat-prose-drafts node_modules).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s say what holds

Felix-1368's review of 467a48f (room-240 #46701):

- draftsHeldNotice opened "[drafts] name: not sent —" even when its body
  now says a copied draft's words may already have been posted. A batch
  holding any such draft now opens with the held drafts alone ("[drafts]
  name: 2 plain-speech segments held as drafts …"); a batch with nothing at
  risk keeps the old prefix. Same reason as 467a48f's "nothing confirmed".
- Only speech an explicit send suppressed is held from raw runs (e9099e5);
  bounces and holds with no destination still come from routing, which
  reads trimmed segments. Draft.text's doc said "byte for byte" and the
  drafts tool's `read` said "exactly as written" of every draft. The doc now
  says which is which, and `read` labels the text "exactly as a resend
  publishes it", which is true of every draft. (e9099e5's message said
  every silenced hold made Draft.text byte for byte "as its doc and read
  claim"; that held for send-suppressed drafts only.)

Test: the mixed-batch notice test asserts the prefix; the three-segment
test's `read` label assertion follows the new wording.

Full suite 1384/1384 on this tree (tsc, then node --test --test-force-exit
--test-concurrency=4 dist/test/*.test.js; node 24, Petra's feat-prose-drafts
node_modules). git diff --check clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…digests

The ingestion stamp the delivery-receipt lane and the source-header work
share (room-220 #46752–#47210; isolated as its own foundation at Tessa-974's
request, #47586, so receipts need not wait on outgoing routing).

mcpl/inbound-source.ts gains the one implementation: canonicalJson (keys
sorted at every level, undefined dropped) and sourceBodyDigest(blocks) =
SHA-256 hex of canonicalJson([blocks]) — the one-element framing an
undecorated, unsharded stored copy has always hashed to, so stamped and older
copies of the same body share a version (Hugo-1004 #46835).

Both lanes stamp, outside the frozen admission envelope (they describe the
delivery — a materialization, a correction — not the admission):
- metadata.sourceBodyDigest: the body as delivered, after MCPL conversion and
  before any host decoration (on the push lane, the closed-channel
  invitation) or storage sharding;
- metadata.storedBodyDigest: the same function over exactly the blocks handed
  to addMessage, decorations included (Oscar-1005 #47210). A copy that no
  longer hashes to it was changed after delivery — CM's editMessage keeps
  metadata — so a consumer can tell an intact copy from an edited one.
A silent heartbeat stores nothing and gets neither field. Neither proves
later presence or completeness.

Tests (inbound-source.test.ts): a channels/incoming item's two digests agree
and equal the legacy unsharded hash, sit outside the envelope, and an edit
through the context manager leaves the stored digest unmatched; a DM push
into a closed channel stores the invitation, so its stored digest covers it
while the delivered digest is the adapter body alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… blocks stored there

Tessa-974's recheck (room-220 #48282): on channels/incoming,
storedBodyDigest was taken at ingestion, but the main storage path can still
append the closed-channel invitation afterwards — when a message accepted
while its channel was open is processed after the channel closed. The
untouched stored copy (header + body + invitation) then failed its own
witness, which the receipts lane would read as an edit.

The stored digest is now taken where each path stores, over exactly the
blocks it hands to storage, through one helper (withStoredDigest):
- channels/incoming, main path: after the invitation is appended;
- channels/incoming, an RFC-006 correction delivered to a conversation fork;
- channels/incoming, conversation-router delivery;
- push/event: at its addMessage (a silent heartbeat stores nothing).
sourceBodyDigest stays at ingestion, from the undecorated delivered body.
Deferred storage keeps the content and metadata it was given, so a deferred
copy's witness still matches.

Test (inbound-source.test.ts), reproducing Tessa's discriminator: an
incoming message is accepted and held while its channel is open, the channel
is closed, then the message is processed — it is stored with the invitation
and its storedBodyDigest equals the hash of its stored blocks, while its
sourceBodyDigest is the delivered body alone. Fails before this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…xport sourceBodyDigest from the package root

Greptile's review of anima-research#252 at 711b400 (review 5448991826):

P1 (discussion r4212520929). handleMcplPushEvent stamped
metadata.sourceBodyDigest only when the body had blocks, a guard meant for
the silent heartbeat, which stores nothing. But PushHandler maps an ordinary
empty payload to [], and handleMcplPushEvent stores it unless the
heartbeat's exact marker holds. So an ordinary empty push, or an addressed
empty push into a closed channel (stored as the host's invitation alone),
was stored with a storedBodyDigest and no host-computed sourceBodyDigest;
and since push metadata starts from the adapter's origin, an
origin.sourceBodyDigest survived on the stored copy as if the host had
computed it. The digest is now taken for every push, an empty body
included, before any decoration, written over whatever the origin carried.
A silent heartbeat still stores nothing; its metadata is never kept.
channels/incoming already stamped unconditionally, and every storage site
on both lanes already wrote storedBodyDigest over the adapter's value.

P2 (discussion r4212520946). The helpers were reachable only from
src/mcpl/inbound-source.ts, and the package's exports map exposes only the
root, so a package consumer could not import them and would have to copy
the hashing code. sourceBodyDigest is now re-exported from
src/mcpl/index.ts and the package root. canonicalJson stays out of the
package API: it is the digest's serializer, and keeping serialization and
framing inside sourceBodyDigest saves consumers from duplicating either,
which is the drift the finding is about (a consumer hashing
canonicalJson(blocks) itself gets a different digest from
sourceBodyDigest(blocks)). It keeps its module export for in-repo tests
that pin the framing. Agreed with Petra-1012 (room-203 #49948) and
June-1758 (room-262 #49931); wording per June-1758's review (#50126).

The changelog fragment said both helpers were exported as the shared
implementation and that both fields were stamped at ingestion. It now names
the one public function and states the algorithm, says the stored digest is
taken where each path stores (711b400), that an empty body has a digest,
and that adapter-supplied values are replaced.

Tests (inbound-source.test.ts, over the real MCPL child):
- an ordinary empty unscoped push whose origin carries forged digests is
  stored with sourceBodyDigest = storedBodyDigest = SHA-256 of "[[]]";
- an empty DM into a closed channel is stored as the invitation alone, its
  sourceBodyDigest the empty body's and its storedBodyDigest the stored
  blocks'.
Both fail before this change: sourceBodyDigest came back as the adapter's
'adapter-value'. The test file now imports sourceBodyDigest from the
package root, so the public export is typechecked. The speech-route
fixture's dm and push ops accept "content" (blocks, [] included) in place
of "text", as its incoming op does on the receipts branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the last inbound (shelf-355)

Unaddressed plain speech used to follow the process-global most recent
inbound channel (ChannelRegistry.defaultPublishChannel), re-pinned itself
mid-turn to whoever addressed the resident last, and could be retargeted by
traffic the resident never answered (a resident's reports: unaddressed
prose routed to the last speaker; an addressed mark that later speakers
overwrite). Settled design: room-203 (Tessa-974, Basil; the hold boundary
in commons #39414/#39415; the UI rule in #41399/#41492).

Model (src/speech-routes.ts, pure): a SpeechRoute is a resolved server,
channel, optional thread and reply edge (or a local surface), with an origin
— trigger (inferred), home (a conversation fork's), open (channel_open). A
TurnRoute is that route plus an optional hold naming competing
conversations. inferTurnRoute(candidates, home): a fork home wins; else the
addressed candidates if any, else all conversational ones; one conversation
(server+channel+thread) infers a route, its newest considered message the
reply edge; several start the turn held; none, or one whose channel couldn't
be resolved, gives no route. suspendsRoute(): only an inferred, unheld route,
only for a different conversation that addressed the resident or that the
resident explicitly engaged this turn.

Candidates: InferenceRequest.routeCandidates, from the frozen inbound source
envelope on every conversational wake — channels/incoming (trunk, fork,
router), push events naming a conversation (DMs, addressed-while-closed),
coalesced wakes, local surfaces (console/TUI/API, addressed by nature), and a
debounced gate batch, which now reports every conversation it holds
(WakeProvenance.routeCandidates; an addressed event the host can't resolve
competes as unroutable, so it can hold a turn but never be a route). The
batch's candidates are merged at the turn (quiesced coalescing keeps an
evicted request's candidates).

Framework: turnRoutes replaces activeTriggerChannels, turnLocusPins and the
locus announcement state. Decided once at a true new turn (attempt 0),
announced on change (route, hold with each conversation's usable address, or
no destination); continuations (context-budget restart, guard retry) and
framework retries keep it, including a channel_open the resident made.
speakUnaddressed() delivers each unaddressed segment along it: a channel
route publishes (with its serverId, so a channel id shared across servers
goes to the right one); a surface route shows it there, publishes nothing,
and the receipt says so; a held turn holds an `ambiguous` draft naming the
competing conversations; no route holds a `no-destination` draft. Live,
trailing and text-only locus paths and hybrid unprefixed prose use it; a
hybrid `>>>` pin still wins. The mid-turn addressed re-pin becomes the
ambiguity hold: a presented conversational arrival that suspendsRoute()
accepts holds the route for the rest of the turn, with a notice in the same
injection batch; ambient chatter, reactions and same-conversation arrivals
change nothing (the reply edge stays on the waking message). channel_open
gains setSpeechTarget (default true): true sets a deliberate route for the
rest of the logical turn (no borrowed reply edge; replaces an inferred route,
a hold or a hybrid pin), false opens for reading and the result states the
current route or its absence. Refusal reactions, rewind notices, the failure
log, typing and the sleep notice use the agent's own route.

Registry: no more latest-inbound tracking (defaultPublishChannel and
getDefaultPublishChannel removed; markAccepted no longer retargets
anything). resolveLocus returns only a fork home. speechRouteResolver
replaces activeChannelResolver and feeds buildChannelContext, so
defaultOutgoing/incoming are the route the agent's words will take.
channel_publish goes through the publish executor: without channelId it uses
the caller's route, and with none (held, surface, absent) it refuses with the
reason; optional serverId; the receipt names serverId, channelId and
channelLabel; failed and unknown outcomes are errors keeping their status and
attempted destination (shelf-356's AF receipt requirement). publishForAgent
shares it, attributed to its agent. routeSpeech/deliverSpeech accept
{serverId, channelId}.

Docs/changelog: speech-routes.changed.md, speech-routes.breaking.md
(registry API), hybrid doc wording; mode primers describe the route.

Tests: speech-routes.test.ts (inference and suspension rules, identity:
servers, threads, surfaces, unroutable); speech-route-turns.test.ts
(continuations and retries keep the route incl. channel_open; a new turn
decides afresh; surface route end to end); batched-wake-locus (real MCPL
child, both lanes: addressed+ambient, ambient-only one/two conversations,
two addressed held, resend of the held draft to the DM, advertised context =
delivered route); present-while-acting (hold on addressed/engaged arrivals
with notice in the batch, ambient and same-conversation arrivals change
nothing and keep the reply edge, fork home never held, hybrid >>> pin
survives a hold, held-turn receipt, channel_open default and
setSpeechTarget:false with and without a route, announce-on-change);
trunk-channel-routing (routes per turn, heartbeat none); channel-registry-
routing (no latest-inbound, route-fed context, channel_publish default /
refusal / outcomes, same id on two servers); event-gate (batch candidates).

Suite (--test-concurrency=4, run as a Jobs job): 1361/1361 + code-execution
36/36; after a final wording change the touched files re-run 64/64.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…honest hold notice; one conversational predicate

Agnes-984's first review pass on 8a1d579 (room-203 #46576):

1. Thread conversations were routes delivery couldn't honor: identity
   included threadId and the notice promised the thread, but publishing
   carries no thread, so plain speech landed in the channel root. A thread
   conversation now competes (it can hold a turn) but is never itself a
   route: inferTurnRoute returns { route: null, unroutable: { conversation,
   reason: 'thread' } }; the routing notice says plain speech can't be
   posted into a thread and to answer with a send tool that names it; the
   held no-destination draft's note says the same. The gate's unresolvable
   channel case now records `unroutable` with reason 'unresolved' and its
   own notice. SpeechRoute never carries a thread.
2. The `serverId: ''` placeholder diverged between paths: plain speech
   omitted it and delivered, channel_publish passed it and was refused.
   ConversationRef.serverId and SpeechRoute.serverId are now optional and
   absent when unknown (homeRoute, candidateForChannel, channel_open's
   route, gate unroutable candidates, the registry view); channel_publish's
   default passes a server only when the route has one, resolving the id
   exactly as routeSpeech does.
3. The mid-turn hold notice always said "addressed you"; it now names the
   actual cause — "X addressed you mid-turn" or "X, where you sent a message
   this turn, continued mid-turn".
4. Turn-start candidates now go through the same conversational predicate as
   mid-turn arrivals: isConversational(tags, metadata) in speech-routes.ts
   (reactions and system markers are machinery), used by
   isConversationalInjection, every request-site candidate (incoming,
   router, push, coalesced) and the gate (PendingEvent.conversational), so a
   reaction that wakes a turn is never a route candidate.

Tests: speech-routes (thread unroutable but competing; unknown server routes
without a placeholder; unresolved records its reason; isConversational);
channel-registry-routing (a route without a server: channel_publish and
routeSpeech deliver alike); present-while-acting (engaged-hold notice names
the continuation, never "addressed"); event-gate (a reaction in the batch is
no candidate); trunk-channel-routing (a reaction wake has no route; a thread
wake holds speech with the thread notice and note, nothing to the channel
root). Focused: 141/141 + trunk 13/13.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…reads become routes on exact channels

The consumer side of the explicit publish-target contract (shelf-355; settled
in room-203 #47417, #47497, #47765; protocol anima-research/mcpl#17, types
anima-research/mcpl-core-ts#9). channels/publish named a channel but not a
place inside it, so a connector with threads chose where a post landed
(slack-mcpl anima-research#6: the newest incoming message's thread, even for a root
reply). Replaces f9f42d5's blanket "threads are never routes" with a
declaration-gated rule.

The rule, on every AF publication path:
- ChannelRegistry.publish names its place: the target's threadId, or the
  channel root (null). It publishes only to a channel whose descriptor
  declares capabilities.publish.target, a thread only on `exact`; otherwise
  it fails before anything is opened or sent (placeRefusalText). An invalid
  thread id fails too. sendChannelsPublish keeps a targeted publish in the
  Request form.
- Echo check (RFC-011 §5): delivered:true counts only when the receipt's
  threadId equals the place asked for; missing or different is `unknown`,
  with a reason naming both. The contradictory-evidence classifier is kept,
  and a refusal's `reason` is carried into the failed outcome.
- PublishDestination gains threadId; receipts, draft attempts and
  channel_publish results name it.

Routes (speech-routes.ts): inferTurnRoute takes the declaration lookup. A
conversation is routable only on a declared channel, a thread only on
`exact`; otherwise unroutable `untargetable` (no declaration) or `thread`
(a thread on a `root` channel: a contradiction, held, never posted to the
root). A fork home follows the same rule. Routes carry their threadId, and
plain speech, channel_publish's default, the sleep announcement and the
receipt follow it. Gate-batched candidates now carry each event's thread
(event-gate PendingEvent/GateRouteCandidate), so a thread and its channel's
root are different conversations on that path too.

Resident controls: channel_publish, drafts.resend and channel_open (with
setSpeechTarget) take an optional threadId; a channel named alone means its
root, never a borrowed thread. channel_open on an undeclared channel opens it
but sets no target, and says why. Notices and refusals name the connector's
own tools only as tools to consult — those it lists that are classed comms
(RFC-008) or named send/reply/post/say, else the prefix its tools share — and
otherwise state only that publication from here is unavailable until the
connector declares a target (Tessa-974 #48922).

Streams (RFC-011 §6): outgoing chunks and completions go only to a declared
channel and carry threadId null; a channel whose route is a thread is not
streamed, since one channel's stream could carry both thread speech and
root envelopes.

Tests: publish-outcome (root sent explicitly, exact threads, undeclared
refused with nothing opened, invalid ids, four bad echoes, root echo,
refusal reason, a withdrawn declaration); speech-routes (exact threads,
root contradiction, undeclared, home, per-channel lookup); trunk routing
(thread into its thread, contradiction held, undeclared held with notice);
event-gate (thread on batched candidates); a new targeted-publish.test.ts
end to end over the fixture, which now implements RFC-011 as a server
(per-channel declarations, honor-or-refuse, echo, wrong/no-echo modes, a
`declare` op, connector tools, DM channels registered with a declaration as
discord-mcpl does). Existing stubs and fixtures declare `root` and echo;
vector 43 now replies into its thread on an `exact` channel. Lazily
registered (host-minted) DM channels are undeclared, and the routing test
says so. Changelog: targeted-publish.breaking.md (adoption: connectors
first; what undeclared channels lose) and targeted-publish.changed.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lish checks selectors before defaulting

Tessa-974's bounded recheck at feb44a4 (room-203 #49361; probe
/tmp/tessa-356-target-receipts-probe.mjs):

1. Plain-speech receipts discarded server identity. recordProseDelivery
   kept channel and thread only, and appendProseDeliveryReceipt looked the
   label up later by bare channel id, so deliveries to alpha/shared/topic and
   beta/shared/topic collapsed into one line under one server's label, and a
   later rename rewrote what the receipt said was used. Each delivery is now
   recorded as resolved at delivery — server, channel, the label it had then,
   thread (ProseDelivery) — deduped on server, channel and thread; a channel
   id that two delivered servers share is shown with its server
   ("#Alpha (alpha/shared, thread topic) · #Beta (beta/shared, …)").
   routeSpeech returns the delivered destination's serverId and label for
   this; the {{unsent}} receipt dedupe uses the same key.
2. channel_publish normalised selectors by truthiness: {channelId:'only',
   serverId:''} posted on the sole registered server although publish()
   itself refuses an empty selector, and an empty channelId fell through to
   the speech route. Supplied channelId, serverId and threadId are now
   checked before any default — empty or non-string is refused with nothing
   sent; a serverId or threadId without its channelId is refused; null still
   means "not in use".

Tests: speech-route-turns (two servers sharing a channel id and thread stay
two destinations with their delivery-time labels after a rename);
channel-registry-routing (five bad selector shapes refused at the tool entry
point with nothing published, null treated as omitted); routeSpeech's
results now include serverId and label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ads pinned to the protocol field; stream rule tested

Review of the 355 part at 8b5658a/b5667c8:

- Tessa-974 (room-203 #49649): the receipt added a channel's server only
  when two servers happened to receive speech in one turn, so a single
  delivery to beta/shared/topic still read "#Beta (shared, thread topic)"
  although another registration owns `shared` (labels can collide too).
  Every channel destination is now written self-contained as
  `#label (server / channel-id, thread T)` — `server / channel` as the source
  headers write it — in the [delivered] receipt and in every draft receipt
  and resend line (placeText, shared by destinationText); the
  collision-counting branch is gone. A destination with no label reads
  `(server / channel-id)`.
- Agnes-984 (#49680) finding 1: whether an adapter's metadata could set a
  gate candidate's thread. It cannot on channels/incoming — the registry
  builds the gate's metadata with the protocol fields (messageId, threadId)
  after the adapter's metadata, always present even when undefined — but
  nothing said or tested so; now a comment does, and a test feeds a root
  message whose adapter metadata carries threadId/messageId, and a thread
  message whose metadata disagrees, and checks the gate sees only the
  protocol values. (On push/event the thread is the origin's threadId,
  which RFC-011 revision 3 binds.)
- Finding 2: targeted-publish.breaking.md called the connector PRs
  releases and named anima-dnd as one; they are open PRs, and anima-dnd's
  awaits its maintainers' choice of route (anima-dnd#1).
- Finding 3: the RFC-011 §6 stream rule had no test. publish-outcome now
  drives the registry over exact/null, exact/t-1, root/null (streamed, with
  that place on chunk and completion) and root/t-1, undeclared (nothing);
  trunk-channel-routing drives a turn on a thread route (no chunk or
  completion at all, the speech still published into the thread) and one on
  the root (chunks and one completion, all threadId null).
- Nit: channel_open with threadId and setSpeechTarget: false now says the
  thread was not used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…on; origin keys can't override host fields

Agnes-984's 355 review finding 1 (room-203 #49680), as Tessa-974 framed it
(#49785): the frozen envelope must outrank free-form metadata on the gate
path, so a gate-batched wake and the direct path agree on where a reply goes
— a thread decides where a post lands (MCPL RFC-011).

- Both lanes now freeze an ordinary item's source envelope at admission
  before the gate reads it (it was frozen just after), and hand the gate the
  envelope under INBOUND_SOURCE_KEY, last. A coalesced item's envelope is
  still frozen by its coalescer at delivery.
- EventGate.handleDebounce takes a channel item's server, registered
  channel, thread and message from that envelope, ahead of metadata; free
  metadata is only the fallback for items without one.
- push/event: the gate's metadata spread the server's origin after the
  host's own fields, so an origin key could stand in for serverId,
  eventType, featureSet or eventId — e.g. an origin.serverId naming another
  server would have made a batched candidate route to that server's channel
  with the same id. The origin now comes first and the host's fields after
  it. (channels/incoming already put the protocol fields after the
  adapter's metadata.)

Tests: event-gate (a push whose metadata and route resolver name a forged
server, thread and message: the candidate is the envelope's); a new
push-gate-metadata test (origin serverId/eventType/featureSet/eventId and an
origin-supplied envelope key are overridden, the origin's RFC-011 thread is
kept, the envelope is frozen once before the gate); inbound-source's
exactly-once acceptance tests still pass with the earlier freeze.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rce header (shelf-356, AF side)

A message read alone could not say where it came from: channel provenance
lived only in metadata, adapters rendered location prose only when the
channel changed, and backscroll items carried nothing (a resident's report:
messages lose channel provenance). Grammar agreed with discord-mcpl in
room-203 #41210:

  [source: <server> / <canonical-channel-id> · <label-at-receipt> · thread <id> · reply to <id>]
  [source: <server> · unscoped]

renderSourceHeader() (mcpl/inbound-source.ts) renders it from a frozen
inbound source envelope; the label, thread and reply tails appear only when
the item has them; a local surface's input gets none. Stamped exactly once,
at ingestion, and stored with the message as its first text block:
- channels/incoming (handleMcplChannelIncoming, before fork/trunk/router
  placement) — creates, coalesced deliveries and RFC-006 corrections all pass
  there with their own frozen envelope;
- push/event — a derived channel (DMs, addressed-while-closed) or
  `unscoped`, never a guessed channel; a silent heartbeat stores nothing and
  gets nothing (its empty-content detection runs first).
Because it is stored, a later rename, a recompile or a replay never rewrites
it, and the second of two consecutive messages names its channel too.
Adapter body text and structured origin fields are unchanged.

channel_open backscroll: each history item gains a `source` field rendered
from the ITEM's channelId (a spliced item wears its true channel); label from
the registry for that id, else the adapter's item channelLabel; an item that
names no channel gets none.

The authority rule (SOURCE_HEADER_RULE: the canonical id is authoritative
when a label differs) is stated in channel_list, channel_open and
channel_publish descriptions and in a one-time `[source]` notice, appended at
a resident's first turn with channel traffic in its recent context and
recorded in framework state (sourceHeadersExplained) so it is said once.

The rest of 356's AF requirement — channel_publish receipts naming serverId,
channelId and channelLabel with honest failed/unknown statuses — landed with
the routes commit (8a1d579).

Tests (source-headers.test.ts, 10, over real MCPL children: a
Discord-shaped server with two guilds sharing a channel name and DMs, a
Zulip-shaped server with topics in a stream): grammar; consecutive and
interleaved messages; same-named channels kept apart by id; thread and reply
tails; DM push, unscoped push, no header on console input; a rename after
ingestion keeps stored headers; headers on the wire and the notice said once
with the rule; tool descriptions state it; backscroll items per own channel
(spliced, unregistered with adapter label); backscroll headers in the
channel_open tool result on the wire; a coalesced correction carries the same
header and reaches the model with it. XML tool mode renders the same tool
result content (membrane's formatting), so it is not separately exercised.

Suite (--test-concurrency=4, Jobs job): 1370/1370 + code-execution 36/36.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fter storage; header beside the digests

Tessa-974's review of the source-header commit (room-203 #46649), composed
with the shared ingestion stamp (6c927de; room-220 #46752–#47586):

1. The stamp's order around the header: metadata.sourceBodyDigest is taken
   from the body as delivered, before the header decorates it; the exact
   header text is recorded as metadata.sourceHeader on both lanes; and
   metadata.storedBodyDigest is taken after, over exactly the stored blocks,
   header included. A rename then changes the header and the stored digest
   but never the delivered body's version (a replay of the same message
   reads as the same body).
2. Header values are rendered literally: every field comes from an adapter,
   and a label holding `]`, a newline and `[source: …` rendered a second
   apparent attribution. A value containing a grammar character (brackets,
   `·`, ` / `, quotes, backslash) or any control character is rendered as a
   JSON string literal; other values as is.
3. stampHistory spread the item after the host header, so an adapter's own
   `source` replaced it. The host header is now written last and
   authoritative; an adapter value is kept as `adapterSource`.
4. maybeExplainSourceHeaders recorded the notice as given before storing it,
   so a failed append suppressed the explanation for good. The notice is
   stored first and only then recorded, in the same store.

Tests (source-headers.test.ts): forged-label and separator escaping; digest
framing equals the legacy unsharded hash and differs from the unframed one;
a replay of the same message and body after a rename stores a new header and
the same sourceBodyDigest, with each header recorded; an unscoped push's
digest is its undecorated body; an adapter `source` in backscroll is kept as
adapterSource while the host header stands; a failed notice append records
nothing and the next attempt gives and records it; the channel_open tool
results AF hands the stream, run through membrane's own
AnthropicXmlFormatter.formatToolResults, keep the header readable inside
<function_results> (the XML tool-result path).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…es them unmatched

With the header stored in the body (shelf-356), the stored copy's digest —
taken at each storage site since 711b400 — must cover it, and the receipts
lane relies on a mismatch to tell an edited copy from an intact one
(room-220 #47210, #47220). Tests in source-headers.test.ts check that each
copy's storedBodyDigest equals the hash of its stored blocks and differs
between an original and its post-rename replay while their sourceBodyDigest
is shared, that an unscoped push's stored digest covers its header, and that
an edit through the context manager keeps the metadata, so the stored hash
no longer matches the edited content.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ntrols visibly

Tessa-974's recheck (room-203 #48327): headerValue selected U+2028/U+2029
for quoting, but JSON.stringify leaves them literal (JSON permits them), and
U+0085 (NEL) too, so a label could still break a header across lines
(probe: shell-output/2987/122.log). Quoting alone was not enough.

A structural value is still rendered as a JSON string literal, and every
character JSON.stringify leaves literal that can break a line or hide — DEL,
the C1 controls (U+0080–U+009F, NEL among them), U+2028 and U+2029 — is now
escaped visibly as \uXXXX. The quoted value still parses back to the label
with JSON.parse.

Test (source-headers.test.ts): a label carrying U+2028, a forged
`[source: x]`, U+2029, U+0085 and DEL renders as one line with the visible
escapes, no line-breaking or invisible control survives, and the quoted
value parses back to the original label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Petra-1012 added 7 commits October 12, 2026 03:01
…kes when its turn ends; a placeless turn's continuation takes no fallback

From Basil-1007's and Audrey-2282's reviews of 6114926 (room-448 #75410,
#75447, #75458).

The fork rule at turn start (Audrey). Each producer checked the fork rule
when it queued a wake (the gate, channels/incoming, a push, a coalesced
batch, continuationRequest), and beginAgentTurn used the candidates as they
were. A wake can wait in the queue while a fork claims a channel, so the
trunk could still take it: Audrey's probe moved the claim after
continuationRequest returned and got a trigger route into the fork's
channel. Now beginAgentTurn filters every wake's candidates with
mayTakeCandidate, as ownership stands when the turn starts, and the
producers no longer filter candidates (they still keep a fork's channel out
of each agent's typing channel, which is shown at once). One place, for
every producer: a claim made, or given up, while a wake waits counts. A
candidate the rule leaves out still named its conversation, so the wake
still takes no fallback, and an unroutable one in a fork's channel holds no
other agent's turn.

A bounce wakes when its turn ends (Basil). bounceProse runs mid-turn, and
queued its continuation then, reading the route record at the bounce. If
the turn then moved its route (a channel_open, which the bounce notice
suggests, or a mid-turn hold), the follow-up named the place the resident
had just left. Now a bounce in a live turn (activeTurnTokens) only marks
proseBounceWakes, and driveStream's finally queues the wake once the
logical turn has ended (frameReachedTerminal and ownsPhysicalStream, beside
settleTurnDrafts), so it reads the record as the turn left it. A bounce
outside a turn (a delivery that fails after it) queues at once, as before.
A true new turn clears a mark no turn end consumed (a superseded turn): the
bounce notice is in that turn's history already.

A placeless turn's continuation takes no fallback (Basil's silent-tick
point, decided here at the routing level). continuationRequest named a
conversation only when the turn's record gave candidates, so a turn whose
words had no place ({ route: null }: a silent tick, a reaction or a
fork-owned channel, a no-name wake with no fallback place) gave a
continuation that took the fallback, speaking where the last message came
in. Now any route record names a conversation; only an agent with none (no
locus-mode turn yet, or an explicit-prose turn) names nothing. Copying a
silent tick's prompt and suppression onto its retry, as a budget restart
does, is left out: that is turn semantics beyond where words go, and main
doesn't do it either.

Not changed: a fallback turn's continuation gets an inferred route to the
same place (Basil's second point); the outcome is right, and a mid-turn
hold's notice then uses the inferred wording.

Tests (trunk-channel-routing): the fork rule judges a continuation at turn
start, both ways (claimed after queueing: no route; given up before the
turn: chanA), and an unroutable candidate in a fork's channel holds nothing;
a placeless turn's continuation gets no place, and an agent with no record
takes the fallback; a bounce during a turn wakes at its end and speaks where
the turn's route moved (a test module bounces and moves the route
mid-turn); a mark a superseded turn left starts no extra wake.
batched-wake-locus: the gate, push, coalesced and channels/incoming
producers carry their candidates (typing still follows the fork rule).

Changelog: the fork rule's timing, the bounce's wake, and a placeless
turn's continuation.
…h, a silent tick's retry stays silent, and an unresolved conversation is checked again

From the haiku review of 6114926 (sw-af256f, wf-428; all 17 verdicts
recorded with review_verdicts). Its blockers, I1 (a silent turn's
continuation took the fallback) and I2 (so did any { route: null } turn's),
and I13 (the fork rule only at queue time) were fixed by 698c31a; this
round adds what the sheet found beyond them.

A fallback turn's continuation (I3, and Basil-1007's notice-wording point,
room-448 #75410). continuationCandidates named a fallback route's place as
an addressed candidate, so the continuation was inferred as a 'trigger'
route there: it beat an ambient arrival, held beside an addressed one, and
a mid-turn hold used the inferred wording. A fallback turn's wake named no
conversation, so now its continuation names none either: the route gives
no candidates, and continuationNames (speech-routes) is false for a record
whose route is a fallback and that isn't held. The new turn takes the
fallback afresh, as the turn did, and an addressed arrival batched beside it
takes the turn. Every other route record still counts as named, so a turn
whose words had no place gives its continuation no place either.

A silent tick's continuation is a silent tick (I1's remainder).
continuationRequest copies the running turn's suppressProse, ephemeral
prompt and silentHeartbeat stamp (activeTurnTriggers), as a context-budget
restart does, so a rewind retry of a refused silent tick runs as the tick:
told it's private, its rows stamped, its prose not routed. A continuation
queued when no turn runs (an unstick, which needs an idle agent) carries
nothing; a silent tick's record still gives it no place.

An unresolved conversation is checked again (I14). A continuation
candidate kept the old record's unroutable flag, so an 'unresolved' place
stayed unresolved; now it carries none, and routeRefusal checks it at the
turn's start like any candidate.

The tune-out hook is queueTuneOutWake.bind(framework) (I6), so there is no
forwarding left to drop the channel.

Text: the coalesced batch's comment says the merged thread or the
origin's, either (I9); getDefaultPublishChannel says withdrawals are left
out by noteDelivered's callers (I11); continuationRequest's doc says a
budget restart and a guard retry continue the same logical turn and aren't
built there (I12); the changelog's continuation bullet names its producers
plainly, says a channel_open sets a route for its own turn only (I7), and
says what a fallback turn's and a silent tick's continuations do (I17).

Tests: speech-routes: a fallback turn's continuation names none (held, it
names what it held between); an unresolved conversation routes once it
resolves; continuationNames for a placeless record and for none.
trunk-channel-routing: the placeless continuation follows a real
reaction-woken turn (I2); a fallback turn's continuation takes the
fallback afresh and yields to an addressed arrival (I3); a refused silent
tick's retry carries the tick's prompt and stamp, gets no place and
publishes nothing (I1); an unstick names its agent's last conversation,
through handleHostCommand (I5); the superseded-bounce test gives each
stream its own response, so an extra wake fails it at once instead of
timing out. inference-exhaustion-rewind's harness gets activeTurnTriggers,
which continuationRequest now reads, in place of conversationAgentHomes,
which it no longer does.

Not changed: I4 (intended: the continuation re-answers its turn's
message, as the mid-turn rule keeps a same-conversation arrival from moving
the reply edge), I7 (intended), I8 (not real: J3737's mutant removing
!occ.retract failed mcpl-coalescing), I10 (not worth it), I15 and I16
(intended). Not driven to their route (I5): tool_results_ready, whose only
source has no caller in src; the bare provider-acceleration-retry; and
prose-continuation, an explicit or hybrid-mode wake.
…n starts, and a bounce wakes at once

From a review of 6114926..5ba5d8c, and from Basil-1007's and
Audrey-2282's reads of the redesign (room-448).

A framework wake that continues a turn read that turn's record when the
wake was queued. To read a bounce's route late enough, the bounce's wake
was then deferred to the end of the turn that bounced. Several teardown
paths never reach that end: a terminal start failure, a withdrawn turn,
an abandoned or superseded stream, the puppet and store-surgery holds,
and a budget restart whose successor fails. On those paths the wake was
lost, and the next true new turn cleared the mark. The silent tick's
prompt and stamp came from the running turn's trigger, which some paths
had already torn down when they built the continuation, so a silent
tick's retry could run as an ordinary turn.

Now each producer queues a request flagged continuesTurn and nothing
else. processInferenceRequests fills it in from the agent's turn record
when its batch starts, before the batch's fields are merged
(continuationFor). That record belongs to the turn the request
continues: a resident's true new turn starts only there, an agent's
requests wait while a turn is alive, and the next turn takes every
request the agent has pending. A wake queued during a turn therefore
names where that turn's words went by its end, after a channel_open or a
hold. A bounce queues its wake at once, as on main, and the
proseBounceWakes mark and its three sites are gone. A bounce that lands
while a successor turn is alive runs after it and names the successor's
conversations, which is where the resident last spoke.

The turn record keeps what a continuation needs after the teardown:
- whether the turn's wake named a conversation (TurnRoute.named). A
  continuation names one exactly when the turn it continues did, or when
  it carries that turn's conversations. A no-name turn that found no
  fallback place gives a continuation that names none, and that
  continuation takes the fallback as it stands when it starts.
- for a fallback turn that wasn't held, its place. The continuation keeps
  that place as its own fallback (fallbackPlace) instead of reading the
  last inbound again, so a message arriving elsewhere meanwhile doesn't
  move the rest of the reply. It still names no conversation, so anything
  its batch names outranks it, and the fork rule applies to it at turn
  start like any fallback.
- for a silent wake, its prompt and stamp (TurnRecord.silent). A
  continuation of a silent tick is a silent tick too, whatever queues it.

The fork rule now reaches every route source. A channel_open into a
channel a conversation fork owns opens it but sets no route, and its
result says why. A mid-turn hold no longer counts an arrival in a fork's
channel as a competing conversation.

A wake's candidate whose channel had no server when it was queued is
resolved again when its turn starts, so a server that registered while
the wake waited makes it routable. A gate candidate that names its
server is a raw id the host couldn't resolve, and stays unroutable.

An unstick is refused while a turn is alive (activeTurnTokens), not only
while the agent's status is busy, since the status reads idle at a
turn's start and after endTurn's reset.

beginAgentTurn's arm that gave a suppressProse trigger { route: null }
in the route block could never run, because such a trigger takes the
disabled branch. The block after the route block writes the silent
record, so the arm is gone.

continuationNames is replaced by continuationOf, which returns the
candidates, whether the continuation names a conversation, and the kept
fallback.

Changelog: the continuation bullet says when the record is read, what a
held fallback turn's and an unroutable turn's continuations do, and that
a channel_open moves the route for the rest of its turn. The fork bullet
covers channel_open and the mid-turn hold, and the unresolved bullet
covers the server found at turn start.

Tests:
- speech-routes: continuationOf for a fallback turn (its place kept,
  outranked by a named candidate, none when held), for a placeless turn
  whose wake named a conversation, and for one whose wake named none.
- trunk-channel-routing:
  - a fallback turn's continuation keeps its place after the last
    inbound moves, and a fork that claims that place meanwhile keeps the
    continuation out;
  - a no-name turn with no fallback place gives a continuation that takes
    the fallback once there is one;
  - a bounce in a turn queues its wake at once and the wake names where
    the turn's route moved;
  - a bounce outside a turn wakes too;
  - a silent tick's start that fails at the failure threshold gives a
    rewind retry that is a silent tick, with the tick's prompt and stamp
    and no draft left to resend (Audrey-2282's fault injection, made a
    test);
  - the silent refusal retry's record shows a silent tick;
  - an unstick is refused while a turn is alive;
  - a candidate's server that registered while its wake waited is found,
    and a gate's unroutable candidate that names its server stays
    unroutable;
  - the existing continuation tests read what a request carries through
    continuationFor.
- speech-route-turns: a channel_open into a fork's channel sets no route.
- present-while-acting: an addressed mid-turn arrival in a fork's channel
  holds nothing.
- inference-exhaustion-rewind: the retry is a continuesTurn request that
  names the failed turn's conversation when its batch starts.
- mcpl-coalescing and trunk-channel-routing: a record whose wake named a
  conversation now carries named: true.
…nstick and a bounce's resend are ordinary, and a continuation's conversation yields the reply edge

From an automated review of 5ba5d8c..45cfe46 by Claude Haiku agents,
with Basil-1007's and Audrey-2282's reads (room-448).

Silence. The record keeps a silent wake's silence, because the turn's
own continuations can be queued in its teardown, after its trigger is
gone. Between their queueing and their batch no other turn of the agent
runs, so the record they read is theirs. Two kinds of wake follow a turn
without being part of it: an operator's unstick, and a bounce's resend,
which can land after a later turn has begun. On main both are always
ordinary turns. They now take the record's conversations but never its
silence (ORDINARY_CONTINUATIONS), where 45cfe46 made an unstick after a
silent tick, or a bounce after a silent successor, a silent tick.

The silent record is now written only when the turn begins. A
context-budget restart or a guard retry keeps it, `named` included;
before, the restart rewrote it from a trigger whose namesConversation
the batch merge always clears. A restart batched with an ordinary wake
runs as an ordinary turn, by the merge's silentOnly rule, and the record
then drops `silent`, so its continuations are ordinary too. Its route
stays the tick's null, as any restart keeps its route, so the reply to
the arrival is held as drafts with the usual notice.

channel_open's silent check now reads the record rather than the running
turn's trigger, so a result that lands after a silent turn's teardown
still sets no route. Its write is { route }: the `named` it carried was
never read, since an opened route is a candidate of its own. The
refusal's text for a fork agent's own channel_open is gone; such a call
is refused before this point.

Conversations.
- continuationCandidates keeps an unroutable conversation's flag when
  its channel had no server, so beginAgentTurn resolves it again when
  the continuation starts. Before, the continuation took the channel
  without a server, or stayed unroutable.
- A continuation's candidates are stamped at time 0, older than anything
  batched with them. Anything else in the batch arrived after the turn
  began, so a newer message in the same conversation gives the reply
  edge, where the continuation's old reply edge used to win.
- A continued fallback whose kept place the fork rule or its channel's
  publish target refuses now marks its record `named`, so the
  continuations after it take no place either, rather than the current
  last inbound.
- processInferenceRequests fills continuations before the inference
  policy sees the batch, so a policy reads their fields. A batch the
  policy drops was only filled.

Text. The changelog's continuation bullet now says that a silent tick's
own continuations, such as its retries, stay silent, that a restart
batched with an ordinary wake is the one exception, and that an unstick
and a bounce's resend are repairs, always ordinary. It also says that a
bounce landing
during a later turn names that turn's conversations, that a kept place
yields to a wake batched with it and protects only against a message
that wakes nothing, that naming follows inferred prose routing, that a
held turn's continuation names the conversations its hold named, and
the silence rules above. The held-fallback notice says the speech was
going where the last message had come in when the reply began, which is
true of a kept place too.

Tests:
- speech-routes: a serverless unroutable conversation stays flagged in
  its continuation.
- trunk-channel-routing:
  - a continuation of a turn whose channel had no server takes the route
    with its server once it registers;
  - a continuation's conversation yields the reply edge to a newer
    message batched with it;
  - a silent tick's restart keeps its record whole, and a restart that
    runs ordinary drops `silent` and keeps its route and naming;
  - an unstick and a bounce's resend after a silent tick are ordinary,
    while the tick's own retry stays silent;
  - a kept place refused by the fork rule gives no place to that
    continuation or the next, with the last inbound moved elsewhere;
  - a kept place whose channel stops declaring a target gives no place.
- speech-route-turns: a channel_open whose result lands after a silent
  tick has ended sets no route and keeps the silent record.
- present-while-acting: the fork-guard hold test also checks that the
  addressed message was injected.
… marked in its turn's record and continuations

From Audrey-2282's probe of 501fa63 (room-448).

A producer marks a route candidate unroutable when it can never be the
turn's route: a raw id the gate couldn't resolve to a registered
channel, or a channel with no server. inferTurnRoute recorded such a
conversation as `unresolved`, and a hold recorded only conversation
references, so the mark was lost. A continuation of the turn then named
the conversation unmarked, and the route was decided by checking the
registry again. Audrey's probe used a registry that declares every id: a
gate's `discord / user:U5` held the first turn, and its continuation
published to `user:U5`. 501fa63 kept the mark only for a channel with no
server.

The record now keeps the mark: `unroutable.neverRoutes` for the one
conversation, and `hold.neverRoutes` (conversation keys) for those a
turn-start hold names. A mid-turn hold needs none: its arrivals'
candidates come from inbound sources, which always name a registered
channel and its server. continuationCandidates marks those conversations
unroutable again. So only finding a serverless
channel's server, when the continuation starts, can make one a route; a
raw id named with its server stays unroutable. A conversation refused
for its publish target carries no mark, and is checked again as before.

The changelog's continuation bullet now says which unroutable
conversations are checked again and which stay unroutable.

Tests:
- speech-routes: inferTurnRoute marks a chosen unroutable candidate, a
  hold lists its marked conversations, and continuations keep the marks
  for a raw id with its server and for a serverless channel.
- trunk-channel-routing: a gate's raw id held the first turn, and its
  retry stays unroutable with nothing published; a hold between a raw id
  and chanB, with chanB then claimed by a fork, gives a continuation
  that stays unroutable with nothing published.
…ary takes that wake's route, and a continuation keeps its reply message's time

From the haiku review of 45cfe46..fabdc81 (sw-af256i).

A context-budget restart of a silent tick, batched with an ordinary wake,
runs as an ordinary turn (processInferenceRequests' silentOnly rule). It
kept the tick's route, which a silent tick doesn't have, so the reply to
the batched wake was held as drafts with a no-destination notice, and
its continuations, reading a record with no route and no naming, took
the fallback. The tick had no route to keep, so the restart now decides
its route from the batch's wakes, as a new turn does: the route
decision is one method (decideTurnRoute), used at a true new turn and
here. processInferenceRequests passes a restart batch's route
candidates, naming and kept fallback place; a restart reads them only
in this case. In explicit or disabled prose routing the record is
dropped, as at a new turn.

A continuation's candidate for its turn's route was stamped at time 0,
so any message batched in the same conversation took the reply edge,
including an addressed one older than the message the turn answered
(one the gate held back while the agent was busy). TurnRoute now keeps
`replyAt`, when the reply edge's message arrived, set where
inferTurnRoute sets replyTo, and continuationCandidates stamps the
route's candidate with it. A newer message takes the reply edge; an
older one doesn't. A route with no reply edge (channel_open's) still
counts as older than anything batched with it.

Text:
- The changelog's continuation bullet: an addressed message in the same
  conversation takes the reply edge when it is newer than the one the
  turn answered; a batched wake that names a conversation takes a
  fallback continuation's turn; a silent tick's continuations turn
  ordinary in any batch holding an ordinary wake, and a restart in such
  a batch decides its route from the batch's wakes; a marked channel
  with no server routes once its server is found, and a raw id named
  with its server never does. The bullet is rewrapped.
- TurnRoute.unroutable's neverRoutes doc says the same of marks, and
  continuationCandidates' doc separates refused conversations (checked
  again) from marked ones.
- channel_open's result in a silent wake says "This call came from a
  silent wake, so its plain speech stays private", true after the tick
  has ended too.
- The mid-turn notice for a held fallback route says the place is where
  the last message had come in when the reply began, and that a
  continuation of it keeps that place.

Tests:
- speech-routes: the route's reply time, a newer and an older batched
  message, a channel_open route's continuation at 0, and a 'thread'
  refusal checked again.
- trunk-channel-routing: a silent tick's record kept through a restart
  and a guard retry alone; a restart batched with an addressed wake runs
  ordinary, routes to that wake's conversation with its reply edge, and
  publishes there; the inference policy sees a continuation already
  filled; the gate raw-id, hold and fork-refusal tests assert that their
  continuations ran and wrote their own records.
- speech-route-turns: a channel_open whose result lands after a silent
  tick ended delivers the silent-wake text.
…back rounds

Brings in anima-research#256's ten fallback rounds (2690edb..0bae78b): a resident's
turn woken only by events that name no conversation keeps the fallback
route; a wake that continues a turn names that turn's conversations,
read from the turn's record when the wake's batch starts; a silent
tick's continuations stay silent unless an ordinary wake shares their
batch; a conversation from a candidate marked unroutable stays marked;
and a continuation keeps its reply message's time. The merge has no
conflicts; nothing in anima-research#257's own commits touches the route decision.
Comment thread src/framework.ts
Comment on lines +8361 to +8362
threadId: origin.threadId,
messageId: origin.messageId,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Corrections lose their thread

A channel-scoped push correction can omit origin.threadId and inherit the subject's earlier thread during delivery. The new frozen source does not inherit it. In the existing G11 case, the stored message still belongs to thread, but its header and route candidate name the channel root. A reply can therefore publish outside the original thread.

Freeze the source with the same inherited identity used by deliverCoalesced.

Knowledge Base Used: MCPL conversation and inference routing

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 8361-8362

Comment:
**Corrections lose their thread**

A channel-scoped push correction can omit `origin.threadId` and inherit the subject's earlier thread during delivery. The new frozen source does not inherit it. In the existing G11 case, the stored message still belongs to `thread`, but its header and route candidate name the channel root. A reply can therefore publish outside the original thread.

Freeze the source with the same inherited identity used by `deliverCoalesced`.

**Knowledge Base Used:** [MCPL conversation and inference routing](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/mcpl-conversation-and-inference.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread src/framework.ts
const cand = resolved.candidates?.length ? ` It could mean: ${resolved.candidates.join(', ')}.` : '';
return refuse(`Destination "${spec}" did not resolve: ${resolved.error}.${cand} Nothing was sent.`);
}
const target = registry.resolveDestination({ channelId: resolved.channelId });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Draft resends lose their server

If two servers register the same channel id with different labels, a unique label resolves to the right server. This second lookup drops resolved.serverId and rejects the bare id as ambiguous. The resident cannot resend the draft through this tool even though its destination is known.

Pass resolved.serverId into resolveDestination.

Suggested change
const target = registry.resolveDestination({ channelId: resolved.channelId });
const target = registry.resolveDestination({ serverId: resolved.serverId, channelId: resolved.channelId });

Knowledge Base Used: MCPL conversation and inference routing

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 9870

Comment:
**Draft resends lose their server**

If two servers register the same channel id with different labels, a unique label resolves to the right server. This second lookup drops `resolved.serverId` and rejects the bare id as ambiguous. The resident cannot resend the draft through this tool even though its destination is known.

Pass `resolved.serverId` into `resolveDestination`.

```suggestion
    const target = registry.resolveDestination({ serverId: resolved.serverId, channelId: resolved.channelId });
```

**Knowledge Base Used:** [MCPL conversation and inference routing](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/mcpl-conversation-and-inference.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread src/framework.ts
// endTurn) could otherwise publish late into this one: its words
// overtaken by this turn's, its `>>`/`>>>` choices and deliveries
// written into the state cleared for this turn below.
if (attempt === 0 && !continuingTurn) await this.speechSettled(agent.name);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Speech blocks every agent

After an abort, the old stream can end while its publish is still pending. The next wake reaches this new wait through the awaited scheduler path. While it waits for that agent's speech, the framework stops taking queued messages and tool results for every agent.

An unanswered channel open with requestTimeoutMs: 0 can also leave stop() waiting forever before it closes the connection. Park this agent's wake until its speech settles instead of awaiting network work inside the scheduler.

Knowledge Base Used: Agent runtime orchestration

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 11566

Comment:
**Speech blocks every agent**

After an abort, the old stream can end while its publish is still pending. The next wake reaches this new wait through the awaited scheduler path. While it waits for that agent's speech, the framework stops taking queued messages and tool results for every agent.

An unanswered channel open with `requestTimeoutMs: 0` can also leave `stop()` waiting forever before it closes the connection. Park this agent's wake until its speech settles instead of awaiting network work inside the scheduler.

**Knowledge Base Used:** [Agent runtime orchestration](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/agent-runtime.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +468 to +469
const holder = texts.find((t) => at >= t.start && at < t.start + t.text.length)!;
marks.set(holder.index, [...(marks.get(holder.index) ?? []), at - holder.start]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Marking freezes the host

markHeaderOpenings copies the whole offsets array for every opening in a block. The work grows quadratically. On Node 24.21, the current helper took 5,691 ms for one 320,000-byte block containing 40,000 openings—well below the coalesced lane's 1 MiB limit.

Both MCPL lanes run this synchronously before storage, so one accepted message can stall the host. Append offsets in place and walk text blocks without restarting texts.find for every match.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/mcpl/inbound-source.ts
Line: 468-469

Comment:
**Marking freezes the host**

`markHeaderOpenings` copies the whole offsets array for every opening in a block. The work grows quadratically. On Node 24.21, the current helper took 5,691 ms for one 320,000-byte block containing 40,000 openings—well below the coalesced lane's 1 MiB limit.

Both MCPL lanes run this synchronously before storage, so one accepted message can stall the host. Append offsets in place and walk text blocks without restarting `texts.find` for every match.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread src/framework.ts
@@ -9565,8 +12053,8 @@ export class AgentFramework {
? null
: turnProseRouting === 'explicit'
? trigger?.channelId ?? null
: resolveTurnLocus();
if (typingChannel) this.channelRegistry!.startTyping(typingChannel);
: this.routeChannel(agent.name);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Stream events carry wrong fields

On channel-bearing locus and hybrid turns, typingChannel is now an object. It is still sent directly as channelId on inference:tokens and inference:content_block, whose public types require a string. Subscribers receive { serverId, channelId } instead of a channel id, breaking channel matching and string operations.

Keep the typing target separate from the string sent in traces.

Knowledge Base Used: Agent runtime orchestration

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 12056

Comment:
**Stream events carry wrong fields**

On channel-bearing locus and hybrid turns, `typingChannel` is now an object. It is still sent directly as `channelId` on `inference:tokens` and `inference:content_block`, whose public types require a string. Subscribers receive `{ serverId, channelId }` instead of a channel id, breaking channel matching and string operations.

Keep the typing target separate from the string sent in traces.

**Knowledge Base Used:** [Agent runtime orchestration](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/agent-runtime.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread src/framework.ts
Comment on lines +18035 to +18036
this.chainSpeech(agentName, '[sleep] announcement', async () => {
const pin = agent.proseRouting === 'hybrid' ? this.proseTargetPins.get(agentName) : undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Sleep uses the old destination

driveStream dispatches calls before queuing the prose written before them. The new sleep announcement therefore enters the speech chain first. If an earlier round pinned >>>#alpha and this round writes >>>#beta ... before calling sleep, the announcement reads the old alpha pin and publishes there before beta's words.

Queue the round's prose before dispatching the announcement so it uses the resident's latest choice.

Knowledge Base Used: Streaming prose routing

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 18035-18036

Comment:
**Sleep uses the old destination**

`driveStream` dispatches calls before queuing the prose written before them. The new sleep announcement therefore enters the speech chain first. If an earlier round pinned `>>>#alpha` and this round writes `>>>#beta ...` before calling `sleep`, the announcement reads the old alpha pin and publishes there before beta's words.

Queue the round's prose before dispatching the announcement so it uses the resident's latest choice.

**Knowledge Base Used:** [Streaming prose routing](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/streaming-prose-routing.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread src/framework.ts
Comment on lines +10215 to +10217
const meta = message.metadata as { sourceHeader?: unknown; kind?: unknown } | undefined;
if (typeof meta?.sourceHeader === 'string') sees = true;
if (meta?.kind === 'source-header-notice') given = true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Sender metadata skips the explanation

An adapter message with kind: 'source-header-notice' in its metadata or origin counts as proof that the host already gave the explanation. Both MCPL lanes preserve kind, so this check skips the real notice and records it as given. The resident can then miss the explanation of host headers and sender text.

Also require the host's system: true marker; ingestion already removes an adapter-supplied system.

Suggested change
const meta = message.metadata as { sourceHeader?: unknown; kind?: unknown } | undefined;
if (typeof meta?.sourceHeader === 'string') sees = true;
if (meta?.kind === 'source-header-notice') given = true;
const meta = message.metadata as { sourceHeader?: unknown; kind?: unknown; system?: unknown } | undefined;
if (typeof meta?.sourceHeader === 'string') sees = true;
if (meta?.system === true && meta.kind === 'source-header-notice') given = true;
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 10215-10217

Comment:
**Sender metadata skips the explanation**

An adapter message with `kind: 'source-header-notice'` in its metadata or origin counts as proof that the host already gave the explanation. Both MCPL lanes preserve `kind`, so this check skips the real notice and records it as given. The resident can then miss the explanation of host headers and sender text.

Also require the host's `system: true` marker; ingestion already removes an adapter-supplied `system`.

```suggestion
        const meta = message.metadata as { sourceHeader?: unknown; kind?: unknown; system?: unknown } | undefined;
        if (typeof meta?.sourceHeader === 'string') sees = true;
        if (meta?.system === true && meta.kind === 'source-header-notice') given = true;
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread src/framework.ts
: draftId
? `[discord-send-failed] Your previous reply (${textLen} chars) could not be delivered to ${where} (${reason}). ` +
`Nothing was posted; it is held as draft ${draftId}. drafts(action: "resend", draftIds: ["${draftId}"]` +
(channelId ? `, destination: "${channelId}"${threadId ? `, threadId: "${threadId}"` : ''}` : '') +

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Resend examples break on quotes

The resend example puts channelId and threadId between raw quotes instead of rendering JSON string literals. A valid thread id such as release "v2" makes the suggested call malformed. Backslashes and internal newlines also change what it says. The earlier destination description is escaped, but this second copy is not.

Use quoted() for both arguments so residents can follow the example.

Suggested change
(channelId ? `, destination: "${channelId}"${threadId ? `, threadId: "${threadId}"` : ''}` : '') +
(channelId ? `, destination: ${quoted(channelId)}${threadId ? `, threadId: ${quoted(threadId)}` : ''}` : '') +
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 15775

Comment:
**Resend examples break on quotes**

The resend example puts `channelId` and `threadId` between raw quotes instead of rendering JSON string literals. A valid thread id such as `release "v2"` makes the suggested call malformed. Backslashes and internal newlines also change what it says. The earlier destination description is escaped, but this second copy is not.

Use `quoted()` for both arguments so residents can follow the example.

```suggestion
                      (channelId ? `, destination: ${quoted(channelId)}${threadId ? `, threadId: ${quoted(threadId)}` : ''}` : '') +
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread src/gate/event-gate.ts
Comment on lines +1825 to +1828
const key = JSON.stringify(surface ? ['surface', event.serverId ?? null] : [event.serverId ?? null, channel, event.threadId ?? null]);
const kept = { ...event, content: '' };
const seen = this.evictedRouteEvents.get(key);
this.evictedRouteEvents.set(key, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Evicted conversations keep accumulating

During a long inference or pause, each debounced batch can move evicted events into a new server/channel/thread key. This map has no cap and keeps growing until inference ends. Clearing message content does not bound the retained ids, labels, and route candidates, so the 100-event cap no longer bounds retained metadata.

Cap this state too. When details exceed the cap, keep a conservative “multiple conversations” result.

Knowledge Base Used: Event gate pipeline

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gate/event-gate.ts
Line: 1825-1828

Comment:
**Evicted conversations keep accumulating**

During a long inference or pause, each debounced batch can move evicted events into a new server/channel/thread key. This map has no cap and keeps growing until inference ends. Clearing message content does not bound the retained ids, labels, and route candidates, so the 100-event cap no longer bounds retained metadata.

Cap this state too. When details exceed the cap, keep a conservative “multiple conversations” result.

**Knowledge Base Used:** [Event gate pipeline](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/event-gates.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

This was referenced Oct 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

greptile no-greptile For maintainers and the team: Greptile skips this PR. Not for general use.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants