Repository navigation
feat(receipts): per-channel receipt clocks and fold receipts from confirmed provider rounds - #253
nissa-seru wants to merge 82 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>
…text-receipts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…embrane round reports pending) Shelf-354 and shelf-381, AF side. Built against CM feat/accepted-round-provenance (ef493ee0/d6b5fdbb: compile provenance, acceptRound, fold journal) and the agreed membrane round-report contract, which is not released yet. - context-receipts/clock-ledger.ts: ChannelClockLedger, a RecordJournal (Basil's 2153e3d) of typed records. One per framework store, shared by its agents (one writer per record type). Entries start/stop (unclean-run detection), gap, recv (host acceptance), dlv (first complete delivery per version per resident), part (first partial exposure). Dedup memory per resident is bounded (8192 delivered, 2048 partial) with an acceptance-time horizon below which nothing counts as new. Write failures never reach callers: they open a coverage gap that is reported and then recorded at the next write. A gap is written before the stop marker; if that fails, no stop marker is written. Deliveries name stored state, so the store syncs once per round before the first such write (withCommittedState). Persisted keys are JSON arrays, because Chronicle refuses NUL bytes in record payloads. - context-receipts/evidence.ts: request-owned, frozen evidence of the channel bodies a prepared request carries, from CM provenance and stored metadata.inboundSource (Petra's eb2a588). Version basis by lane contract: event (push/event, RFC-006 coalesced), message-digest (platform id + body digest), stored-copy (identity unknown). Notices (deleted, reaction, deferred) are not bodies. - context-receipts/index.ts: ContextReceipts confirms per membrane round report (UsageEvent.round). Refusals confirm nothing. Established fidelity: complete and unaltered bodies are delivered; partial or altered bodies are partial exposures. Unknown fidelity: neither, and the body stays eligible later. Injected batches count up to the applied prefix. The first standing round calls ContextManager.acceptRound (fold receipts). A missing round report is noticed and surfaced. - agent.ts: prepareActivationRequest returns the request plus evidence (buildActivationRequest delegates). Sanitize keeps a compiled-to-request index map. StartStreamResult carries the evidence. - framework.ts: ledger plus receipts constructed with the store (store id from CM storeIdentity). The acceptance observer feeds received. Streams begin/usage/end. Mid-turn injected bodies form batches (their stored ids are now captured). The ledger stops before the final sync. - channel_list: data becomes {channels, receiptClocks}, with per-channel clocks for the calling resident and the scope (store id, agent, trackingSince, dedupHorizon, gaps, degraded, roundReports availability). The tool description states the exact meanings and limits. - history--folds (modules/history/folds.ts): newest-first receipts with one readable line per changed run (or per baseline run), and the strategy's folding sentence ("never folds" / "never summarizes" / not reporting). - test/context-receipts.test.ts: 15 unit tests (ledger semantics, restart, unclean stop, gap on failed write, failed gap write at stop, dedup horizon, checkpoints; round confirmation incl. refusal, unknown fidelity, alteration, injected prefixes, missing reports; version basis; evidence mapping). Still to do: framework-level fixture (received advancing while delivered stays old during a delivery failure, then advancing), CM/membrane dependency floors, changelog fragment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…led round; history--folds export status hook - test/context-receipts-framework.test.ts: a real MCPL child (speech-route fixture) and a scripted provider. An addressed channel item is received at acceptance. A provider stream that fails delivers nothing: received is m-1 and delivered is null. The next round that stands (round report, established fidelity) delivers: delivered is m-2 with basis message-digest. channel_list's receiptClocks names the agent and store, and is not degraded. history--folds then shows the baseline that the same round's acceptance wrote. - HistoryModule.setFoldExportStatus(provider): a host projecting fold receipts to a file (connectome-host folds.jsonl) reports its export state, and history--folds includes it as `export`, conflicts and target path included. Full AF suite on the previous head: 1329/1331. The two failures are pre-existing and timing-bound on this machine, not caused by this branch. Measured against a baseline worktree at 55d6f8e with registry CM 0.13.0: - R17 (storage outage backoff) fails 4 of 6 runs on the baseline, 5 of 6 on this branch; - vector 34c's 30 s wall-clock bound takes 30.4 s on the baseline and 30.1–30.2 s here, at load ~13. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hange Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s, gate-denied wakes, mid-turn and XML injection, replay, edits Found by the edit-during-inference test: content read back from the store has its object keys in a different order (Chronicle round-trip) than the content as handed over. A mid-turn injection (hashed from the deferred message's content) and the stored copy a later compile carries therefore hashed differently, so the message-digest basis would have counted one body twice and refreshed the clock with an old message. The digest now hashes canonical JSON, with keys sorted at every level and undefined values dropped. Fixtures (test/context-receipts-framework.test.ts, real MCPL child, scripted provider with round reports; the gate wakes on addressed items and DMs and skips ambient ones): - an ambient item is received but not delivered while it wakes nobody; the next round that stands carries and delivers it, alongside the addressed item that woke the resident; - a push/event DM is delivered on the event basis; - in a long tool-using turn, an item accepted while the turn is alive is received at acceptance and delivered by the round that carried its injection. The next turn's stored copy is the same version, with one dlv entry; - XML path: an injection the round didn't carry (applied 0) is not delivered until a later compile carries it. Unit (test/context-receipts.test.ts): a replayed copy of a delivered version in the same round is recorded once; Agent.prepareActivationRequest's evidence names the body as prepared after an editMessage; key order doesn't change a version. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ds folding sentence Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ity) Hugo's review (room-220 #43187) and Tessa (#43237): the bounded memory's acceptance-time horizon refused genuine first deliveries. A body held while more than 8192 others reached the resident would never count, which mistakes age for prior exposure. Equal acceptance times also collided at the horizon. Now every delivered and partially exposed version is remembered exactly, per resident, as a 64-bit digest of its [basis, key]. The sets ride in each checkpoint packed into one base64 string, and are rebuilt into memory (Set<string>) at load. Eligibility is set membership only: a version counts when it first reaches the resident at any age, and never again. Delivery removes a version from the partial set. Checkpoints happen every max(4000, rememberedKeys/4) entries, so total checkpoint storage stays linear in deliveries. Snapshot schema v2; a v1 snapshot (never released) is ignored. ClockScope loses dedupHorizon. channel_list's description and the changelog now say every delivered version is remembered. Test: 50 newer versions are delivered, then a body accepted long before all of them arrives; it counts. Across checkpoints and a restart, none of them counts again. An equal acceptance time is no collision. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…SHA-256 identity, durable markers, retryable acceptance, auxiliary bodies Hugo's review (room-220 #43359, #43446, #43509, #43608): - A version is delivered complete only when every fragment carrying it in the round arrived complete and unaltered. Fragments are split messages, shards, or the same version compiled and injected; each is grouped by its canonical [basis, key] identity first. Before, each fragment was judged on its own, so an altered second fragment still let the first deliver. - Injected batches keep the whole batch's size, which is the producer's coordinate space. Before, `applied` was clamped to the number of body evidences, so a notice before a body hid the body. - Dedup identity is the full SHA-256 of [basis, key] (64 hex chars, packed as 32 bytes) per #41375. The 64-bit truncation is gone. - start, stop and gap entries are appended durable (sync after append): they decide what the next start may claim about this run. - Fold acceptance is marked done only after acceptRound returns. A failed or uncertain write is retried at the stream's next round; CM finds it already accepted if it landed. - Receipts carry `presentation` to acceptRound: verbatim (fidelity established, no compiled message altered), altered, or unknown. - Evidence resolves bodies from CompileResult.rawSources, the compile's own view, instead of getMessage. Bodies a reader compiled from an auxiliary slot (another namespace's messages) were missing from evidence. This also binds evidence to what the compile held. - channel_list: lastPartialAt is described as history: the latest first partial arrival. A later complete arrival shows in lastDeliveredAt. Tests: sparse batch coordinates; a split body with an altered fragment is not delivered; an acceptance failure is retried and the presentation recorded (altered, unknown); an auxiliary-slot body is in the reader's evidence and delivered to the reader only. Receipt suites 23 + 5. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eported presentation (verbatim/altered/unknown); exact counts 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>
…livered when any one copy arrived whole Hugo (room-220 #43976): - An append that landed but reported failure left the in-memory dedup sets stale. The next delivered()/partial() for the same version checked membership before write() reconciled, so a second dlv was recorded and the clock refreshed. Both now reconcile first: they replay the journal when it needs it, then decide. A replay that fails records nothing, and the open gap covers it. Test: a dlv append lands then throws; the retry at a later time records nothing; one dlv, clock at the first time. - Copies and fragments are distinct. A copy (one stored message, or one injected message) is complete only when all of its fragments arrived complete and unaltered. A version is delivered when ANY copy was complete, and is a partial exposure otherwise (reasons unioned). Test: an old partial copy and a fresh complete copy of one version deliver. The all-fragments regression (one altered fragment of a split copy) still holds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In one full-suite run the fixture reported asynchronous activity after a
test ended: an MCPL event arrived after stop() had closed the queue
("Queue is closed"), which is a framework stop-ordering race that exists
independently of this branch. The teardown now waits for an empty queue and
an idle resident, plus a short settle, before stopping.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…im presentation and completeness Hugo (room-220 #44042): AF's request preparation changes the compile before membrane sees it. It drops whitespace-only text (and with it a message that held nothing else), and the tool-result guard substitutes guarded results. Before this change a producer report of verbatim carriage became presentation 'verbatim' for a compile AF itself had altered. prepareActivationRequest now records the request indices it changed: a non-'' text block removed (an empty '' block carries nothing), or a block the guard replaced. It also records whether it dropped a compiled message that held content. startStreamWithInjections records the second (recording) guard pass the same way. withPreparation() marks bodies at changed indices incomplete (missing 'preparation') and sets evidence.preparationAltered. Presentation is 'altered' when preparation or the producer altered anything, 'verbatim' only with established fidelity and no known alteration, and 'unknown' otherwise. Test: real CM and Agent. A whitespace-only message is dropped, and a body whose whitespace block is removed is incomplete. A producer report saying nothing changed still yields presentation 'altered' and no delivery. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…omplete Hugo (room-220 #44222): one copy compiled into two messages, with preparation dropping one (requestIndexOf -1). Before, only the aggregate presentation was marked and the surviving fragment stayed complete, so the copy could deliver from its survivor. requestEvidence now collects the copies with a dropped fragment from the pre-filter provenance and marks their surviving fragments incomplete (missing 'preparation'). A copy dropped entirely still leaves no evidence; it was never an exposure. Test: [-1, 0] gives one body at index 0, incomplete with 'preparation'; [-1, -1] gives no bodies. 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>
…ane's RoundReport type Hugo's lane review asked for the producer's own behavior across the package boundary, not synthetic reports. test/context-receipts-membrane.test.ts drives the framework with the installed @animalabs/membrane. It builds every request and emits its own UsageEvent.round; only the provider adapter is scripted (declaring reportsContentAlterations, as the Anthropic adapter does). Four cases: - native: a verbatim round delivers the body; the fold baseline's presentation is 'verbatim'; roundReportsMissing stays false. - native: membrane substitutes a placeholder for an image/svg+xml body image. The report names that message, so the body is a partial exposure (wire-alteration) and the presentation is 'altered'. - native: an item injected during a tool round is carried by membrane's next round (injectedBatch applied 1) and delivered. - xml: membrane reports applied 0, so the injection isn't delivered until the next turn's compile carries it. The speech-route fixture takes optional `content` blocks for incoming items. AF now imports RoundReport from @animalabs/membrane, one definition, and re-exports it. framework.ts reads event.round without a cast. The synthetic reports in the other suites carry the now-required fields. Requires a membrane release with round reports (membrane a35b406e, packed locally here) and CM with accepted-round provenance; package.json moves to those releases when they're published. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dy losing whitespace is partial With membrane 40205819, Anthropic's empty-text cleanup reports whitespace-only blocks it removes, attributed to the injected message that held them. A body injected mid-turn as [text 'also this', text ' '] is carried without its whitespace block, so the receipt clocks record a partial exposure (wire-alteration), not a delivery. This is the answer preparation gives for the same body compiled (Hugo, room-220 #45131 point 3). The adapter here is the shipped AnthropicAdapter's request builder, and only the network response is scripted. AF builds against membrane's RoundUsage (a round's counts present only when the provider reported them) without changes. 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>
…rnal's writes until the store is reopened Chronicle's append (v0.4.0, src/records/log.rs and src/store.rs) assigns the record's id, writes the record to its log and advances the file size, syncs the log every sync_interval writes, and only then adds the record to the index that getRecordIdsByType reads. When that periodic sync fails, the append throws with the record in the log but not in this process's view of the store. RecordJournal treated an append that threw like a failed barrier: load() cleared the unreconciled state, but the record wasn't in what load() read, so a later checkpoint covered it, and the reopened store replayed it as covered, never reduced. Nothing in the store's JS API rereads the log short of reopening the store, so an append that throws now leaves the journal unwritable for the rest of its life: every append and checkpoint throws, saying nothing more is written until the store is reopened, as at the next start. A journal on the reopened store loads that record with its tail, for the caller to reduce. needsReconcile stays false for it, since no load() resolves it. A failure after the append returned (a durable barrier, or reading the id it returned) is resolved by load() as before, and a failure before the record reaches the store (the afterCommittedState sync) still leaves the journal usable. Tests (record-journal, 10): - an append that throws after writing its record, modelled as Chronicle's failed periodic sync (the record hidden from this view of the store until it's reopened), blocks append and checkpoint, before and after load(); a journal on the reopened store loads it and writes again; an append that throws before writing anything blocks the same way; - a durable checkpoint whose barrier throws blocks writes until load() replays it (split out of the old combined test). Resolving an append that throws by load(), as before, fails the first. A native probe outside the suite injected one EIO at the log's periodic fsync (an LD_PRELOAD shim) on Linux, Node 24.21.0, Chronicle 0.4.0. The 100th append threw with its record in the log. The journal then refused the next append and checkpoint, and after a reopen the tail held all 100 entries, the failed one included. With the old rule, the next checkpoint covered it.
… inside the store Since the previous commit, an append that throws inside the store leaves the journal unwritable for the rest of its life, with needsReconcile false, since no load() on that store can read the append's record back. A caller that decides from what load() returns needs to know that its view may lack that record: needsReopen is true from then on, until a journal on the reopened store has loaded it. needsReconcile keeps its meaning, a failure load() resolves. The getter and its two assertions are the same lines the workspace's disk-agreement change adds to RecordJournal, so the two branches carry identical text and merge cleanly in either order. Tests (record-journal, 10): needsReopen is true after an append that threw inside the store, and false after a durable checkpoint's barrier failed.
…ppend that throws inside the store The shared RecordJournal now treats a throw from the store's append as possibly having written a record outside this process's view (Chronicle 0.4.0 writes a record, syncs its log every so often, and indexes it only then), and refuses every later append and checkpoint for the rest of the journal's life. The ledger needed no code change to stay correct under it: its writes are refused, its gap stays open, stop() writes no stop marker or checkpoint, and the next start reads the record and reports the run as an unclean stop. Before the rule, a delivery whose append wrote and then threw could be recorded again here, since a reread can't see the first. The header says which failures recover in this process (before the record reaches the store, or after it at a durable barrier) and which wait for the next start. Tests: the five recovery tests modelled every failure as the store's append throwing, which the rule now treats as needing a reopen. - A failed write's gap, recorded at the next write: now a sync that fails before a delivery's write, so nothing reached the store; the version that failed to record counts when it next arrives. - New: an append that writes and then throws: nothing more is written (no entry, gap, stop marker or checkpoint), the scope stays degraded, and the next start reads the record and reports an unclean stop. - A delivery that landed and then threw: not written again in this process, and read once at the next start. - The three reconciliation tests (a read that fails part-way keeps memory; recovering through a gap; staying unreadable when recovery's own read fails) now make the landed record's durability barrier fail, which leaves it in the store's view, so the journal is reread in this process as they intend. The first of them reaches its durable write through a gap opened by a failure before the store.
… gaps it drops, and opens no gap for a failed checkpoint - A checkpoint is checked entry by entry: each channel's received stamp, each resident's delivered and partial stamps (an object with a time and a source), each gap, and the new gap count. One this version wouldn't write makes the ledger unreadable at the read, never a TypeError later in clocksFor or channel_list. - An entry kind this version doesn't write makes the read fail, as another version's checkpoint does. Reducing on past it would skip what it records, such as a delivery that a later round would then count again. - The newest 20 gaps are listed, as before, and `gapsOmitted` (in the snapshot and the scope) counts the older ones, so an interval that fell off the list isn't read as covered. - A checkpoint that fails loses no observation yet: every entry it would cover landed and was reduced. It no longer opens a coverage gap and marks the ledger degraded; it's logged. Its append threw inside the store, so the journal takes no further writes this run, and the next entry's refused write opens the gap. (Before, the gap opened at once, and every later write read the whole journal and tried the checkpoint again.)
…exposure A version none of whose copies arrived complete is recorded as a partial exposure, except when every reason given is that the copy's fidelity can't be checked: such a copy confirms nothing and shows no loss, so the version stays unconfirmed. The test was `every()` over the reasons, which holds for an empty set, so an incomplete copy that gave no reason at all was skipped as if unconfirmed. It lost something, so it is now a partial exposure, recorded with the reason `unstated`.
…ides, and a late stream end never clears a successor's state - The tool-results handler hands its mid-turn injections to the stream it captured with the agent's state, before its awaits, but recorded the injection batch under the agent's stream id read after them. If the agent's stream moved meanwhile, the batch landed in the new stream's receipt state and shifted its batch numbering. The handler now captures the stream id with the state and records under it. - driveStream's finally ended the receipt state for (agent name, stream id) unconditionally. A same-named successor (an ephemeral disposed and spawned again) can have begun a stream with the same id, and the late finally would clear it. Following the convention disposal already follows for name-keyed state, the finally now ends it only while no other agent holds the name. - The inbound observer's field comment said it was unset unless a receipt consumer installed one; the constructor always installs the clocks' observer, so every deployment records each accepted channel item.
…aning comes with them, and its result has one shape
- channel_list's tool description carried the clocks' full semantics,
about 600 tokens on every request of every resident with channel tools.
It now says in two sentences what the clocks are and that the result
explains them. The explanation (RECEIPT_CLOCKS_ABOUT) comes in the result,
as receiptClocks.about, so it costs tokens only when the tool is called.
- The explanation now also says that a channel is keyed by its binding, so
a server reconfigured to another endpoint or command starts a separate
history, as a session switch does; that gaps lists the most recent 20,
with gapsOmitted counting older ones; and that a run whose ledger never
became readable recorded nothing, so a later run can't list it.
- Without the clocks wired, channel_list answered a bare array, and with
them { channels, receiptClocks }. It now always answers { channels },
with receiptClocks and each channel's clocks beside them when wired.
…p the evidence import with the others - startStreamWithInjections ran the tool-result guard a second time, to record the submission, and recorded the positions it swapped again. Preparation had already swapped them and recorded where, and on messages already swapped the second call changes nothing the evidence doesn't hold, so the re-report is gone and the comment says what the call is for. - The receipts evidence import sat after the module's interface declarations; it now sits with the imports above them.
…s answer in the answer, and gets read access only - The tool description carried the record's full semantics on every request of every history-module agent. It now says in two sentences what the record is and how to page it. The explanation (FOLDS_ABOUT) comes in each answer as `about`. - The explanation now says what each answer discloses: `source` names the record read (runtime, store, agent, namespace, data directory, branch), and a host that exports the record shows the export's status and target under `export`. It says that a change's runs give estimated tokens before and after while a baseline's give their one estimate; that the usage is the round's input and cache usage; that `altered` means some of the compiled messages weren't carried verbatim; and that `folding` describes the current strategy while each receipt names the strategy that wrote it. - The folding sentence says a strategy "can leave spans out" only when its rendered forms include omitted. - ModuleContext.getAgentContextManager hands a module the agent's own context manager typed to the two reads a fold reader needs (AgentFoldReader: describeRenderedForms, listFoldReceipts), not the whole mutable manager. It is the same object, so history--folds' identity check against its bound manager, which decides whether to show the export, still holds. The API is new in this change, so narrowing it costs no caller anything.
- Every deployment keeps a clock ledger, with one small entry per accepted
channel item in the store beside the item, and one per first delivery or
partial exposure.
- Gaps: the most recent 20 are listed and gapsOmitted counts the rest; a
channel is keyed by its binding, so a reconfigured server starts a
separate history; a run whose ledger never became readable records
nothing; another version's entry or checkpoint makes the ledger
unreadable.
- A ledger write that fails inside the store stops its writes until the host
restarts, and the next start reports the run as unclean.
- channel_list's description stays short, with the clocks' meaning in its
result, though its tool block still changes once; its result is always
{ channels }, with receiptClocks beside them when wired.
- history--folds: read through AgentFoldReader; the round's input and cache
usage, not "the whole provider usage"; each answer names its source and
explains its fields; every history-module agent gets the tool, whatever
its strategy, and a strategy that keeps no receipts gets an answer saying
why the record is empty.
- A fold receipt's presentation reflects alterations of the compiled
messages; an injected body's alteration affects the clocks only.
- references to the authoring team's tracker removed from the fragment.
…is repository can't follow This change's comments and test titles cited the authoring team's own tracker items, conversations and reviewers by number and name, which readers of this repository can't follow. Each comment keeps its sentence without the citation; the stamps test's header says what it covers, and that the source-header composition isn't covered here; test titles keep their claims, and one keeps "(a control)". Citations of the same kind that main already carries are left as they are.
…unknown fidelity confirms no partial exposure Ledger: - A checkpoint whose channel or resident entries this version wouldn't write (a delivered stamp that isn't an object, a resident without partial stamps, a channel without its received stamp, a gap without bounds, no gap count) is unreadable: no clocks, no TypeError, nothing delivered again, nothing written over it. - An entry kind this version doesn't write makes the journal unreadable, and nothing is written past it. - 23 unclean runs leave the newest 20 gaps listed and 3 counted in gapsOmitted, read back after a restart. - A checkpoint that fails opens no gap while every entry has landed; the journal then refuses the next entry, which opens the gap from its time, and the next start reads the entries before the failed checkpoint. Receipts: - `delivered()` reads the ledger's exact dedup set, and the injected-batch test asserts the withheld body isn't delivered after the first round. - A producer-altered injected body is read by batch and index with an asymmetric pair: batch 0, index 1 altered; body 0 delivered, body 1 a partial exposure from wire-alteration. - A round with two deliveries syncs the store once, before the first delivery's append. - Each delivery's branch stamp is the branch its compile names, and "unknown" when the evidence has no provenance. - A stream that ends without a usage event delivers and accepts nothing. Under unknown fidelity, an incomplete body and one membrane names as altered record no partial exposure. - The altered-and-incomplete test asserts each version's own partial entry (m1 wire-alteration, m2 content), not the channel's latest. - An incomplete copy that states no reason records a partial exposure with "unstated". - A replayed copy carried in a later round than the delivery is recorded once. - roundFidelity.since is the first counted round's time, with the clock injected. - A prepared round confirmed after its body was edited delivers the version as prepared, under its pre-edit key, and not the edited copy. history--folds: - A change receipt renders one line per changed run, with a summary's level, id and method and the omitted form; its round usage; the strategy that wrote it; the record's source; and `about`. - The folding sentence says "can leave spans out" only for a strategy that renders omitted spans. The comment at the round's confirmation says why unknown fidelity confirms no partial exposure while a named alteration still marks the presentation altered: one claim is negative and the alteration proves it; the other is positive, and such a round may have dropped the body altogether.
…ng adapter through the real membrane, and channel_list's one shape
- The disposed-agent test waited fixed sleeps. It now records, where the
framework resumes with the prepared request, that the agent was already
deregistered (so the guard is reached), then waits for the provider
stream to start and for the framework to cancel it, before asserting
that no receipt state is left.
- The test of an item that wakes nobody kept its pause, and now also
asserts that the first provider request carries the addressed item: a
wake on the ambient item would have sent one without it.
- A membrane that sends usage without a round report: channel_list's scope
says "unavailable: this membrane does not report provider rounds, so
nothing is confirmed delivered", the body isn't delivered, and
receiptClocks.about carries what the clocks mean.
- Through the real membrane, an adapter that doesn't report what it leaves
out: membrane reports fidelity unknown, the body is neither delivered nor
a partial exposure, the scope's roundFidelity line counts the round, and
the fold receipt's presentation is unknown.
- channel_list on a registry without receipt clocks answers { channels },
with no receiptClocks and no per-channel clocks.
… none counted Earlier checkpoints of this format carried no gapsOmitted, and requiring it made such a checkpoint read as another version's: the ledger stayed unreadable and wrote nothing for good. Nothing released wrote one, but a test deployment of an earlier head of this change could hold one, and the field's absence has a plain reading. An absent count now reads as 0; a count that isn't a number still makes the checkpoint unreadable. The entries the readers dereference are checked as before. Tests: a checkpoint with gapsOmitted removed reads, with 0 counted and its dedup sets intact; the damaged-checkpoint case for the count is now one that isn't a number.
|
@slimepriestess Pushed — Ada-1017 |
|
The releases this PR was waiting for are out: context-manager 0.15.0 (Oct 16) carries anima-research/context-manager#146, and membrane 0.6.0 (Oct 18) carries antra-tess/membrane#104. agent-framework's Written by Oscar-1005. — Lottie-2661 |
Problem
A resident can't tell when a particular channel's message actually reached it.
gate_statusshows when a wake policy matched, andchannel_listshows a channel's identity and transport.So "it never arrived", "it arrived but I haven't been woken for it", and "I saw only part of it" all look the same. Folds are just as invisible: a resident can't ask when, or how, its history was summarized.
This PR adds both records, built from what a confirmed provider round actually carried.
Changes
Per-channel receipt clocks in
channel_listchannel_listreturns{ channels }, withreceiptClocksbeside them andclockson each channel when the clocks are wired, as they are in the framework:lastReceivedAt: when this host accepted an item from the channel (including notices and deferred work), with the item's source message id and source timestamp, so a delayed message can be told from a fresh one.lastDeliveredAt: when a provider round that stood (not a refusal) last carried a new raw body from the channel to the calling resident, complete and unaltered.lastPartialAt: a body that first reached the resident only partially.receiptClocks.aboutin the result, so it costs tokens when the tool is called rather than on every request: diagnostics, not a claim that the resident chose to ignore anyone, and null means "no observation since tracking began", not silence.Agent.prepareActivationRequest,StartStreamResult.evidence). An edit made during inference can't be attributed to the body that was sent.UsageEvent.round, feat(streaming): round reports on yielding streams; formatters and adapters report what they don't carry verbatim antra-tess/membrane#104), never from cumulative events.receiptClocks.roundFidelitycounts such rounds for the calling resident since this process began, whenever there are any: "unknown on N of M provider rounds since … (this process): those rounds can't confirm what they carried, so a delivery there is unconfirmed, not absent". On a path that can never confirm, a resident can tell that from silence (1d7e943, from slimepriestess's approving review).metadata.sourceBodyDigest, from feat(mcpl): stamp each inbound item's delivered-body and stored-copy digests #252). Otherwise, the stored copy. Each delivery names its basis, and showing an already-delivered version again never moves a clock.storedBodyDigest(from feat(mcpl): stamp each inbound item's delivered-body and stored-copy digests #252).shardCount, from feat: compile provenance, rendered layouts and fold receipts at accepted rounds context-manager#146), with every declared shard carried.RecordJournal, so a delivery survives/undo.receiptClocksnames its scope (store id, agent),trackingSince, and coverage gaps. A gap is an unclean previous run or a failed ledger write; a failed write never blocks delivery. The most recent 20 gaps are listed, andgapsOmittedcounts older ones.Fold receipts and
history--foldsContextManager.acceptRound(context-manager#146). It passes the round's own usage, and its presentation:alteredwhen request preparation or the producer reported an alteration of the compiled messages (an injected body's alteration affects the clocks only);verbatimwhen fidelity is established;unknownotherwise.history--folds {afterId?, since?, limit?, branch?}shows the resident's fold record, newest first, or withafterId(a receipt id) oldest first from just after it, so the resident pages forward (moreandnextsay whether and where to continue;sinceis an ISO 8601 time): one readable line per changed or baseline run, the cause, the round's input and cache usage (the whole round's, never presented as a fold's cost), the strategy that wrote each receipt, and a sentence naming how the current strategy renders history. Each answer names the record it read (runtime, store, agent, namespace, data directory, branch) and explains its fields (about), so the tool's description stays short. Every agent with the history module gets the tool. Nothing is injected into context.HistoryModule.setFoldExportStatus.Review follow-up (Greptile's first review; Fern-1769 implemented it, Hazel-1768 reviewed it)
history--foldsanswers for the agent that calls it, resolved throughModuleContext.getAgentContextManager, which hands a module that agent's own context manager typed to the two reads a fold reader needs (AgentFoldReader). The export status stays with the resident whose record is projected.lastPartialAtis history.Integration with main (through #252's
88a24e6)prepareActivationRequesttakes main'sActivationRequestOptions(the silent-heartbeat tick a request answers), and both the stream path andbuildActivationRequestpass it through.separateSilentHeartbeatTicksinserts a request-only turn before a tick's reply, and every later message moves by one. A compiled message's request index, and the positions request preparation altered, now move with them. Without that, an alteration a round reported at a body's position would be attributed to the message before it, and the body confirmed as delivered.Review round (
06b7900..a0a390b), from an automated review of this PR's own commits (60 issues judged, 36 fixed here):29061f3,67135c2, identical to fix(workspace): listings tell the truth about disk, drafts and conflicts #248's): an append that throws inside the store blocks the journal's writes until the store is reopened. The ledger needed no code change for it (d39b146), and five of its failure tests now model each failure where it happens.7579eee,a0a390b): a checkpoint is checked entry by entry, so one this version wouldn't write is unreadable at the read rather than aTypeErrorlater; an unknown entry kind is unreadable;gapsOmitted, read as 0 from a checkpoint written before it existed; and a checkpoint that fails opens no gap, since every entry it would cover landed.8bc2a3f,408c7cd): an incomplete copy that states no reason is a partial exposure (recorded asunstated), not skipped as unverifiable. A mid-turn injection is recorded under the stream it rides. A late stream end clears receipt state only while no other agent holds the name.e87f66e,9058c63):channel_listandhistory--foldskeep short descriptions, and each result explains itself (about).channel_listalways answers{ channels }.getAgentContextManagerreturnsAgentFoldReader, the same object.81a2ea9,30e223b,6c78a95): a redundant second record of the guard's swaps is gone; the fragments say what changed; comments and test titles no longer cite the authoring team's tracker.e657f7a,ae9ef52): listed under Tests.Changelog fragments:
changelog.d/context-receipts.added.md, andchangelog.d/context-receipts.changed.mdfor thechannel_listshape change. No code in this repository or in connectome-host parsed the old bare array.Companion PRs and merge order
Stacked on feat(mcpl): stamp each inbound item's delivered-body and stored-copy digests #252 (ingestion stamps, now at
88a24e6), which is stacked on feat(drafts): hold suppressed plain speech as resendable drafts (with structured publish outcomes) #247 (now at1d6d2bb, which brings in main02adf27). Merge order: feat(drafts): hold suppressed plain speech as resendable drafts (with structured publish outcomes) #247, then feat(mcpl): stamp each inbound item's delivered-body and stored-copy digests #252, then this. Please review only this PR's own commits; the rest is feat(drafts): hold suppressed plain speech as resendable drafts (with structured publish outcomes) #247's, feat(mcpl): stamp each inbound item's delivered-body and stored-copy digests #252's and main's (git log 88a24e6..HEAD).Draft until its dependencies are released. This needs:
shardCount).When those publish,
package.json's floors rise in a follow-up commit here. Until then, CI typechecks against the released packages, which lack these types, and can't pass.Compositions tested locally, at the lane's earlier head
13b3066, before the Greptile follow-up:connectome-host's guarded
folds.jsonlexport (the host side of fold receipts) follows a release of this, as its own PR.Tests
Head
a0a390b, the review round, against packs of membrane #104 at5d19fe75and context-manager #146 at086d57c0:ae9ef52, onenode --test --test-force-exitper file, typecheck clean: 1553/1553 across 135 files on Node 20.20.2, and 1553/1553 on Node 24.21.0, none cut. The breaker and history files pass unforced too.a0a390bthen reads a checkpoint without the gap count as none counted, andcontext-receiptspasses 52/52 there.context-receipts52 (51 cases, one run for both lanes),context-receipts-framework10,context-receipts-membrane7,context-receipts-stamps7: 76 in all, where06b7900had 63 (41, 9, 6 and 7, not the 59 counted below).record-journalhas 10 andchannel-registry-routing25.unknownwithout provenance; a stream that never reports, and unknown fidelity with an incomplete and an altered body; each version's own partial entry; an incomplete copy with no stated reason; a replay carried in a later round;roundFidelity.sinceunder an injected clock; a prepared round confirmed after its body was edited; a change receipt's rendered lines; a membrane without round reports, through the framework; an adapter that doesn't report what it leaves out, through the real membrane; andchannel_list's shape without clocks. The disposed-agent and wakes-nobody tests now wait on what they assert.channel_list's shape), and each fails its test.finally's check for a successor. Each needs a stream to move during the handler's awaits, or a successor to begin a same-numbered stream before the old frame'sfinallyruns, which a test can't hold without a hook into the framework. Both are reasoned from the code.— Ada-1017 (Claude Opus 5.5), the house's agent-framework owner: the review round
29061f3..a0a390b, reviewed by Oscar-1005 (Claude Opus 5.5).Head
06b7900. After slimepriestess's approval atd29e7d8,1d7e943adds theroundFidelityline. Oscar-1005 wrote it, and Basil-1007's review set its wording.06b7900names the line in the changelog fragment. I checked1d7e943as its own change, against packs of membrane #104 at5d19fe75and context-manager #146 at086d57c0, as its own runs were.context-receiptsand the scope line through the framework, fail ond29e7d8's source.roundFidelity()returns, rather than its own map entry, has no test. Its one caller only reads it.node --test --test-force-exitper file): on Node 20.20.2, 1538 / 1538 across 135 files. Node 24.21 counted 1527, because force-exit cuthistory-module-uxto 21 of 26 andmcpl-tool-lifecycleto 25 of 31; alone and unforced they pass 26 / 26 and 31 / 31, so it's also 1538 / 1538.— Ada-1017 (Claude Opus 5.5), as the house's agent-framework owner: this paragraph, the
roundFidelityline under Changes, and the fragment's sentence.005757ffollows context-manager #146's rework for its maintainer review (#146 at086d57c0): evidence readsrawBodies(a body is one ingestion, so the same text posted twice is two bodies),EvidenceInputsno longer takesgroupMembers(nothing read a sharded copy's contents), andhistory--foldstakesafterIdpages. Qualified against packs of membrane #104 at5d19fe75and context-manager #146 at10fbb65a(the tree of2838fb29), one unforcednode --testper file: 135 files, 1536 pass, 1 fail, the failure main'sconversation-routingshutdown race (#263 fixes it). Basil reviewed the delta.Earlier runs below used
node --test --test-force-exit --test-concurrency=2 --test-timeout=120000 dist/test/*.test.js, against packs of membrane #104 atd41ae602and context-manager #146 atef6ea3d6, the PRs' reviewed heads then.7a95f06, with feat(mcpl): stamp each inbound item's delivered-body and stored-copy digests #252's88a24e6and so main02adf27merged), at a machine load around 20–25: 1525 pass / 2 fail of 1527. Both failures aremcpl-coalescingtiming cases this PR doesn't touch: 34c's wall-clock bound (43 s), not attributed to a cause, and R17, the known race whose test-only fix is test(coalescing): R17 observes recovery transitions, not drains #254 (not yet on main). Atbde230d(feat(mcpl): stamp each inbound item's delivered-body and stored-copy digests #252'sd36a5ad, before main), load around 15, it was 1446 pass / 1 fail (34c) of 1447.711b400), same command and packs, under heavier load: 1384 pass / 2 fail / 1 cancelled of 1387. All three are timing-dependent cases inmcpl-coalescing: 34c's wall-clock bound, R16 cancelled at the 120 s timeout, and R17's retry count. R17 is a race between real timers andrunUntilIdle, and its test-only fix is test(coalescing): R17 observes recovery transitions, not drains #254. feat(mcpl): stamp each inbound item's delivered-body and stored-copy digests #252 has since moved tod36a5ad; its reviewer's full run there was 1351/1353, plus code-execution 36/36, with the failures in the samemcpl-coalescingtiming cases.test/context-receipts.test.ts(39):/undokeeping a delivery; checkpoints;history--folds' folding sentence;test/context-receipts-framework.test.ts(8), through the framework with a scripted membrane:history--folds, and a caller it can't resolve is refused;test/context-receipts-membrane.test.ts(6), end to end with the real membrane producer and a real MCPL child:test/context-receipts-stamps.test.ts(6), over feat(mcpl): stamp each inbound item's delivered-body and stored-copy digests #252's stamps and a context-manager strategy that can shard or cut:content;shards;edited.ef6ea3d6with membraned41ae602, and repeated the nested-recovery probe. Fern's full run on that pair, before feat(mcpl): stamp each inbound item's delivered-body and stored-copy digests #252's follow-up was merged, was 1445/1445.Not verified
finally's successor check, as Tests says.changelog.d/context-receipts.added.md,changelog.d/context-receipts.changed.md🤖 Authored by Oscar (Oscar-1005, Claude Opus 5.5), independently reviewed by Hugo (Hugo-1004, GPT-6 Astra). Greptile follow-up by Fern (Fern-1769, Claude Opus 5.5), independently reviewed by Hazel (Hazel-1768, GPT-6 Astra). The ingestion-stamp seam was agreed with Petra (Petra-1012, Claude Opus 5.5) and Tessa (Tessa-974, GPT-6 Astra). Through the Rookery's Pi tooling. The names in parentheses are Rookery colleague references; the house shares one GitHub account.