Skip to content

feat(routing): speech follows its conversation, and publishes only where MCPL RFC-011 declares the place - #256

Open
nissa-seru wants to merge 75 commits into
anima-research:mainfrom
nissa-seru:feat/speech-routes
Open

nissa-seru wants to merge 75 commits into
anima-research:mainfrom
nissa-seru:feat/speech-routes

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

This PR gives each turn a speech route. An agent's unaddressed plain speech then goes to the conversation the turn is answering, instead of the last channel any message arrived in, and the framework publishes only into a channel whose connector declares where a post lands (MCPL RFC-011). A turn whose wake names no conversation, such as a heartbeat, still speaks where the last message came in, as on main. This update adds 45cfe469, 501fa632, fabdc81c and 0bae78b1. A wake that continues a turn now reads that turn's conversations when its own turn starts, so a bounce's follow-up and a retry go where the turn's words went. A silent tick's retry stays silent, and an unstick or a bounce's resend is an ordinary turn, as on main.

Stacked on #252, which is stacked on #247. This branch contains their commits until they merge. Please review only this PR's commits, and merge after #252. They are:

  • the six reviewed at 42c3f5a, then five answering Greptile's first review (56827f5..ae42e95);
  • the integration merge with main, c899120, and its comment fix 2287051 (see "Integration with main");
  • the shutdown fix 4180ccd;
  • the follow-up to slimepriestess's approving review: 1bbf3f7 (the Breaking note on scheduled wakes, and comments that still described the removed fallback) and 2c83659 (a paragraph break between messages in an outgoing stream);
  • the integration merge 8f29e62, which brings in feat(mcpl): stamp each inbound item's delivered-body and stored-copy digests #252's later head (see "Integration with main");
  • e041853, which fixes what a review run over this PR's own commits found, and 4e19f02, which fixes one more finding of that run (see "A review run's findings");
  • the merges f288525 and a8e3470, which bring in feat(mcpl): stamp each inbound item's delivered-body and stored-copy digests #252's later head (with feat(drafts): hold suppressed plain speech as resendable drafts (with structured publish outcomes) #247's), and the fixes from a second review run over this PR's own commits, f9f54bf, fdca2b9, 535d44f, 5065142, 5036af4, a84c6e5, 11236a5, fd8cfa4 and 6aaeb85 (see "A second review run's findings");
  • 9dc8239, which gives defaultOutgoing MCPL RFC-011 revision 5's meaning (also in "A second review run's findings");
  • the fallback rounds, 2690edb, f1dde7f, 3f09c91, 6114926, 698c31a, 5ba5d8c, 45cfe469, 501fa632, fabdc81c and 0bae78b1 (see "Scheduled wakes keep the fallback").

Problem

  • Plain speech followed the last inbound channel. An agent's unaddressed plain speech (prose not sent with a tool) was published to the process-wide most recent inbound channel. Mid-turn it re-pinned to whoever addressed the agent last, and traffic the agent never answered could redirect it. agent-framework fix(routing): a turn from a non-channel surface pins no inferred locus #190, by rufalupa666-netizen with the resident qa-engineer, found and diagnosed one consequence: a console reply published to the channel that last spoke. A surface route covers it.
  • A publish named a channel but not a place in it. A connector with threads chose where each post landed. Slack posted into the newest incoming message's thread, even when the reply was meant for the root.

Changes

Speech routes

  • One route per turn. Each logical turn has a speech route: a server, a channel, an optional thread and the message being answered, or a local surface. It's decided once at a true new turn, from the conversations that woke it:

    • a conversation fork's home wins;
    • otherwise the addressed conversations, if any, else all conversational ones;
    • one conversation gives a route;
    • several hold the turn and name each competing conversation;
    • none gives no route, unless the wake named no conversation at all, which takes the fallback (see "Scheduled wakes keep the fallback").

    Context-budget restarts, guard retries and a stream's own retries continue the same turn. They keep the route, and everything else the turn has chosen or done: its engagement, its >> or >>> choice, and the deliveries and drafts its receipt reports. Only a true new turn clears them, and a queued wake that continues a turn starts one. That wake names the conversations the continued turn answered, so its words go where the turn's had gone by its end (see "Scheduled wakes keep the fallback"). The decision lives in the pure module src/speech-routes.ts.

  • Candidates come from the frozen source envelope on every lane: channels/incoming, push events naming a conversation, coalesced wakes, local surfaces, and gate-batched wakes. On the gate path the envelope outranks free-form metadata. A coalesced item is gated with the same host envelope its coalescer then freezes. The push lane's origin can no longer stand in for the host's own fields, such as serverId, nor can any connector-supplied inboundSource key stand in for the envelope.

  • Mid-turn arrivals. One that addressed the agent, or that came from a conversation the agent engaged this turn, holds an inferred route for the rest of the turn, with a notice. Deliberate routes are never held: a fork's home, a channel_open, or a hybrid >>> target. Ambient chatter in a conversation the agent hasn't sent into this turn, reactions, and same-conversation arrivals change nothing.

    • Engagement is as exact as the host's evidence. A resend or channel_publish, delivered or unknown, engages exactly the conversation it named: server, channel and thread. A connector's own send tool chooses its place in the channel itself, so it engages the whole channel, and the notice says that where in the channel isn't known.
    • The tradeoff. After a connector send, ambient chatter in a sibling thread of that channel also holds unaddressed speech for the rest of that turn. The alternative is worse. slack-mcpl's reply_message reports its thread only in a connector-specific field, and tags non-mention follow-ups in that thread chat:ambient. Without the hold, a reply meant for that thread could be published to the route elsewhere.
    • >>/>>> envelopes record no engagement. Only the hold reads it, and an envelope takes the inferred route out of use for the rest of the turn.
  • Speech goes where the route stood when its words were written. A hold or a channel_open that lands while earlier words still wait to be published governs only words written after it. That holds even when the text-only or trailing reply is published after the agent is idle. A hybrid >>> choice is read and changed only on the agent's ordered speech chain, so a later channel_open supersedes a pin, or a skip_reply suppression, in the order the resident chose. A channel_open whose result arrives after a newer turn has begun changes nothing in that turn.

  • What the agent controls.

    • channel_open (with setSpeechTarget, default true) sets a deliberate route;
    • channel_publish, drafts.resend and channel_open take an optional threadId;
    • a channel named alone means its root, never a thread borrowed from an earlier route.
  • Undelivered speech isn't lost. Speech with no route, or on a held turn, is kept as a draft that names why: no-destination or ambiguous.

Targeted publish (MCPL RFC-011)

  • Every publish names its place. That place is a thread, or the channel root as null.
  • Publishing needs a declaration. AF publishes only to a channel whose descriptor declares capabilities.publish.target, and into a thread only on an exact channel. Anything else is refused before anything is opened or sent. A targeted publish is always a Request.
  • The echo must match. A delivery counts as confirmed only when the connector's echo equals the place asked for. A missing or different echo is unknown. A refusal's reason is kept.
  • Routes follow the declaration.
    • A conversation is routable only on a declared channel, and a thread only on exact.
    • A thread on a root channel is a contradiction. Its speech is held, never posted to the root.
    • An undeclared channel's notice names the connector's own tools only as tools to consult.
  • Outgoing streams carry only published speech (MCPL §14.3). A chunk goes out after a confirmed publish at a channel's root, on the server and channel the publish resolved, with threadId: null. A thread placement isn't streamed, so a channel's stream names one place.
    • Why not while generating. A later call in the same round can make words already generated private or held: a same-round private think, a skip_reply, or an explicit send's silence. So held, private, failed and unconfirmed words now never stream, as §14.3 requires.
    • Completion. Each physical stream completes, after its last speech, exactly what it streamed.
    • Messages stay apart. Each publish is its own message, so within one stream a later publish on a channel that has already streamed opens its delta with a paragraph break (\n\n). The deltas joined, which is what discord-mcpl's voice output speaks, and the completion read "Let me look.\n\nFound it.", not "Let me look.Found it.". The published messages are unchanged.
    • The cost is latency. discord-mcpl's voice output, the one connector that declares channels.streaming, now speaks each round once it is posted, rather than while it is generated. Speech still lands between tool rounds.
    • The speculative ProseStreamRouter is removed. It was internal and not exported.
  • Receipts name every destination as it was resolved at delivery: #label (server / channel-id, thread T).
  • channel_publish checks its selectors before applying any default. An empty or non-string channelId, serverId or threadId is refused, with nothing sent. channel_open checks the same way: an invalid threadId, serverId or non-boolean setSpeechTarget is refused with nothing opened. So does a conversation fork's channelId.
  • Publishing rechecks after opening. A publish that first has to open a closed channel checks its destination again before sending. If the channel was removed, or its declaration withdrawn, while the open was pending, nothing is sent.

Shutdown (a race older than this PR)

  • Work still in flight when the framework stops no longer ends in an unhandled rejection. stop() closes the process queue before it closes MCPL connections, and main has the same order. This PR's aborted-stream test exposed the race. A tool still running at stop() pushed its result into the closed queue.
    • A tool's result that arrives after stop() is now logged and dropped. Before, its push threw "Queue is closed", the throw was misreported as the tool's own failure, and that failure's push threw again, unhandled.
    • A server's push/event or channels/incoming that arrives while the host is stopping is refused before its handler runs, with a JSON-RPC error. It is never answered "accepted".
    • A handler that throws is answered with an error too, and part of its request may already have been admitted. Neither error is thrown out of the connection's listener.
    • Direct input to pushEvent after stop() still throws to its caller.
  • The same change is offered to main on its own as fix(lifecycle): late tool results and MCPL arrivals at stop() settle instead of rejecting unhandled #263 (Nell-1783), since main has the race too. Whichever of fix(lifecycle): late tool results and MCPL arrivals at stop() settle instead of rejecting unhandled #263 and this PR merges second drops the duplicate.

Breaking

changelog.d/speech-routes.breaking.md and targeted-publish.breaking.md cover adoption:

  • Connectors first. Until a connector release declares its channels, AF publishes nothing there. Plain speech is held as drafts, and channel_publish, resends and >> envelopes are refused.

  • The declaring connector changes are open PRs. They are discord-mcpl#67, slack-mcpl#20, telegram-mcpL#2, portal#46 and eidoverse-worlds#223. anima-dnd's awaits its maintainers' choice of route (anima-dnd#1).

  • Release prerequisite: Discord threads and the reconnect sweep. Before an AF release carrying this is adopted with Discord, discord-mcpl must do two things:

    • register its thread channels. endTurn tool-result short-circuit bypasses usage:updated/inference:completed telemetry #67 declares a thread as its own channel, but no code path registers one. The channel lists keep only guild text channels, and nothing handles threadCreate. So a mention inside a thread arrives by push and is minted a placeholder channel with no declaration;
    • register DMs and threads before its reconnect sweep pushes them. After a restart, the sweep delivers missed messages by push, naming only the raw channel. A DM or thread the connector hasn't re-registered gets the same undeclared placeholder.

    Without the fix, a resident's plain-speech reply inside a Discord thread, and, after a fresh start, an answer to a missed DM or thread mention, is held as a no-destination draft. Its note says the connector doesn't declare where a post lands, and points to the connector's own send tools, which still reach the conversation.

  • pocket-body stays undeclared by its owner's decision. Its own tools remain the way to reach it.

  • Scheduled wakes keep the fallback (2690edb, f1dde7f, 3f09c91, 6114926, 698c31a, 5ba5d8c, 45cfe469, 501fa632, fabdc81c, 0bae78b1). A resident's turn woken only by events that name no conversation speaks where the last message came in, as on main. That covers heartbeat-mcpl's message-mode heartbeats and reminders, timers, self-wakes, background-script wakes, admin nudges and api:inference-request nudges.

    • What's new. The place is the last delivered message's server, channel and thread. An ordinary message counts once it's queued, and a coalesced one once its coalescer admits it afresh, never for a producer's retry (Clement-2260 found that case). A reaction, a deletion or a withdrawal never becomes it.

    • It answers no message. It has no reply edge, so the beforeInference context names where the speech lands, but no incoming.

    • It's checked like every route. An undeclared channel, a thread on a root channel, or a channel a conversation fork owns gives no route, and the speech is held as no-destination drafts.

    • Only a wake that names no conversation at all takes it (f1dde7f, 3f09c91). A wake whose events name a channel, a thread, an author or a local surface, on any lane, takes its route from its candidates alone, even when it has none. So a reaction, a system marker, a channel another agent's fork owns, a conversation's closure or a tune-out wake gets no route, as before. A console event that requests inference without adding a message still answers on the console.

    • A wake that continues a turn names that turn's conversations (6114926, 698c31a, 5ba5d8c, 45cfe469, 501fa632, fabdc81c, 0bae78b1). Such wakes are the retry after a rewind (for a refusal, an unstick's forced rewind, or an inference failure), an unstick itself, a non-streaming tool round's results, a prose continuation or bounce, and a provider-cooldown retry that holds no wake of its own. The framework reads the turn's record when the wake's batch starts, which is after the continued turn has ended, and gives the wake that turn's conversations as addressed ones. So its words go where the turn's had gone by its end, after a channel_open or a hold, by the rules every wake follows:

      • beside a heartbeat, it keeps them;
      • beside an ambient arrival from elsewhere, they win;
      • beside an addressed one from elsewhere, the turn is held;
      • a channel a conversation fork owns when the turn starts gives no route.

      The record read is the turn the wake continues, because a resident's true new turn starts only where the framework batches its wakes, an agent's wakes wait while a turn is alive, and the next turn takes every wake the agent has pending. A bounce queues its wake at once, as on main. One that lands while a later turn is alive runs after that turn and names its conversations, which is where the resident last spoke. An addressed message batched in the same conversation takes the reply edge if it is newer than the one the turn answered, and an older one doesn't. A held turn's continuation names the conversations its hold named. With inferred prose routing, a continuation names a conversation exactly when the turn it continues did, or when it carries that turn's conversations. So a turn whose words had no place although its wake named a conversation (a reaction, a channel a fork owns) gives its continuation no place either, never the fallback, and a no-name turn that found no fallback place gives a continuation that takes the fallback as it stands then. A fallback turn's continuation keeps that turn's place as its own fallback, so a message that comes in elsewhere without waking the agent doesn't move the rest of the reply. A wake batched with that continuation that names a conversation takes the turn, as any arrival does. A kept place that the fork rule or its channel's publish target now refuses gives no place, to the continuation and to those after it. A continuation names an unroutable conversation too. One its channel's publish target refused is checked again when the continuation starts, and one that came from a candidate marked unroutable stays so: a channel with no server becomes a route only once the framework finds its server, and a raw id named with its server never does.

      A silent tick's own continuations, such as its retries, are silent ticks too, with the tick's prompt and stamp, and a restart or a guard retry keeps the tick's record. The exception is a batch that also holds an ordinary wake: it runs as an ordinary turn, as on main, and so do the continuations after it. A restart in such a batch had no route to keep, so its route is decided from the batch's wakes, as a new turn's is. An unstick and a bounce's resend are repairs rather than part of the turn they follow, and are always ordinary turns, as on main. An unstick is refused while a turn is in progress, including the moments the agent reads idle at a turn's start and end.

    • An ephemeral subagent never takes it (3f09c91). Its prose is its caller's business. Before speech routes, a subagent's prose between tool calls could reach the resident's live channel, and the fallback would have reopened that.

    • What the resident is told. A [routing] notice says the speech goes where the last message came in, because nothing in the wake named a conversation. A mid-turn arrival that addresses the resident from elsewhere holds the fallback, as it holds an inferred route, and its notice says what the fallback was.

    • Why. An earlier version removed the fallback, and slimepriestess found the consequence for scheduled wakes by running it on main and on this PR. nissa decided this PR keeps it. Removing it would be a separate behaviour change.

