Repository navigation
feat(routing): speech follows its conversation, and publishes only where MCPL RFC-011 declares the place - #256
nissa-seru wants to merge 75 commits into
Conversation
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>
[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
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.SummaryAdds per-turn speech routes, exact thread/root publication, durable held drafts, and streams that carry only confirmed published speech.
Reviews (2) · Last reviewed commit: "fix(routing): a silent tick's restart th..." · Reviewed by Greptile |
…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.
…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.
| // 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); |
There was a problem hiding this comment.
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.| 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 }); |
There was a problem hiding this 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
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.| @@ -7878,8 +8510,15 @@ export class AgentFramework { | |||
| materialized?: McplContentBlock[], | |||
| assemblingFor?: string, | |||
| ): Promise<CoalescingPlacement | undefined> { | |||
| this.settleCoalescedTrigger(occ.event.event); | |||
There was a problem hiding this 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
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.| 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; |
There was a problem hiding this 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
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.| // 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 ?? [])]; |
There was a problem hiding this comment.
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.| @@ -9565,8 +11917,8 @@ export class AgentFramework { | |||
| ? null | |||
| : turnProseRouting === 'explicit' | |||
| ? trigger?.channelId ?? null | |||
| : resolveTurnLocus(); | |||
| if (typingChannel) this.channelRegistry!.startTyping(typingChannel); | |||
| : this.routeChannel(agent.name); | |||
There was a problem hiding this comment.
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 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 adds45cfe469,501fa632,fabdc81cand0bae78b1. 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 onmain.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:
42c3f5a, then five answering Greptile's first review (56827f5..ae42e95);c899120, and its comment fix2287051(see "Integration with main");4180ccd;1bbf3f7(the Breaking note on scheduled wakes, and comments that still described the removed fallback) and2c83659(a paragraph break between messages in an outgoing stream);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, and4e19f02, which fixes one more finding of that run (see "A review run's findings");f288525anda8e3470, 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,fd8cfa4and6aaeb85(see "A second review run's findings");9dc8239, which givesdefaultOutgoingMCPL RFC-011 revision 5's meaning (also in "A second review run's findings");2690edb,f1dde7f,3f09c91,6114926,698c31a,5ba5d8c,45cfe469,501fa632,fabdc81cand0bae78b1(see "Scheduled wakes keep the fallback").Problem
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:
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 modulesrc/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'sorigincan no longer stand in for the host's own fields, such asserverId, nor can any connector-suppliedinboundSourcekey 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.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.reply_messagereports its thread only in a connector-specific field, and tags non-mention follow-ups in that threadchat: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_openthat 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 laterchannel_opensupersedes a pin, or askip_replysuppression, in the order the resident chose. Achannel_openwhose result arrives after a newer turn has begun changes nothing in that turn.What the agent controls.
channel_open(withsetSpeechTarget, default true) sets a deliberate route;channel_publish,drafts.resendandchannel_opentake an optionalthreadId;Undelivered speech isn't lost. Speech with no route, or on a held turn, is kept as a draft that names why:
no-destinationorambiguous.Targeted publish (MCPL RFC-011)
null.capabilities.publish.target, and into a thread only on anexactchannel. Anything else is refused before anything is opened or sent. A targeted publish is always a Request.unknown. A refusal'sreasonis kept.exact.rootchannel is a contradiction. Its speech is held, never posted to the root.threadId: null. A thread placement isn't streamed, so a channel's stream names one place.think, askip_reply, or an explicit send's silence. So held, private, failed and unconfirmed words now never stream, as §14.3 requires.\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.channels.streaming, now speaks each round once it is posted, rather than while it is generated. Speech still lands between tool rounds.ProseStreamRouteris removed. It was internal and not exported.#label (server / channel-id, thread T).channel_publishchecks its selectors before applying any default. An empty or non-stringchannelId,serverIdorthreadIdis refused, with nothing sent.channel_openchecks the same way: an invalidthreadId,serverIdor non-booleansetSpeechTargetis refused with nothing opened. So does a conversation fork'schannelId.Shutdown (a race older than this PR)
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 atstop()pushed its result into the closed queue.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.push/eventorchannels/incomingthat arrives while the host is stopping is refused before its handler runs, with a JSON-RPC error. It is never answered "accepted".pushEventafterstop()still throws to its caller.Breaking
changelog.d/speech-routes.breaking.mdandtargeted-publish.breaking.mdcover 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:
threadCreate. So a mention inside a thread arrives by push and is minted a placeholder channel with no declaration;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-destinationdraft. 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 andapi:inference-requestnudges.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
beforeInferencecontext names where the speech lands, but noincoming.It's checked like every route. An undeclared channel, a thread on a
rootchannel, or a channel a conversation fork owns gives no route, and the speech is held asno-destinationdrafts.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 achannel_openor a hold, by the rules every wake follows: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 onmain. 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, andpush-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-actingandexplicit-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-outcomeandtrunk-channel-routing.42c3f5a, apart from a single preservation test (setSpeechTarget: falsekeeps askip_replysuppression), which passes there too.Review follow-up tests (
2c83659). A newpresent-while-actingtest 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>>>#betaenvelope 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, withnpx tsc --noEmitclean. Each of the 5 mutants that removes one of its changes fails the tests that cover it.fabdc81c. We ran2d3a8335, 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, withnpx tsc --noEmitclean. 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, withnpx tsc --noEmitclean. Each of the 12 mutants that removes one of this round's fixes fails the tests that cover it; one of them needed the test501fa632added.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 beforee041853), as CI runs it (Petra-1012, Anima VPS, a freshnpm install: chronicle 0.4.0, context-manager 0.13.0, membrane 0.5.86):npx tsc --noEmitis clean, andnpm 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,main02adf27, an ancestor of this head, gives 1377/1377 on both. With the stream separator removed, exactly the three tests2c83659names fail inpresent-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 is4180ccd's 1554 plus the new test. Stanley-1868's independent full run at96e6a30, the same change before the hybrid-test fix, was 1555/1555 too, and their rerun ofpresent-while-actingat2c83659was 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.stop-with-work-in-flightfile, the first seven tests fail against2287051'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.--test-force-exit, under a preload that prints any unhandled rejection's stack:2287051's code, then passed 3/3 at4180ccd, and 5/5 on a control-flow-identical earlier version of the fix;stop-with-work-in-flightpassed 8/8 in each of 3 runs;2287051, the integration, before the shutdown fix (with main via the integration mergec899120). 1495 pass / 2 fail of 1497, and code-execution 50/50, at a 1-minute load average of 9–37. The failures:present-while-acting.test.jsas a file. All its tests pass, but "an aborted stream still completes what its published speech streamed" leaves a delayed tool running paststop(), 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.4180ccdfixes 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 inmcpl-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 inmcpl-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 atae42e95.Both fail without this change. At feat(mcpl): stamp each inbound item's delivered-body and stored-copy digests #252's base
711b400,mcpl-coalescingrun 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 headd36a5adreports 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-coalescingcan hang. A plainnpm testduring 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
c899120merges #252's integration head88a24e6(which carries #247's1d6d2bband main02adf27) intoae42e95. It is a merge, so every reviewed commit keeps its hash.2287051corrects a comment.src/mcpl/push-handler.ts.inferenceIdon: the event, then the host envelope, then the gate, with the origin first, the host's fields after it and the envelope key last.silent-heartbeat-rowstests. 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 nameorigin.mcplChannelId, and the stub declares a root publish target, as this PR's other stub tests do.ff29945.8f29e62then merges #252's later heada2accc1into2c83659. That head carries #247's315d12c(a dismissal is refused while its draft's attempt is out) and the stored-form body digest from slimepriestess's review of #252.threadId, now sits inside315d12c's guard that marks the draft as sending.--test-force-exit.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 ine041853, 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 in4e19f02.Fixed
getChannelServerIdreturned 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, againstresolveDestination's own rule, and the same channel could count as two conversations.getChannelServerIdnow names a server only when exactly one registered the id.45cfe469).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.channel_publishgainedserverIdin this PR, but the fork guard compared onlychannelId. The guard (channel_publishandchannel_close) now refuses anyserverIdother than the home's sole registrant.channels/incomingbroadcast 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). Achannel_openinto 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).4e19f02).describeConversation, which the routing notices use, droppedserverId. So two servers' channels with the same id and label read alike ("#general (general); #general (general)").serverIdto give.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 opennotice, the gate's batched-wake lines and the receipt itself.#label in "Guild", beside a reply token that's already server-safe.onRouteFailurenow carries the thread, and the[discord-send-failed]marker says "thread T in #channel" and, when the outcome is unknown, to check the thread.suspendsRoute's doc says its caller passes only conversational arrivals.#legacyassertion.getDefaultPublishChannelstubs in eight test files.Judged not to need a change
tool_results_readyround isn't counted as a turn continuation.framework.tsqueues it only when a tool round ends without a live stream, which onlyAgent.runInferenceleaves, and nothing insrc/calls that. (Since6114926, its wake names its turn's conversations, like every continuation's.)Checks (Anima VPS):
4e19f02.npx tsc --noEmitis clean, andnpm testpasses 1576/1576 on node v24.21.0 and on node v20.20.2, with a freshnpm install(chronicle 0.4.0, context-manager 0.13.0, membrane 0.5.86). That's8f29e62's 1562, pluse041853's ten new tests and4e19f02's four.e041853's new tests fail against8f29e62's source where they should, and the gap tests pass there.e041853's source, the tests4e19f02adds or changes inspeech-routes,speech-route-turnsandpublish-outcomefor the address and the refusal fail 4 times.e041853's 18 pieces fails at least one test when removed.4e19f02's 11 pieces does too: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,f288525merges #252's later head (with #247's), anda8e3470merges 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 asmcpl:channel-incomingevents, as the MCPL lane does.Routing keeps the server and the thread (
f9f54bf,fdca2b9,5065142):>>/>>>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.inference:startedand the failure log name the route's server;defaultOutgoingnames the route's thread;threadId.describeConversationshows 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):system: truein 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):>>>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.channel_opensets 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):channel_publish,channel_openanddraftsresend 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.publish()rechecks the server and its grant after opening a closed channel.publishForAgenttracesspeech-routedonly for a confirmed delivery.defaultOutgoingfollows RFC-011 revision 5 (9dc8239). The beforeInference context'sdefaultOutgoingcarriesthreadIdexactly as the publish it describes will, so a server is never told a place no post will reach:defaultOutgoingcarriesthreadId: null, where it used to leave the field out;defaultOutgoingis left out, as when there is no route.At
9dc8239the full suite, one file at a time, passes 1624/1624 across 135 files, withnpx tsc --noEmitclean. The new withdrawal assertion fails with the check removed.Texts and tests (
11236a5,6aaeb85, and comments throughout):TurnRoute, the lazy placeholder andsendOutgoingChunkhave docs that say what the code does.explicit-prose-routingandchannel-label-historyexpect targets on their server.Checks (Anima VPS):
npx tsc --noEmitclean, at4023e9d. That has the same tree as6aaeb85, because the commits were reworded without changing their trees.Not verified
No live platform was exercised. An independent reviewer composed this host at this PR's head,
4180ccd(and at feat(mcpl): every channel item names its conversation in a stored source header #257's48e5135), with each declaring connector run as a real MCPL process and only its platform client stubbed. The checks assert outcomes: the route formed, what was delivered where, drafts, and what the other side received. Every connector passed in two runs at each head, with no async activity left after a test.2d137bf(endTurn tool-result short-circuit bypasses usage:updated/inference:completed telemetry #67)threadId, so its answer is held until discord-mcpl#66 lands2d137bf+ fix: prevent deferred inference requests from starving the event loop #660ba9f49(Prioritize user input in process queue #20)ed87186(ConversationRouter.route() violates command-query separation (mutates lastActivity) #46)0af1ed4(Add (optional) logging for QueueEvents, and associated UI code #2)4180ccd, interleaved at matched load.3a3fecfthrough6d6f133), the route, delivery, echo and hearing held, but some answers responded to the catch-up push rather than to that question.eidoverse-worlds was not composed. Its evidence stays at the connector level, because a session needs a live world.
The compositions ran at
4180ccd.1bbf3f7changes only comments and changelog text.2c83659changes only the outgoing stream's text between messages. Neither was recomposed.RFC-011 is a draft (RFC-011 draft: targeted publish mcpl#17, types feat(channels): RFC-011 targeted publish types mcpl-core-ts#9). This PR implements its revision 3. Revision 4 adds an optional host migration setting for undeclared servers. Once the maintainers accept that wording, this PR will offer it as a separate, reviewed change (mcpl#17, RFC-011 draft: targeted publish mcpl#17 (comment)).
Companion PRs and merge order
Authorship and review
42c3f5a.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 of42c3f5a.4180ccd, and Mira-1782 reviewed it.56827f5…4180ccd) lack theCo-Authored-Bytrailer 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.1bbf3f7and2c83659, 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.e041853, from a review run Oscar-1808 organised, and Ada-1017 reviewed it.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.f9f54bf…6aaeb85) and both merges with them, and Agnes-1009 reviewed them.2690edb,f1dde7f,3f09c91,6114926,698c31a,5ba5d8c,45cfe469,501fa632,fabdc81cand0bae78b1, 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 reviewed6114926,5ba5d8c,45cfe469andfabdc81c. 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 of45cfe469before it was built. For501fa632, 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 onmainboth are ordinary. Audrey's probe of a bounce landing during a later turn checked the case the design names. Forfabdc81c, 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.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.