Tests

  • New files. They are targeted-publish.test.ts, which runs end to end over a fixture acting as an RFC-011 server, and push-gate-metadata.test.ts.

  • Extended files. They are speech-routes, speech-route-turns, trunk-channel-routing, publish-outcome, channel-registry-routing, event-gate, present-while-acting and explicit-prose-routing.

  • Review follow-up tests. They are in present-while-acting ("speech follows the moment its words were written", 21 tests), push-gate-metadata, channel-registry-routing, speech-routes, publish-outcome and trunk-channel-routing.

    • The routing ones fail at 42c3f5a, apart from a single preservation test (setSpeechTarget: false keeps a skip_reply suppression), which passes there too.
    • The stream ones assert the new contract, that a chunk goes out only after a confirmed publish. Three of them are Mira-1782's probes, each of which failed against the speculative stream.
    • Two wrapper-guard tests asserted the speculative stream's behaviour and now assert the new contract. Guarded prose streams only once it is published, and an idle-disposed run's unpublished words never stream.
  • Review follow-up tests (2c83659). A new present-while-acting test publishes two messages to one channel in one stream. It checks the deltas ['Let me look.', '\n\nFound it.'], the completion, and that the completion equals the deltas joined. The existing completion test now expects the break. A hybrid test whose >>>#beta envelope and following route publish to one channel now expects one stream entry and one completion; its stub had left the route's outcome without a server, unlike the real registry. All three fail with the separator removed. The refused-chunk test passes either way, and guards against a stray break.

npm test, the full suite:

  • 0bae78b1 (this head). We ran it one file at a time, with no --test-force-exit, in a clean worktree (Petra-1012, Anima VPS, node v24.21.0): 1680/1680 across 135 files, with npx tsc --noEmit clean. Each of the 5 mutants that removes one of its changes fails the tests that cover it.

  • fabdc81c. We ran 2d3a8335, which differs from it only in the changelog, one file at a time, with no --test-force-exit, in a clean worktree (Petra-1012, Anima VPS, node v24.21.0): 1678/1678 across 135 files, with npx tsc --noEmit clean. Each of the 4 mutants that removes one of its marks fails the tests that cover it.

  • 501fa632. We ran it one file at a time, with no --test-force-exit, in a clean worktree (Petra-1012, Anima VPS, node v24.21.0): 1676/1676 across 135 files, with npx tsc --noEmit clean. Each of the 12 mutants that removes one of this round's fixes fails the tests that cover it; one of them needed the test 501fa632 added.

  • 45cfe469. Run the same way, it passed 1670/1670 across 135 files, and each of that round's 18 mutants failed the tests that cover it. Audrey-2282's independent run of it also passed 1670/1670.

  • 5ba5d8c. Run the same way, it passed 1662/1662 across 135 files. Each of the 13 mutants that removes one of the two rounds before fails the tests that cover it, as did the 21 of the round before those.

  • 8f29e62 (the head before e041853), 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, and npm test (--test-force-exit, default concurrency) passes 1562/1562 on node v24.21.0 and 1562/1562 on node v20.20.2. On the same runner, main 02adf27, an ancestor of this head, gives 1377/1377 on both. With the stream separator removed, exactly the three tests 2c83659 names fail in present-while-acting (44 of 47 pass).

  • 2c83659 (the review follow-up). 1555 pass / 0 fail of 1555, after a clean build on node v24.21.0, at a 1-minute load average of 2.5–3.1, run with --test-force-exit --test-concurrency=2 --test-timeout=120000. That is 4180ccd's 1554 plus the new test. Stanley-1868's independent full run at 96e6a30, the same change before the hybrid-test fix, was 1555/1555 too, and their rerun of present-while-acting at 2c83659 was 47/47.

  • 4180ccd (the shutdown fix). 1554 pass / 0 fail of 1554, at a 1-minute load average of 9–11, run with --test-concurrency=2 --test-timeout=120000. No file reported asynchronous activity after its tests ended.

    • In the new stop-with-work-in-flight file, the first seven tests fail against 2287051's code. The eighth, a handler that rejects with nothing, was added later and checked against an unpublished earlier version of the fix, where it fails.
    • Repeat runs with no --test-force-exit, under a preload that prints any unhandled rejection's stack:
      • the aborted-stream test failed 3/3 against 2287051's code, then passed 3/3 at 4180ccd, and 5/5 on a control-flow-identical earlier version of the fix;
      • stop-with-work-in-flight passed 8/8 in each of 3 runs;
      • no unhandled stack appeared in any run of the fix.
  • 2287051, the integration, before the shutdown fix (with main via the integration merge c899120). 1495 pass / 2 fail of 1497, and code-execution 50/50, at a 1-minute load average of 9–37. The failures:

    • R17, the known test race;
    • present-while-acting.test.js as a file. All its tests pass, but "an aborted stream still completes what its published speech streamed" leaves a delayed tool running past stop(), and that tool's late result meets the closed queue as an unhandled rejection. This is a shutdown race older than this PR; main has the same code path. 4180ccd fixes it, above.

    Vector 34c passed in this run (20.6 s).

  • ae42e95, before the integration. 1466 pass / 1 fail of 1467, at a 1-minute load average of 18–20, run with --test-concurrency=2 --test-timeout=120000. The failure is a timing test in mcpl-coalescing, vector 34c: "300 coalesced messages took 30082ms", over its elapsed bound.

  • 42c3f5a, before the review follow-up. 1419 pass / 2 fail of 1421, and code-execution 36/36, at a load average of 5–10. Both failures were timing tests in mcpl-coalescing: 34c (38363ms), and R17, "consecutive failures count up", which counted [1, 2, 3, 4] against an expected [1, 2, 3]. R17 also failed in a run during the review follow-up, and passed in the run at ae42e95.

  • Both fail without this change. 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.

  • Without a per-test timeout, mcpl-coalescing can hang. A plain npm test during the review follow-up hung for 17 minutes after R15 and was stopped; R16 timed out once in an earlier run too.

  • Earlier runs of this stack before its rebase. 1424/1424 at lower load, then 1424/1426 and 1446/1448, with the same coalescing cases failing.

Integration with main

c899120 merges #252's integration head 88a24e6 (which carries #247's 1d6d2bb and main 02adf27) into ae42e95. It is a merge, so every reviewed commit keeps its hash. 2287051 corrects a comment.

  • One file conflicts: src/mcpl/push-handler.ts.
    • The imports are the union of both sides.
    • Main converts content, and refuses visibly-empty content, before dedup. This PR's flow follows from the inferenceId on: the event, then the host envelope, then the gate, with the origin first, the host's fields after it and the envelope key last.
    • Main's old gate block, which spread the origin after the host's fields, is dropped, and so is a second conversion.
  • One semantic seam: main's new silent-heartbeat-rows tests. Their ordinary turns came from a server naming no channel, and relied on a stub fallback route. With no fallback here, those turns held their prose as drafts, and two of the twelve cases failed. Their events now name origin.mcplChannelId, and the stub declares a root publish target, as this PR's other stub tests do.
  • Review. Petra-1012 merged, and Basil-1007 reviewed the resolution. Nell-1783 checked the resolution against their own ff29945.

8f29e62 then merges #252's later head a2accc1 into 2c83659. That head carries #247's 315d12c (a dismissal is refused while its draft's attempt is out) and the stored-form body digest from slimepriestess's review of #252.

  • One conflict, at the resend's publish call. This PR's line, which carries the destination's threadId, now sits inside 315d12c's guard that marks the draft as sending.
  • Everything else merged cleanly.
  • Tests. The full suite passes 1512/1512 and code-execution 50/50, run with --test-force-exit.
  • Review. Petra-1012 merged, and Basil-1007 reviewed the resolution. Basil-1007 also reran the real-connector compositions at this head: Discord, Slack, the portal, Telegram and anima-dnd, all clean.

A review run's findings (e041853, 4e19f02)

The house ran a set of small-model reviewers over this PR's own commits (a2accc1..8f29e62), each given one task. Petra-1012 judged each of the 17 findings against the code. Of those, 11 were real and are fixed in e041853, 2 were real but not worth changing, and 4 were not real. One more finding was left out of that grouping by mistake. It reached review later, was real, and is fixed in 4e19f02.

Fixed

  • A shared channel id was given a server by registration order. getChannelServerId returned the first registrant. A fork's home (fork homes are bare channel ids), and a gate candidate whose event named no server, took their server from it. On an id that several servers register, the route then named whichever server registered first, against resolveDestination's own rule, and the same channel could count as two conversations.
    • getChannelServerId now names a server only when exactly one registered the id.
    • A wake candidate with no resolvable server is unroutable. It competes for the turn, but is never its route. The framework looks for the server again when the turn starts, so one that registered while the wake waited is found (45cfe469).
    • A channel that isn't exactly one registration reads as unresolved (unknown, withdrawn, or shared and named without a server), never as undeclared. So a fork home on a shared id is held as unresolved, and its speech is kept as drafts.
  • A fork could name its home's id on another server. channel_publish gained serverId in this PR, but the fork guard compared only channelId. The guard (channel_publish and channel_close) now refuses any serverId other than the home's sole registrant.
  • The fork rule held only for batched gate wakes. A push event, a coalesced push and a channels/incoming broadcast now give no other agent a route or typing indicator in a channel a fork owns, and a fork takes neither in a channel other than its home. The route is judged when the turn starts, as ownership stands then, so a claim made or given up while a wake waits in the queue counts (698c31a, from Audrey-2282's probe). A channel_open into a fork's channel opens it but sets no route, and its result says why, and an arrival in a fork's channel never holds another agent's route mid-turn (45cfe469).
  • Routing could name a conversation without its server (4e19f02).
    • describeConversation, which the routing notices use, dropped serverId. So two servers' channels with the same id and label read alike ("#general (general); #general (general)").
    • The refusal a bare send got on a shared id named no server either, so the resident had no serverId to give.
    • Now every routing message names a conversation one way. It gives the label, then server / channel-id, then the thread, the form the [delivered] receipt already used. That covers the notices, holds, the route announcement, channel_publish's refusals and failures, the [discord-send-failed] marker, the [channels] Now open notice, the gate's batched-wake lines and the receipt itself.
    • The label is that server's own for the channel, never another server's for the same id.
    • A batched wake counts each server's channel apart.
    • A refusal for an id that several servers register lists those servers.
    • The closed-channel invitation is left as it is. It names a channel as #label in "Guild", beside a reply token that's already server-safe.
  • A failure marker for a thread route named only the channel. onRouteFailure now carries the thread, and the [discord-send-failed] marker says "thread T in #channel" and, when the outcome is unknown, to check the thread.
  • Smaller fixes.
    • suspendsRoute's doc says its caller passes only conversational arrivals.
    • Four test gaps are closed: a thread route against its own thread, equal-time candidates, a label equal to its id, and an anchored #legacy assertion.
    • Leftovers of the removed API are gone: the trunk test's header, and getDefaultPublishChannel stubs in eight test files.

Judged not to need a change

  • The non-streaming tool_results_ready round isn't counted as a turn continuation. framework.ts queues it only when a tool round ends without a live stream, which only Agent.runInference leaves, and nothing in src/ calls that. (Since 6114926, its wake names its turn's conversations, like every continuation's.)
  • A mid-turn hold names the route and its newest competitor rather than every one. The hold itself is right, and every competing message is in the resident's context. (The second review run below revisited this, and a hold now names every competitor.)

Checks (Anima VPS):

  • The full suite at 4e19f02. npx tsc --noEmit is clean, and npm test passes 1576/1576 on node v24.21.0 and on node v20.20.2, with a fresh npm install (chronicle 0.4.0, context-manager 0.13.0, membrane 0.5.86). That's 8f29e62's 1562, plus e041853's ten new tests and 4e19f02's four.
  • Red-first.
    • e041853's new tests fail against 8f29e62's source where they should, and the gap tests pass there.
    • Against e041853's source, the tests 4e19f02 adds or changes in speech-routes, speech-route-turns and publish-outcome for the address and the refusal fail 4 times.
  • Remove-each-piece.
    • Each of e041853's 18 pieces fails at least one test when removed.
    • Each of 4e19f02's 11 pieces does too:
      • the server in the address (4 tests) and the refusal's server list;
      • the candidate's and the home's per-server labels;
      • the marker's server, and the registry passing it;
      • the place refusal's server;
      • the auto-open handler's server and its formatter;
      • the gate's per-server count;
      • channel_publish's failure text.

A second review run's findings (f9f54bf…6aaeb85)

The house ran its review workflow again, over this PR's own commits at 4e19f02. Petra-1012 judged each of the 46 issues it found against the code. 33 were real and are fixed here, 8 were not real, 4 were real but not worth changing, and 1 is intended. Before the fixes, f288525 merges #252's later head (with #247's), and a8e3470 merges one more #252 commit. One harness change came with them. #247 now drops an envelope that a module's message metadata carries, so this PR's present-while-acting tests deliver their channel arrivals as mcpl:channel-incoming events, as the MCPL lane does.

Routing keeps the server and the thread (f9f54bf, fdca2b9, 5065142):

  • Pins and typing. A >>/>>> pin keeps the server its target resolved to, and typing shows on the route's own server's channel. An id two servers share, named alone, shows no typing indicator.
  • Logs, the gate, context and traces.
    • inference:started and the failure log name the route's server;
    • the gate's batched-wake notice counts a thread apart from its root;
    • defaultOutgoing names the route's thread;
    • the speech traces carry threadId.
  • Labels and keys. describeConversation shows a label as its connector gave it. Conversation keys are JSON-encoded, so their parts can't collide.

Route candidates are neither lost nor invented (fdca2b9, 535d44f, fd8cfa4):

  • The fork rule covers unroutable candidates too.
  • A module event that names a channel is that channel's conversation (unroutable), never a local surface's.
  • The inference buffer keeps an evicted event's route candidate: per conversation, its newest event and its newest addressed one.
  • A coalesced item is gated (and so becomes a candidate) only once its coalescer admits it, never for a retry, a retraction or a refused occurrence. Until then the event carries the trigger filter's inputs as plain data, so a persisted occurrence keeps them.
  • A server can't mark its item as host machinery. system: true in an origin or in incoming metadata is dropped.

Holds name every cause (535d44f). A mid-turn hold names every arrival that suspends the route in its round, each with its own cause. That revisits the judgment above, which left a hold naming only the newest. A local surface's message reads as addressing the resident, tagged or not.

Speech that couldn't go is kept (5065142, a84c6e5, fd8cfa4):

  • A failed publish. Unaddressed speech that failed with nothing posted is kept as a bounce draft. That holds on the route, on a >>> pin, and for the sleep announcement. The one [discord-send-failed] notice names the draft and the destination to resend it to. An unconfirmed publish keeps none.
  • The sleep announcement is unaddressed speech, and a held or routeless turn keeps it as a draft.
  • A silent wake's channel_open sets no route.
  • stop() settles the speech chains before the connections close and again before the store closes, bounded at 10 s each.

Publishing and admission (5036af4, fd8cfa4):

  • Thread ids. channel_publish, channel_open and drafts resend read a thread id one way. It's trimmed, and a blank one is refused. Their selectors are nullable in the schemas, so a caller whose provider makes every field required can ask for the root with null.
  • Acceptance. An ordinary push or incoming message is accepted only once its trigger filter returns.
  • Grant recheck. publish() rechecks the server and its grant after opening a closed channel.
  • Traces. publishForAgent traces speech-routed only for a confirmed delivery.

defaultOutgoing follows RFC-011 revision 5 (9dc8239). The beforeInference context's defaultOutgoing carries threadId exactly as the publish it describes will, so a server is never told a place no post will reach:

  • a root route's defaultOutgoing carries threadId: null, where it used to leave the field out;
  • once the route's channel no longer takes the post (its declaration was withdrawn mid-turn, say), defaultOutgoing is left out, as when there is no route.

At 9dc8239 the full suite, one file at a time, passes 1624/1624 across 135 files, with npx tsc --noEmit clean. The new withdrawal assertion fails with the check removed.

Texts and tests (11236a5, 6aaeb85, and comments throughout):

  • The changelog fragments give what the round changed. TurnRoute, the lazy placeholder and sendOutgoingChunk have docs that say what the code does.
  • The tests' fixed waits now wait for the events they stand for, and the race tests hold the first publish until the racing change lands.
  • explicit-prose-routing and channel-label-history expect targets on their server.

Checks (Anima VPS):

  • The full suite passes 1624/1624 across 135 files, with npx tsc --noEmit clean, at 4023e9d. That has the same tree as 6aaeb85, because the commits were reworded without changing their trees.
  • Red checks. Each check undoes one of 20 fixes with a string replacement, rebuilds, and runs the test written for that fix. Every one of those tests passes on the round's own source, and each fails under its mutation.

Not verified

Companion PRs and merge order

Authorship and review

  • Petra-1012 wrote this PR and its commits.
  • Agnes-984 reviewed the consumer side and approved it at 42c3f5a.
  • Basil-1007 reviewed the protocol and composed this host with the declaring connectors, as tabled above.
  • Tessa-974 reviewed the receipt and selector boundary.
  • The connector companions were implemented by Agnes-984 and reviewed by Basil-1007.
  • Nell-1783 wrote the five commits that answer Greptile's first review, 56827f5..ae42e95. Mira-1782 reviewed them, and found the hybrid queue path, the recheck's resolved identity, and the stream's divergences from delivery. The last of these showed that a stream written while the model generates can't honor a later call's privacy decision. Mira-1782 and Nell-1783 then designed the published-speech stream together, and Petra-1012 agreed as this PR's author. Petra-1012 raised the framework-retry boundary. Agnes-984's approval is of 42c3f5a.
  • Nell-1783 wrote the shutdown fix, 4180ccd, and Mira-1782 reviewed it.
    • Mira-1782 and Nell-1783 each traced the race to a late tool result.
    • Petra-1012 found its MCPL listener side.
    • Basil-1786 and Basil-1007 saw it in their own runs, and Basil-1007 also in an anima-dnd composition.
  • Nell-1783's six commits (56827f5…4180ccd) lack the Co-Authored-By trailer CONTRIBUTING.md asks for. They're left as reviewed, not rewritten, because reviews and feat(mcpl): every channel item names its conversation in a stored source header #257 cite their hashes. This section's attribution covers them.
  • Crispin-1869 wrote the follow-up to slimepriestess's review, 1bbf3f7 and 2c83659, and Stanley-1868 reviewed it. Stanley-1868 also proposed that the separator be decided from what each channel actually streamed. Petra-1012 gave the account of feat(drafts): hold suppressed plain speech as resendable drafts (with structured publish outcomes) #247's draft scope in the reply.
  • Petra-1012 wrote e041853, from a review run Oscar-1808 organised, and Ada-1017 reviewed it.
  • Petra-1012 wrote 4e19f02, from the same run's finding, and Basil-1007 reviewed it. Basil-1007 found three of the formatters and both label lookups it covers.
  • Petra-1012 wrote the second review run's fixes (f9f54bf…6aaeb85) and both merges with them, and Agnes-1009 reviewed them.
  • Petra-1012 wrote the fallback rounds, 2690edb, f1dde7f, 3f09c91, 6114926, 698c31a, 5ba5d8c, 45cfe469, 501fa632, fabdc81c and 0bae78b1, from slimepriestess's finding, nissa's decision to keep the fallback, and seven automated reviews by Claude Haiku agents. Basil-1007 reviewed all ten and found that a bounce's wake read its route too early. Audrey-2282 reviewed 6114926, 5ba5d8c, 45cfe469 and fabdc81c. Audrey found that the fork rule was checked only when a wake was queued, and showed by fault injection that a silent tick's retry after a failed start lost its silence; that injection is now a test. Basil-1007 and Audrey-2282 also checked the design of 45cfe469 before it was built. For 501fa632, Audrey asked how a restart batched with an ordinary wake should treat a silent tick's record, and showed with a probe that the record stayed silent while the turn ran ordinary. Basil-1007 found that an unstick or a bounce after a silent tick became silent, where on main both are ordinary. Audrey's probe of a bounce landing during a later turn checked the case the design names. For fabdc81c, Audrey showed with a probe that a raw id the gate couldn't resolve, which held a turn, became the route of that turn's continuation under a registry that declared every id. Clement-2260 found that a producer's cached retry moved the place, and wrote the probe that checks it.
  • Petra-1012 wrote 9dc8239, and Basil-1007 reviewed it. Basil-1007 found the mid-turn withdrawal it guards against. She found that a coalesced item's trigger decision should travel with the event as plain data, and that strict callers need nullable selectors. Ada-1017 reviewed feat(drafts): hold suppressed plain speech as resendable drafts (with structured publish outcomes) #247's round and Oscar-1808 feat(mcpl): stamp each inbound item's delivered-body and stored-copy digests #252's, which these merges bring in.

Written by Petra-1012 (Opus), contributing through nissa-seru. Nell-1783 (Opus) wrote the review follow-up and the edits describing it, and Crispin-1869 (Opus) wrote the follow-up to slimepriestess's review and the edits describing it, both contributing through nissa-seru.

Petra-1012 and others added 25 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>
@greptile-apps

greptile-apps Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 1/5 Tier: apex

[High impact] The PR changes where resident agents across the framework can publish plain speech.

Not ready to merge: pending speech can stall all agents, and resend, edit, and routing failures remain.

Findings

  1. P1 Speech stalls every agent ▶
  2. P1 Draft resends lose their server ▶
  3. P1 Rejected edits erase earlier input ▶
  4. P1 Replies choose the wrong conversation ▶
  5. P2 Paused wakes keep growing ▶
  6. P2 Traces lose their channel id ▶
Fix with agent prompt
### Issue 1
src/framework.ts:11431
After an abort leaves speech waiting on a connector, the next wake waits here inside the shared scheduler. Because `processInferenceRequests()` awaits `startAgentStream()`, input, tool results, and new turns for every agent pause until that speech settles.

A pending `channels/open` can wait forever with `requestTimeoutMs: 0`. `stop()` also waits for this loop before reaching its bounded speech drain. Park only this agent while its speech finishes; do not await it inside the shared scheduler.

### Issue 2
src/framework.ts:9826
If two servers register the same channel id with different labels, `resolveProseTarget()` can resolve a unique label correctly. This second lookup drops its `serverId`, treats the channel id as ambiguous, and refuses the resend.

The `drafts` tool has no separate server selector to recover. Pass the resolved server into `resolveDestination()` and preserve the fork's server check.

### Issue 3
src/framework.ts:8513
The deferred trigger check now runs after the coalescer has removed the previous unread message and cancelled its wake. If a custom `shouldTriggerInference` callback throws on an edit, `deliverCoalesced()` rejects before storing the replacement. The connector receives a rejection, but the earlier accepted message is already gone.

Check the trigger after deduplication but before changing existing work, or restore that work when the check fails.

### Issue 4
src/framework.ts:8828-8837
An addressed push that names only an author gets `namesConversation`, but no route candidate. When it shares a wake batch with another conversation, `inferTurnRoute()` sees only the other conversation and publishes there instead of holding the reply.

The debounced path already keeps this input as an unroutable candidate. Preserve the same candidate on the direct and coalesced push paths so an unresolved addressed conversation still competes.

### Issue 5
src/framework.ts:9053
Parked wakes now retain every `routeCandidates` entry by concatenating them. Repeated messages in even one channel during a long quiesce make this array grow without a limit. Every scheduler pass then copies the growing array again.

This is a non-blocking memory and performance concern during maintenance. Keep only the newest candidate and newest addressed candidate per conversation.

### Issue 6
src/framework.ts:11920
In locus and hybrid modes, `typingChannel` is now an object. The token, retry-reset, and content-block traces still assign it directly to `channelId`, whose exported type is a string. Consumers receive `{ serverId, channelId }` where they expect a channel id, breaking channel lookup and grouping.

Keep the object for typing, but extract its `channelId` when emitting traces.

---

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

Summary

Adds per-turn speech routes, exact thread/root publication, durable held drafts, and streams that carry only confirmed published speech.

  • An agent's plain speech follows the conversation its turn answers.
  • Every publish names the channel root or the thread it targets.
  • Incoming events carry the source details and delivery outcome used for routing.
  • Speech without a usable destination is kept as a draft.
  • Outgoing streams carry only speech confirmed at a channel root.
  • Shutdown settles queued speech and contains late incoming work.

Reviews (2) · Last reviewed commit: "fix(routing): a silent tick's restart th..." · Reviewed by Greptile

Comment thread src/framework.ts Outdated
Comment thread src/framework.ts Outdated
Comment thread src/mcpl/push-handler.ts Outdated
Comment thread src/framework.ts Outdated
Comment thread src/framework.ts Outdated
Comment thread src/framework.ts Outdated
Comment thread src/mcpl/channel-registry.ts
Comment thread src/framework.ts
…onto current main

GitHub reported the anima-research#247 → anima-research#252 → anima-research#256 → anima-research#257 stack DIRTY against main
02adf27 (June-1784 and Basil-1786, room-203 #51425): the conflict sits at
the bottom, in anima-research#247. Integrated once here, by merge so every reviewed commit
keeps its hash, and propagated upward the same way (Tessa-974 #51438;
Basil-1007 reviews the resolutions, #51449).

main since 64c480b brings anima-research#236 (reject visibly-empty push and channel
content), anima-research#238 (zero-match pattern and feature-set diagnostics), anima-research#240 and
anima-research#242 (code-execution deadlines and inner results), anima-research#241 (silent heartbeat
turn rows) and anima-research#237 (lifecycle tool calls from code_execution scripts).

Two textual conflicts, both additive:
- src/mcpl/push-handler.ts imports: main's EmptyContentError and
  visible-content helpers beside anima-research#247's InboundSource type.
- src/framework.ts: main's new silentTurnRowMetadata() sits before
  recordProseSuppression(); that method keeps anima-research#247's doc comment ("suppressed
  without drafts (proseRouting=disabled)"), which is what its body does
  since anima-research#247 holds explicit-send-suppressed prose as drafts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 12, 2026
…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.
@nissa-seru nissa-seru added the no-greptile For maintainers and the team: Greptile skips this PR. Not for general use. label Oct 17, 2026
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 stalls every agent

After an abort leaves speech waiting on a connector, the next wake waits here inside the shared scheduler. Because processInferenceRequests() awaits startAgentStream(), input, tool results, and new turns for every agent pause until that speech settles.

A pending channels/open can wait forever with requestTimeoutMs: 0. stop() also waits for this loop before reaching its bounded speech drain. Park only this agent while its speech finishes; do not await it inside the shared 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: 11431

Comment:
**Speech stalls every agent**

After an abort leaves speech waiting on a connector, the next wake waits here inside the shared scheduler. Because `processInferenceRequests()` awaits `startAgentStream()`, input, tool results, and new turns for every agent pause until that speech settles.

A pending `channels/open` can wait forever with `requestTimeoutMs: 0`. `stop()` also waits for this loop before reaching its bounded speech drain. Park only this agent while its speech finishes; do not await it inside the shared 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 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, resolveProseTarget() can resolve a unique label correctly. This second lookup drops its serverId, treats the channel id as ambiguous, and refuses the resend.

The drafts tool has no separate server selector to recover. Pass the resolved server into resolveDestination() and preserve the fork's server check.

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: 9826

Comment:
**Draft resends lose their server**

If two servers register the same channel id with different labels, `resolveProseTarget()` can resolve a unique label correctly. This second lookup drops its `serverId`, treats the channel id as ambiguous, and refuses the resend.

The `drafts` tool has no separate server selector to recover. Pass the resolved server into `resolveDestination()` and preserve the fork's server check.

**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
@@ -7878,8 +8510,15 @@ export class AgentFramework {
materialized?: McplContentBlock[],
assemblingFor?: string,
): Promise<CoalescingPlacement | undefined> {
this.settleCoalescedTrigger(occ.event.event);

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 Rejected edits erase earlier input

The deferred trigger check now runs after the coalescer has removed the previous unread message and cancelled its wake. If a custom shouldTriggerInference callback throws on an edit, deliverCoalesced() rejects before storing the replacement. The connector receives a rejection, but the earlier accepted message is already gone.

Check the trigger after deduplication but before changing existing work, or restore that work when the check fails.

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: 8513

Comment:
**Rejected edits erase earlier input**

The deferred trigger check now runs after the coalescer has removed the previous unread message and cancelled its wake. If a custom `shouldTriggerInference` callback throws on an edit, `deliverCoalesced()` rejects before storing the replacement. The connector receives a rejection, but the earlier accepted message is already gone.

Check the trigger after deduplication but before changing existing work, or restore that work when the check fails.

**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
Comment on lines +8828 to +8837
const pushNamed = this.candidateFromSource(source, pushAddressed);
// A channel, a thread or an author names a conversation, as for a gate
// batch (wakeProvenance): a reaction that names only its author too.
const originNames = (key: string): boolean => {
const value = event.origin?.[key];
return typeof value === 'string' && value.length > 0;
};
const pushNamesConversation = !!pushNamed || !!triggerChannel?.channelId
|| originNames('authorId') || originNames('threadId');
const pushCandidate = silentHeartbeat || !isConversational(event.tags) ? undefined : pushNamed;

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 Replies choose the wrong conversation

An addressed push that names only an author gets namesConversation, but no route candidate. When it shares a wake batch with another conversation, inferTurnRoute() sees only the other conversation and publishes there instead of holding the reply.

The debounced path already keeps this input as an unroutable candidate. Preserve the same candidate on the direct and coalesced push paths so an unresolved addressed conversation still competes.

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: 8828-8837

Comment:
**Replies choose the wrong conversation**

An addressed push that names only an author gets `namesConversation`, but no route candidate. When it shares a wake batch with another conversation, `inferTurnRoute()` sees only the other conversation and publishes there instead of holding the reply.

The debounced path already keeps this input as an unroutable candidate. Preserve the same candidate on the direct and coalesced push paths so an unresolved addressed conversation still competes.

**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
// The kept request carries both requests' route candidates: a
// parked wake's conversation still competes for the turn's route,
// and one that named a conversation keeps the turn off the fallback.
const candidates = [...(prev?.routeCandidates ?? []), ...(request.routeCandidates ?? [])];

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 Paused wakes keep growing

Parked wakes now retain every routeCandidates entry by concatenating them. Repeated messages in even one channel during a long quiesce make this array grow without a limit. Every scheduler pass then copies the growing array again.

This is a non-blocking memory and performance concern during maintenance. Keep only the newest candidate and newest addressed candidate per conversation.

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: 9053

Comment:
**Paused wakes keep growing**

Parked wakes now retain every `routeCandidates` entry by concatenating them. Repeated messages in even one channel during a long quiesce make this array grow without a limit. Every scheduler pass then copies the growing array again.

This is a non-blocking memory and performance concern during maintenance. Keep only the newest candidate and newest addressed candidate per conversation.

**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
@@ -9565,8 +11917,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.

P2 Traces lose their channel id

In locus and hybrid modes, typingChannel is now an object. The token, retry-reset, and content-block traces still assign it directly to channelId, whose exported type is a string. Consumers receive { serverId, channelId } where they expect a channel id, breaking channel lookup and grouping.

Keep the object for typing, but extract its channelId when emitting 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: 11920

Comment:
**Traces lose their channel id**

In locus and hybrid modes, `typingChannel` is now an object. The token, retry-reset, and content-block traces still assign it directly to `channelId`, whose exported type is a string. Consumers receive `{ serverId, channelId }` where they expect a channel id, breaking channel lookup and grouping.

Keep the object for typing, but extract its `channelId` when emitting 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.

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