Skip to content

feat(mcpl): stamp each inbound item's delivered-body and stored-copy digests - #252

Merged
antra-tess merged 11 commits into
anima-research:mainfrom
nissa-seru:feat/ingestion-stamps
Oct 21, 2026
Merged

antra-tess merged 11 commits into
anima-research:mainfrom
nissa-seru:feat/ingestion-stamps

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

Stacked on #247. The branch contains #247's commits until it merges. Please review only this PR's seven commits, 6c927de, 711b400, d36a5ad, a2accc1, d981e49, 97d860d and 7ce228e (the merges bring #247's), and merge after #247.

Problem

A consumer that wants to know which delivered body reached the model needs two things for every inbound item:

  • a stable version identity for the body as delivered;
  • a way to tell whether a stored copy still holds that body.

Stored message blocks can't provide either. The host decorates bodies before storing them: the closed-channel invitation does today, and source headers follow. A copy can also be changed after delivery through editMessage, which keeps the message's metadata.

Changes

  • One digest implementation, exported from the package root. sourceBodyDigest(blocks) is the SHA-256 hex of canonicalJson([blocks]), where canonicalJson sorts keys at every level, drops an undefined object entry and writes an undefined array element as null, as JSON does. That one-element framing is what an undecorated, unsharded stored copy already hashes to, so stamped and older copies of the same body share a version. Each block is hashed as the store keeps it (a2accc1): inline media in its stored form (any image whose source isn't a URL, and every document, audio or video block, with its base64 re-encoded from its bytes, an image's media type taken from its signature, and only type and source kept), and strings well-formed (a lone surrogate as U+FFFD, and an entry whose key has one dropped). So a body hashes alike as delivered, as handed to storage and as read back. canonicalJson stays internal to mcpl/inbound-source.ts: keeping serialization and framing inside sourceBodyDigest avoids consumers duplicating them.

  • metadata.sourceBodyDigest is stamped at ingestion on both MCPL lanes. It is taken from the body as delivered: after MCPL conversion, before any host decoration or storage sharding. Every stored MCPL item gets one: a visibly-empty item is refused at admission (main's fix(mcpl): reject visibly-empty push and channel content (no uncaused wakes) #236) and stores nothing. Console and API input, and the host's own notices, carry neither digest. It lives outside the frozen source envelope, because it describes the delivery (a materialization, a correction), not the admission.

  • metadata.storedBodyDigest is taken where each path stores, over exactly the blocks handed to storage, through one helper (withStoredDigest). The paths are:

    • the channels/incoming main path, after any closed-channel invitation is appended;
    • an RFC-006 correction delivered to a conversation fork;
    • conversation-router delivery;
    • push/event.

    An unsharded stored copy, read back with its blobs resolved, that no longer hashes to it was changed after delivery. A body the store shards (adaptive ingress chunking) carries the whole body's two digests on every shard, and no shard hashes to them. A blob_ref is digested as given, while the store resolves it on read, so a copy holding one doesn't match; the MCPL lanes never store one. Both fields are always the host's own: a value an adapter supplied is replaced. A silent heartbeat stores nothing and persists neither field.

  • Changelog fragment: changelog.d/ingestion-body-digests.added.md.

Neither digest proves later presence or completeness, and nothing here consumes them yet.

Tests

test/inbound-source.test.ts runs over a real MCPL child:

  • An undecorated incoming item: its two digests agree and equal the legacy unsharded hash, and they sit outside the envelope. An edit through ContextManager.editMessage keeps the metadata, so the stored digest no longer matches.
  • A DM pushed into a closed channel: the host's invitation is stored with it, so its stored digest covers the invitation, while its delivered digest is the adapter body alone.
  • A message accepted while its channel was open and processed after the channel closed: it is stored with the invitation, and its stored digest matches its own blocks. This case fails before 711b400, which moved the stored digest from ingestion to the storage sites.
  • A visibly-empty ordinary push, then an accepted push, both with forged digests in their origin: the empty push is refused at admission and stores nothing (main's fix(mcpl): reject visibly-empty push and channel content (no uncaused wakes) #236), and the accepted one is stored with the host's digests. d36a5ad's two empty-body tests became this one in the integration merge 88a24e6; before d36a5ad, the delivered digest came back as the adapter's value.
  • Images the store relabels or re-encodes, through the push lane: a PNG labelled image/jpeg, and PNG base64 broken by a newline, are stored as image/png in canonical base64. Each copy read back still matches its storedBodyDigest, and its sourceBodyDigest matches the image as stored.
  • The store contract, without an MCPL child: twenty-two bodies are written through a real ContextManager over a chronicle store and read back, live and after reopening. Each still hashes as it was handed over, including every way the store was seen to change a body, and the test records which bodies come back changed. Image bytes the store has no signature for (SVG, BMP, AVIF) are labelled image/png and expected back unchanged, so a context-manager that learns to sniff one fails the test. Changes the store keeps (other bytes, a label it doesn't replace) still change the digest.
  • These last two, and a check that a label the store replaces is not part of the digest, fail before a2accc1.
  • Every branch of the copied sniffer (d981e49, from slimepriestess's approving re-review): the store contract includes GIF87a bytes labelled image/png, GIF89a labelled image/jpeg and WebP labelled image/png. Each comes back relabelled and still hashes as handed over. Each was checked red, by removing its branch from the copied sniffer: without gif, the GIF87a row fails; with GIF87a alone, the GIF89a row fails; without webp, the WebP row fails. A context-manager that stopped relabelling one would fail that row's "changed" check.

The test file imports sourceBodyDigest from the package root, so the public export is typechecked.

npm test in one run, on the house's VPS (fresh install, clean dist, Node 24.21.0):

  • This branch at a2accc1: 1475 pass / 0 fail, every file complete.
  • This branch at 4c73d3f, its parent, under the same conditions: 1472 pass / 0 fail, every file complete.
  • Difference: the three new tests, compared by name.
  • This branch at d981e49 (a fresh build of its tree with an earlier wording of one test comment), run with --test-force-exit: 1425 pass / 0 fail, plus code-execution's 50 / 0 run separately. Together that's 1475, as a2accc1. The new rows sit inside the existing store-contract test.

Earlier runs, with npm test in two parts (everything but code-execution at concurrency 4, then code-execution alone):

Integration with main

88a24e6 merges #247's integration merge (1d6d2bb, which brings in main 02adf27) into this branch. Each reviewed commit keeps its hash.

  • There's no textual conflict, but one semantic seam. Main's fix(mcpl): reject visibly-empty push and channel content (no uncaused wakes) #236 now refuses visibly-empty content at admission:

    • on push/event, with -32602;
    • on channels/incoming, with empty_content.

    The exceptions are the exact silent-heartbeat marker, which stores no row, and an RFC-006 retraction, which appends nothing.

  • What that means here:

    • An empty ordinary push now never reaches storage and has no digests.
    • Every push that is accepted still gets the host's sourceBodyDigest and storedBodyDigest, written over anything its origin carried.
  • In the merge:

    • d36a5ad's two empty-body tests become one test showing that a visibly-empty push is refused and stores nothing, while an accepted push with the same forged origin digests carries the host's digests.
    • The other is a DM into a closed channel with forged origin digests. Its delivered body and its stored copy, invitation included, carry the host's digests.
    • The stamp's comment and the changelog fragment drop the empty-body claim.

Merge by Petra-1012. Basil-1007 reviewed it, including a check that d36a5ad's original tests fail only at those two cases against the merge.

4c73d3f then merges #247's later head 315d12c (which refuses a draft dismissal while that draft's attempt is out), without conflict: its remerge-diff is empty, and the prose-drafts, inbound-source, empty-content and silent-heartbeat files pass. Merge by Petra-1012, checked by Basil-1007.

After the house's review sweep at d981e49

The house's review workflow read this PR's own commits (sw-af252) and found 18 issues. My verdicts, each with its reasons, are in its ledger: 15 fix-now, 1 not real and 2 not worth changing. The round is 97d860d, with 7ce228e after its review.

  • The texts say what the code does:
    • the digests are on every stored MCPL item, not every inbound item;
    • canonicalJson mirrors the store's append (editMessage writes JSON instead);
    • the copy check is of an unsharded copy;
    • a blob_ref doesn't match its digest;
    • undefined array elements are null;
    • nothing here injects into a live turn.
  • New tests pin every storage site and the store's normalizations:
    • Forged adapter digests are replaced at the conversation router's site and at a fork correction's (a coalesced replacement following its prior into an unbound fork), and on the channels/incoming path.
    • A sharded body: a strategy's store splits a two-block push, and both shards carry the whole body's digests, with neither hashing to them. A one-block body isn't sharded and matches its own.
    • The RFC-006 materialized copy's digests are the host's, over the rendered body.
    • A channel message deferred mid-turn and stored at the boundary still hashes to its stored digest.
    • The store contract gains a video, an image without a media type, a text block with an undefined field, and a URL image with a stray data string, which is kept and digested as given. A document without a media type, and an image without one whose bytes have no signature, are refused at write. A blob_ref is resolved on read and doesn't match.
  • 7ce228e, after the review: the reviewer suggested a store-contract row for an undefined array element. That row showed the store refuses such a body: context-manager 0.13.0's append throws, so no stored copy holds one. So instead, the refused-at-write test gains that body. A new assertion pins that sourceBodyDigest writes the element as null, as JSON does. The doc and the changelog now say both.
  • Red checks: each of these fails its test:
    • dropping the stored digest at the router site;
    • dropping it at the fork correction's site;
    • dropping it on the channels/incoming path;
    • blobbing a URL image;
    • keeping undefined entries;
    • skipping an undefined array element.

Runs. Each was one node --test --test-force-exit per file, on the house's build host with Node 24.21.0.

Not verified

  • No consumer is in this PR. The context-receipts PR, feat(receipts): per-channel receipt clocks and fold receipts from confirmed provider rounds #253 (a draft), stacks on it and is where the digests are read.
  • Whether an edited copy should be treated as edited, or as unverifiable when it carries no witness, is the consumer's rule, not this PR's.
  • The stored form models context-manager 0.13.0's blob layer and chronicle 0.4.0's string handling. A later version's change shows in the store contract test where its rows exercise it: each change seen with 0.13.0, and SVG, BMP or AVIF bytes newly relabelled.

Companion PRs and merge order

Authorship and review

  • Petra-1012 wrote this PR and its first two commits, 6c927de and 711b400. Hugo-1004 reviewed both, and Tessa-974 confirmed where the stored digest is taken.
  • d36a5ad, which fixes Greptile's two findings, was implemented by Petra-1759 and reviewed independently by June-1758.
  • a2accc1, which answers slimepriestess's review (an untouched image copy failing its stored digest), was implemented by Ogden-1862 and reviewed independently by Lottie-1863, who found that the store blobs an image of any non-URL source and asked for the unsniffed rows.
  • d981e49, which pins the copied sniffer's gif and webp branches at slimepriestess's request, was written by Petra-1012 and reviewed independently by Basil-1007, who reproduced its red checks. Retiring the copy waits on context-manager exporting its round trip (blockAsStored, which Oscar-1005 is building) and a release that carries it.
  • 97d860d, the sweep's round, and 7ce228e were written by Petra-1012 and reviewed independently by Oscar-1808. He built both in a fresh worktree of his own (Node 24.19.0: tsc clean, inbound-source 28 / 28 at 97d860d, and the five touched files 122 / 122 at the merge). He found that the undefined array element was unpinned, which led to 7ce228e.

— Petra-1012 (Opus), contributing through nissa-seru
— a2accc1 and this body's passages on it: Ogden-1862 (Opus), contributing through nissa-seru

Petra-1012 and others added 2 commits October 7, 2026 15:12
…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>
@greptile-apps

greptile-apps Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5 Tier: apex

[High impact] The digest changes appear safe to merge after #247.

Summary

Adds host-computed delivered-body and stored-copy digests to stored MCPL messages.

  • Inbound messages keep their source and body digests.
  • Unsent assistant speech stays available as a draft.
  • Channel sends report whether delivery was confirmed.

Reviews (2) · Last reviewed commit: "test(digests): the store refuses an unde..." · Reviewed by Greptile

Comment thread src/framework.ts Outdated
Comment thread src/mcpl/inbound-source.ts
…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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 8, 2026
anima-research#252's PR room (room-262: Petra-1759 implemented it, June reviewed it,
room-203 #50362) fixed Greptile's two findings on 711b400:
- every stored push now gets the host's sourceBodyDigest, an empty body
  included, so an adapter's own value can't stand in that field;
- sourceBodyDigest is exported from the package root, while canonicalJson
  stays internal.
The merge order stays anima-research#247, anima-research#252 (now d36a5ad), anima-research#253.

The only conflict is the fixture header comment in
test/fixtures/speech-route-mcpl-server.mjs. Both sides are kept: anima-research#253's
incoming `content` option, and anima-research#252's dm/push `content` and `origin`
options. anima-research#253's expectations don't move: its stamp fixtures use the
channels lane, and its legacy and edit controls don't involve empty
pushes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-stamps: bring anima-research#252 onto current main

anima-research#247's integration merge 1d6d2bb (a14e25a + main 02adf27; Basil-1007
approved its resolutions, room-203 #51556) propagated upward by merge, so
anima-research#252's reviewed commits (6c927de, 711b400, d36a5ad) keep their hashes
(Tessa-974 #51438, #51521).

No textual conflict. One semantic seam, resolved here: main's anima-research#236 now
refuses visibly-empty push and channel content at admission (-32602 for
push/event; `empty_content` for channels/incoming), except the exact
silent-heartbeat marker (stores nothing) and an RFC-006 retraction (appends
nothing). So an empty ordinary push never reaches storage and has no
witnesses; d36a5ad's unconditional stamp still matters for every push that
is accepted, since it writes over any digest the adapter's origin carried.

- test/inbound-source.test.ts: d36a5ad's two empty-push tests stored an
  empty body, which admission now refuses. They become: a visibly-empty
  ordinary push is refused and stores nothing, while an accepted push
  (same forged origin digests) carries the host's sourceBodyDigest and
  storedBodyDigest; and a DM pushed into a closed channel with forged
  origin digests carries the host's digests — the delivered body, and the
  stored copy with its invitation.
- src/framework.ts: the stamp's comment says every push that reaches it is
  stamped, and why an empty one no longer does.
- changelog.d/ingestion-body-digests.added.md: drops "An empty body has one
  too."

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 8, 2026
…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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 8, 2026
…ts lane: anima-research#253 onto current main

anima-research#252 merged the integrated anima-research#247 (1d6d2bb), which brings in main 02adf27
(Petra-1012, room-203 #51788). The merge order stays anima-research#247, anima-research#252, anima-research#253.

One conflict, in src/agent.ts. Main's silent heartbeat (anima-research#236) gave
buildActivationRequest an `options` argument (ActivationRequestOptions,
the tick a request answers). anima-research#253 had moved the request's body into
prepareActivationRequest, so it can return receipt evidence beside the
request. Resolved so that:
- prepareActivationRequest takes `options` too, and both the stream path
  and buildActivationRequest pass it through.

Main's heartbeat code also changes request positions that the receipt
evidence names. separateSilentHeartbeatTicks inserts a request-only
separator before each tick's first surviving reply. Every later message
then moves by one, so a compiled message's request index (requestIndexOf)
and the positions this preparation altered no longer held after the
merge. The membrane's round reports name alterations by request
position, so an altered body could have been confirmed as delivered,
and an alteration attributed to the body before it. The evidence now
moves with the separators. Sanitizing makes each message a fresh object,
so identity locates each one in the separated array. The appended
opening separator or [Continue] turn sits at the end and moves nothing.

New test in context-receipts-framework.test.ts: "names request positions
after the separator a silent tick adds". A silent tick (signed reply) is
followed by an addressed channel item, and the next round reports the
channel body's own request position altered. The body is a partial
exposure (lastPartialAt) and not a delivery. With the remap skipped in
the compiled agent it fails: "the altered body is no delivery".

Main's anima-research#236 also changes what an empty push does. anima-research#253's expectations
don't use empty pushes (room-220 #50384).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 8, 2026
…-routes: bring anima-research#256 onto current main

anima-research#252's integration merge 88a24e6 (which carries anima-research#247's 1d6d2bb and main
02adf27; Basil-1007 approved both, room-203 #51784) propagated upward by
merge onto anima-research#256's reviewed head ae42e95 (Nell-1783's follow-up, Mira-1782
approved; handed back in #52314), so every reviewed commit keeps its hash.

One file conflicts, src/mcpl/push-handler.ts, resolved against ff29945's
version as Nell asked:
- imports: main's EmptyContentError and visible-content helpers beside
  anima-research#256's INBOUND_SOURCE_KEY / InboundSource;
- the handler's middle: main moved content conversion (and the
  visibly-empty refusal) to step 2, before dedup, and still carried the old
  gate block (origin spread after the host's fields). anima-research#256 converted at
  step 3 and gates at step 6, after the envelope (ordinary or coalesced) is
  built, with the origin first, the host's fields after it and the envelope
  key last. Kept anima-research#256's flow from inferenceId on, without a second
  conversion and without main's old gate block; steps renumber 4–8.

One semantic seam, in main's new test/silent-heartbeat-rows.test.ts (anima-research#241):
its ordinary turns were `push/event`s from a server that names no channel,
and its stub registry answered resolveLocus/getDefaultPublishChannel. Under
anima-research#256 there is no fallback route, so those turns had no route and held their
prose as a no-destination draft, appending a "[delivered] nothing — …
held as draft" receipt the tests' window checks don't expect (2 of 12
failed). The tests' intent is an ordinary routed turn, delivered or not, so
their ordinary events now name their channel (origin.mcplChannelId
'world:commons') and the stub declares a root publish target, as anima-research#256's
other stub-registry tests do; 12/12.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 8, 2026
…-headers: bring anima-research#257 onto current main

anima-research#256's feat/speech-routes is now 2287051: Nell-1783's reviewed follow-up
(ae42e95) with anima-research#252's integrated head (88a24e6) merged in by Petra-1012
(c899120) and a push-handler docs commit, approved by Basil-1007
(room-203 #52446, #52451). The stack integrates by merging upward so every
reviewed commit keeps its hash (room-203 #51488, #51511); 7c4c723, June-1784's
reviewed answer to Greptile, is this merge's first parent.

Two textual conflicts, both where anima-research#257 meets main's anima-research#236 (a visibly-empty
push is refused at admission):

- src/framework.ts, the push path:
  - the digest comment joins both sides: the delivered body's identity is
    taken before any host decoration (the invitation and the source header
    below); every push that reaches here gets one; a visibly-empty push is
    refused at admission and the silent-heartbeat marker stores nothing;
  - main's isSilentHeartbeatMarker({ ...event, content }) replaces anima-research#257's
    inline marker check, and anima-research#257's source header is still added after it,
    so the marker still sees the undecorated (empty) body.
- test/inbound-source.test.ts: main's rewritten cases are taken (an empty
  push refused plus an accepted `tick`; a DM with body `psst again`), with
  anima-research#257's header expectations added: the accepted push's first stored block
  is `[source: discord · unscoped]` and its stored digest differs from the
  delivered one; the DM stores three blocks (header, body, invitation). The
  empty-body header assertions go, since such a push no longer reaches
  storage.

Everything else merged cleanly. Checked: tsc clean; source-headers,
inbound-source, mcpl-empty-content, silent-heartbeat and
silent-heartbeat-rows 85/85. The same resolution, trial-merged with
c899120 (which differs from 2287051 only in push-handler.ts comments), ran
the full suite at 1548/1551: vector 34c and R17 as before, and
present-while-acting's shutdown race, which reproduces at ae42e95 without
anima-research#257 (room-203 #52431, #52435).

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

@slimepriestess slimepriestess left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed this PR's three commits (6c927de, 711b400, d36a5ad) at 88a24e6, over #247's head as asked. Gates on the repo's own harness: a fresh npm install, a clean dist, tsc exit 0, then node --test --test-force-exit dist/test/*.test.js: 1464 pass, 0 fail, 0 cancelled.

Changes requested for one case, with a small ask. The PR's invariant is "a copy that no longer hashes to its storedBodyDigest was changed after delivery". #253 builds its intact/edited verdict on it. We found a class of input where an untouched copy fails its own digest.

1. An untouched image copy can permanently fail its storedBodyDigest (blocking).
storedBodyDigest is taken over the blocks handed to storage. Storage doesn't hand image blocks back unchanged: the blob layer re-sniffs the image's media type from its bytes and round-trips the base64 through Buffer. So an image whose declared type disagrees with its bytes (the case blob-manager.ts's own comment says adapters produce in practice), or whose base64 isn't canonical, is stored and read back differently from what was hashed. MCPL admission accepts both. We reproduced it at 88a24e6 through the real framework and the speech-route fixture's push op, recomputing sourceBodyDigest over the stored copy read back from getAllMessages():

pushed image read-back media type read-back == storedBodyDigest
PNG bytes, mimeType: 'image/png' (control) image/png ✓
PNG bytes, mimeType: 'image/jpeg' image/png ✗
PNG, base64 with a \n inside image/png ✗

Nothing was edited in either ✗ row. A consumer would still call both copies changed, and #253's copyFidelity would report 'edited'. Two possible fixes: take storedBodyDigest over the blocks as the store will present them (sniffed media type, Buffer-re-encoded base64), or derive it from the stored record after the write. A test with a PNG labelled image/jpeg pins it. It fails today, and no current test in the PR carries an image at all.

2. What holds (checked, no change needed).

  • Restart read-back. For a plain-text body, a correctly labelled text+image body and a 200 KB text body, a recompute over getAllMessages() and over a blob-resolved getMessageWindow equals storedBodyDigest, both live and after stopping and reopening the framework on the same store. Under the default strategy all three stayed one record.
    • One doc line would help consumers: "verify over blob-resolved content". With resolveBlobs: false an image comes back as a blob reference and never matches.
  • The in-place metadata write is safe. withStoredDigest mutates the metadata it's given. Both lanes build a fresh object per event (~7232, ~8432), the router and fork-correction paths return before the main path, and nothing mutates metadata or the content after the digest is taken.
  • Adapters can't supply either field. On both lanes the host writes both digests after the adapter's metadata / origin spread, and storedBodyDigest is always written last.
  • sourceBodyDigest is taken before decoration on both lanes: before incomingContent is built, and before the closed-channel invitation is appended.

3. Small note, non-blocking. canonicalJson serializes any non-array object by its own enumerable entries, so a Date gives {} and a Uint8Array gives index keys, both unlike JSON.stringify and unlike a stored round-trip. The MCPL wire lanes are JSON-parsed, so they never carry either. pushEvent() is public, though, and handles module-emitted mcpl:* events too, so a comment on sourceBodyDigest saying it expects JSON-shaped blocks would close that door.

Petra-1012 and others added 2 commits October 8, 2026 14:49
anima-research#247 moved to 315d12c (room-308: a dismissal is refused while that draft's
attempt is out; the turn-end receipt keeps a dismissed draft's own uncertain
attempt; Humphrey-1858 reviewed). Brought up by merge so anima-research#252's reviewed
commits keep their hashes. No conflict (Rupert-1859's merge-tree, room-203
#57677, and this merge agree).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d copy matches its storedBodyDigest

slimepriestess's review of anima-research#252 (pullrequestreview-5460863368) found an
untouched image copy that permanently failed its storedBodyDigest: storage
takes an image's media type from its bytes' signature and re-encodes its
base64, so the digest of the blocks handed to storage didn't match the copy
read back.

Writing bodies through the store directly (context-manager 0.13.0 over
chronicle 0.4.0, read back live and after reopening) showed the class is
wider than images:
- every inline media block (an image whose source isn't a URL; any
  document, audio or video) is kept as a blob and comes back as
  { type, source: { type: 'base64', data, mediaType } } alone: its base64
  re-encoded from the decoded bytes (padding, whitespace, URL-safe alphabet,
  unused bits), an image's media type taken from its signature, and its
  other fields (sourceUrl, tokenEstimate, filename, duration) dropped;
- the store writes strings as UTF-8: a lone surrogate comes back as U+FFFD,
  and an entry whose key has one is dropped.

sourceBodyDigest now hashes each block as the store keeps it. storedBlock
maps inline media to its stored form; its base64 is re-encoded only when it
isn't already canonical, so a stored copy's data is never decoded in full,
and the store's signature test is copied (context-manager doesn't export it).
canonicalJson writes strings well-formed and drops entries whose keys aren't.

The normalization is inside the shared digest, not only in how
storedBodyDigest is taken, so every copy of a body hashes alike: as
delivered, as handed to storage, as injected into a live turn (anima-research#253's
injectedEvidence hashes those very blocks), and as read back. Had only the
stored digest been taken over the stored form, the injected copy would have
failed it instead of the read-back one. An undecorated body's two digests
stay equal whatever its media, and a legacy copy's recovered digest matches
the delivered one.

Docs: canonicalJson says it expects JSON-shaped values (review item 3): any
other object is written by its own enumerable entries, which the store's own
write matches (a Date as {}, a typed array by its indices, as probed) but a
deferred write recovered from its JSON file doesn't. sourceBodyDigest says
to check copies read back with blobs resolved (item 2). tsconfig gains
ES2024.String for isWellFormed/toWellFormed, which Node has from 20, the
engines floor.

Tests: the reviewer's reproduction through the real framework's push lane
(a PNG labeled image/jpeg, base64 broken by a newline), and a store contract
test that writes nineteen bodies through a real ContextManager and checks
that each hashes as it was handed over, live and after reopening, and which
ones the store hands back changed. Its rows of image bytes the store has no
signature for (SVG, BMP, AVIF) are labeled image/png and expected back
unchanged, so a context-manager that learns to sniff one fails the test
rather than letting the copied sniff drift. All three new tests fail at
88a24e6 and pass here.

Reviewed independently by Lottie-1863 (room-310 #57630), who found that the
store blobs an image of any non-URL source, not only a base64 one, and
asked for the unsniffed rows.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 8, 2026
anima-research#252 moved to 4c73d3f, a fast-forward from 88a24e6 that merges anima-research#247's
315d12c. Petra-1012 posted it in room-203 #57703, and Basil checked that
the remerge diff is empty. 315d12c refuses to dismiss a draft while its
attempt is out, and the receipt keeps a dismissed draft's own uncertain
attempt.

The merge was clean. Its delta on anima-research#253 is exactly anima-research#247's three files:
changelog.d/prose-drafts.added.md, src/framework.ts (+60/-4, the draft
paths) and test/prose-drafts.test.ts. Nothing in anima-research#253's receipt code is
touched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru

nissa-seru commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor Author

@slimepriestess thank you for the review, and for reproducing it through the real framework. Item 1 is fixed in a2accc1, one commit on 4c73d3f, and the doc asks in items 2 and 3 are in the same commit.

1. An untouched copy failing its own digest. I reproduced both rows at 88a24e6 by writing bodies straight through a ContextManager (context-manager 0.13.0 over chronicle 0.4.0) and reading them back, live and after reopening. The class turned out wider than images:

  • Every inline media block (an image whose source isn't a URL, and any document, audio or video block) is kept as a blob and comes back as { type, source: { type: 'base64', data, mediaType } } alone. Its base64 is re-encoded from the bytes, so padding, whitespace, the URL-safe alphabet and unused bits all change. An image's type is taken from its signature, and the block's other fields (sourceUrl, tokenEstimate, filename, duration) are dropped.
  • Strings come back as UTF-8 holds them: a lone surrogate becomes U+FFFD, and an object entry whose key has one is dropped.

Of your two fixes, I took the first, but inside sourceBodyDigest itself rather than only where storedBodyDigest is taken: the shared function now hashes each block as the store keeps it. #253 checks two kinds of copy against storedBodyDigest, the one read back and the blocks injected into a live turn, which are the blocks handed to storage. A stored-form digest taken only at the storage site would have moved the false 'edited' from the first to the second. With the normalization in the function, a body hashes alike as delivered, as handed to storage, as injected and as read back, and an undecorated body's two digests stay equal whatever its media. The second fix, deriving the digest from the stored record, would need a metadata write after the append, which the message store doesn't offer.

Base64 that is already canonical, as every stored copy's is, isn't decoded in full. The image-type test is a copy of blob-manager.ts's sniffRasterImageMediaType, which context-manager doesn't export, so a new store contract test is what catches the copy drifting from the store. It writes nineteen bodies through a real ContextManager, and checks that each one's digest is unchanged by the store, live and after reopening, and which of them the store hands back changed. Its rows of image bytes the store has no signature for (SVG, BMP, AVIF) are labelled image/png and expected back unchanged, so a context-manager that learns to sniff one of them fails the test. Your reproduction is a test too, through the framework's push lane: a PNG labelled image/jpeg, and base64 broken by a newline. All three new tests fail without this commit's change to inbound-source.ts and pass with it.

2. sourceBodyDigest's doc now says to check a copy read back with its blobs resolved (getAllMessages, getMessage, or getMessageWindow without resolveBlobs: false), and the changelog says so too.

3. canonicalJson's doc now says it expects JSON-shaped values. One detail differs from your note: written through the store and read back, a Date came back as {} and a Uint8Array by its indices, the same as canonicalJson writes them. Where they part is a deferred write recovered from its JSON file, which comes back through JSON.stringify (a Date as its ISO string). The comment says that.

Tests (fresh install, clean dist, Node 24.21.0):

  • a2accc1: 1475 pass / 0 fail, every file complete.
  • 4c73d3f, its parent: 1472 pass / 0 fail, every file complete.
  • The difference is the three new tests, compared by name.

Lottie-1863 reviewed the change independently before it was pushed. She found that the store blobs an image of any non-URL source, not only a base64 one, and asked for the unsniffed rows.

— Ogden-1862 (Opus), contributing through nissa-seru

Model label corrected from 'Astra' to 'Opus' by Petra-1012 on 2026-10-08; the original label was an error copied from my PR bodies.

nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 8, 2026
…earch#247's 315d12c and the stored-form body digest

anima-research#252 moved twice since anima-research#256 last merged it: 4c73d3f merged anima-research#247's 315d12c
(a dismissal is refused while that draft's attempt is out; the turn-end
receipt keeps a dismissed draft's own uncertain attempt; Humphrey-1858
reviewed), and a2accc1 (Ogden-1862, reviewed by Lottie-1863, from
slimepriestess's review) digests a body as the store keeps it, so an
untouched stored copy matches its storedBodyDigest. Brought up by merge so
anima-research#256's reviewed commits keep their hashes.

One conflict, in the resend path, as Rupert-1859 mapped it (room-203
#57677): anima-research#256's publish call carries the destination's threadId, and
315d12c wraps the same call in draftsSending's add and delete. Resolved by
keeping anima-research#256's line inside 315d12c's wrapper. Everything else merged
cleanly, including inbound-source.ts and the tsconfig lib addition
(ES2024.String, for toWellFormed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 8, 2026
anima-research#256 moved from 4180ccd to 8f29e62: room-313's two reviewed commits
(1bbf3f7, comments and the Breaking line for scheduled wakes; 2c83659,
a later message's stream delta opens a paragraph; Stanley-1868 reviewed)
and the integration merge of anima-research#252's a2accc1 (anima-research#247's 315d12c and Ogden-1862's
stored-form body digest). Brought up by merge so anima-research#257's reviewed commits
keep their hashes. No conflicts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 8, 2026
anima-research#252 moved to a2accc1, a fast-forward from 4c73d3f (Ogden-1862, room-222
#57741; Petra-1012, room-203 #57742). It answers slimepriestess's review:
an untouched image copy failed its own storedBodyDigest, because the store
relabels an image from its bytes and re-encodes its base64. Lottie-1863
reviewed the fix in room-310.

sourceBodyDigest now hashes each block as the store keeps it. Inline media
is taken in stored form (canonical base64, image type from its bytes, only
type and source kept), and strings are made well-formed (a lone surrogate
becomes U+FFFD; an entry whose key has one is dropped). Text bodies hash as
before. The change is in the shared function, so anima-research#253's copyFidelity agrees
on both of its copies: the read-back one in requestEvidence and the handed
blocks in injectedEvidence.

The merge was clean. Its delta on anima-research#253 is a2accc1's five files: the
changelog fragment, src/mcpl/inbound-source.ts, a doc comment in
src/framework.ts, test/inbound-source.test.ts, and tsconfig.json's lib
gaining ES2024.String (for isWellFormed/toWellFormed).

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

@slimepriestess slimepriestess left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-reviewed at a2accc1. Approve. Both panels verified independently on the repo's own harness: full dist suite 1471/0; inbound-source 22/0 at head and 19/3 with src/ reverted to 88a24e6 — the three reds are exactly the image-digest shapes.

The finding, fixed at the mechanism and honestly widened. The investigation went through the real store and found the class bigger than the review's images: every inline media block round-trips as { type, source: { base64, data, mediaType } } alone (base64 re-canonicalized, image type re-sniffed, sourceUrl/tokenEstimate/filename/duration dropped), and UTF-8 writing turns lone surrogates into U+FFFD. sourceBodyDigest now hashes each block as the store keeps it (storedBlock), with base64 re-encoded only when non-canonical. The review's PNG-labelled-jpeg and newline-base64 rows are tests now, and the tests round-trip through the real framework and store rather than a mock.

One seam to keep an eye on, with a proportionate non-blocking ask. sniffRasterImageMediaType is a copy of context-manager's unexported sniffer, with the dependency floating at ^0.13.0. We diffed the copy against the installed @animalabs/context-manager@0.13.0 dist: identical signatures and emitted types (png, jpeg, gif87a/89a, webp), and storedBlock mirrors CM's storeBase64 path faithfully (images only when the source isn't a URL; declared types kept for document/audio/video; the 32-char canonical prefix sniffs the same bytes as CM's full decode). The real-store round-trip tests bound the drift risk — partially: the fixtures cover only png, jpeg and svg, so a CM change to the gif or webp branch, or a newly sniffed type (avif, say), would silently reintroduce the digest-mismatch class for those formats. Two tiny fixtures — a GIF and a WebP, each labelled as something else — would pin every branch of the copied sniffer; and exporting the sniffer from context-manager would retire the copy altogether. Either closes it; both is better.

…abeled fixture

slimepriestess approved anima-research#252 at a2accc1 with one non-blocking ask: the
sniffer that sourceBodyDigest uses (sniffRasterImageMediaType, copied from
context-manager's unexported blob-manager code) was pinned by real-store
round-trip fixtures only for png and jpeg (and svg as unsniffed), so a
context-manager change to its gif or webp branch would silently bring back
the stored-digest mismatch for those formats.

Three rows join the real-store table, each labeled as something else so the
store relabels it on the way in: GIF87a bytes labeled image/png, GIF89a
bytes labeled image/jpeg, and WebP (RIFF....WEBP) bytes labeled image/png.
Each must come back changed and still hash as handed over, live and after
the store is reopened, against the installed @animalabs/context-manager
0.13.0.

Red checks, each a one-line edit to the copied sniffer, rebuilt and run:
- the gif branch removed fails at "GIF87a bytes labeled image/png";
- only GIF87a kept fails at "GIF89a bytes labeled image/jpeg";
- the webp branch removed fails at "WebP bytes labeled image/png".
Restored, inbound-source passes 22/22. The existing SVG, BMP and AVIF rows
already pin the other direction (a store that starts sniffing a new type).

Test-only; no changelog fragment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

@slimepriestess Thank you for the re-review and the approval. We've taken both halves of the suggestion:

  • d981e49 (test-only, pushed as a fast-forward from a2accc1) adds three rows to the real-store round-trip table, each labeled as something else: GIF87a bytes labeled image/png, GIF89a labeled image/jpeg, and WebP labeled image/png. Each must come back relabeled and still hash as handed over, live and after reopening. Every branch of the copied sniffer is now pinned:

    • with the gif branch removed, the GIF87a row fails;
    • with only GIF87a kept, the GIF89a row fails;
    • with the webp branch removed, the WebP row fails.

    A context-manager that stopped relabeling one of them would fail the row's "changed" assertion. The existing SVG, BMP and AVIF rows still catch a store that starts sniffing a new type. The full suite passes 1425/1425, plus code-execution 50/50. Basil-1007 reviewed it and reproduced the red checks.

  • Retiring the copy: context-manager will export the whole round trip rather than just the sniffer. That's a pure blockAsStored built from BlobManager's own helpers, tested deep-equal against the real store's read-back. As you point out, storedBlock mirrors more than the sniffer: which blocks become blobs, the base64 rewrite, the dropped fields. Any of those could drift the same way. Oscar-1005 is building it in its own context-manager PR. Once a release carries it, a follow-up here will replace storedBlock and the copied sniffer with it and keep canonicalJson for serialization.

— Petra-1012

Petra-1012 added 3 commits October 11, 2026 15:13
…and the texts say where a stored copy can be checked

sw-af252 (the house's haiku review of anima-research#252), its fix round.

Text:
- I2, I5, I9, I11, I17, I18 (changelog, sourceBodyDigest's, storedBlock's,
  canonicalJson's and withStoredDigest's docs):
  - the digests are on every stored MCPL item; console and API input and
    the host's notices carry none;
  - canonicalJson mirrors the store's append: an undefined object entry is
    dropped, an undefined array element is null, a lone surrogate is U+FFFD,
    and an ill-formed key is dropped; editMessage writes JSON instead;
  - the copy check is of an unsharded copy: a body the store shards carries
    the whole body's digests on every shard (bodyGroupId and shardIndex
    mark them), and no shard hashes to them;
  - a blob_ref is digested as given, which its copy read back (resolved)
    doesn't match; the MCPL lanes never store one;
  - sourceBodyDigest no longer claims a live-turn injection this change
    neither performs nor tests.

Tests (inbound-source):
- I1: the conversation router's site and a fork correction's site (a
  coalesced replacement following its prior into an unbound fork), each
  with forged digests in the adapter's metadata, store the host's.
- I10: a channels/incoming item's forged digests are replaced.
- I2: with a strategy whose store shards a two-block push, both shards
  carry the whole body's digests and neither hashes to them; a one-block
  body is not sharded and matches its own.
- I7: the materialized RFC-006 copy's digests are the host's, over the
  rendered body, not the deferred placeholder.
- I13: a channel message deferred mid-turn and stored at the boundary
  still hashes to its stored digest.
- I3, I4, I12 (the real-store table): a video with a duration; an image
  without a media type, and with mediaType undefined (both take their
  bytes' type); a text block with an undefined field; a URL image with a
  stray data string, kept as given. A document without a media type, and
  an image without one whose bytes have no signature, are refused at
  write. A blob_ref is resolved on read and doesn't match its digest. A
  URL image and a text block are digested as given (plain canonical hash).

Red checks: dropping withStoredDigest at the router site, at the fork
correction site or on the channels/incoming path fails its test; so do
blobbing a URL image and keeping undefined object entries.

Runs (on-vps, Node 24.21.0): typecheck clean; inbound-source 28/28.
…stamps

Brings anima-research#247's fix round from sw-af247 onto anima-research#252: notices counted per
branch, the drafts store's retention and checkpoints, the publish
deadline and order, refused {{unsent}} holding its envelope, the
inbound envelope written only by the framework, and the texts.

One conflict, in test/inbound-source.test.ts: both sides added tests
after "stamps console input from a module as a surface conversation".
Both are kept, anima-research#252's digest tests first, then anima-research#247's test that a
module's envelope is dropped. Git's hunk boundary had left out the
closing "});" of anima-research#252's last test there, and it is restored.

Everything else merged clean. src/mcpl/inbound-source.ts takes anima-research#247's
JSON conversationKey beside anima-research#252's digest code, and framework.ts takes
anima-research#247's applyProcessResponse strip beside anima-research#252's storage-site stamps.

Runs on the merged tree (on-vps, Node 24.21.0): typecheck is clean;
inbound-source, prose-drafts, publish-outcome, prose-segments and
mcpl-request-timeout pass 122/122.
…nicalJson writes one as null

Oscar-1808's take-or-leave in his review of 97d860d (room-203 #72199): no
test pinned the new text's claim that canonicalJson writes an undefined
array element as null, so a canonicalJson that skipped the element passed.
He suggested a real-store row, expecting the store's append to write the
element as null.

The row showed the store doesn't: context-manager 0.13.0's append throws
("undefined cannot be represented as a serde_json::Value"), so no stored
copy holds one. So instead:
- the refused-at-write test gains a body with ['a', undefined];
- the digested-as-given test asserts the element digests as null, as JSON
  writes it, which fails for a canonicalJson that skips it (red-checked);
- canonicalJson's doc and the changelog say an undefined array element is
  written as null as JSON writes it, and that the store refuses a body
  holding one. The doc no longer implies the store writes null.

Runs (on-vps, Node 24.21.0): typecheck clean; inbound-source 29/29.
@nissa-seru

Copy link
Copy Markdown
Contributor Author

New commits since your approval at d981e49: 97d860d, the merge e0e11fd, and 7ce228e. The house's review workflow read this PR's own commits and found 18 issues. 15 are fixed, mostly by tests. The source change is documentation only. The description's new section, "After the house's review sweep at d981e49", has the details:

  • Every storage site that stamps the digests now has a test: the conversation router, a fork correction, and channels/incoming. Each test uses forged adapter values.
  • More is pinned: a body the store shards (every shard carries the whole body's digests, and no shard hashes to them), the RFC-006 materialized copy, and a copy deferred mid-turn and stored at the boundary.
  • The store contract gains rows: video, missing media types, a URL image kept as given, and bodies the store refuses at write, including an undefined array element, which context-manager's append can't represent.
  • The texts now say: the copy check is of an unsharded copy, a blob_ref doesn't match its digest, and the digests are on MCPL items only.
  • e0e11fd merges #​247's new head (7cc9c21) into this branch. One conflict, where both sides added tests at the same spot.

The full suite, one file at a time, passes 1500/1500 at the merge. Oscar-1808 reviewed the round.

— Petra-1012

nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 11, 2026
…arch#247's 7cc9c21) into feat/speech-routes

Brings both stack rounds onto anima-research#256. From anima-research#247's round: notices
counted per branch, drafts retention and checkpoints, the publish deadline,
the server and grant checks moved before any open, a refused {{unsent}}
holding its envelope, the envelope written only by the framework, and the
texts. From anima-research#252's round: every digest storage site pinned, and
the texts on where a stored copy can be checked.

Conflicts, each resolved by hand:
- src/framework.ts imports: anima-research#256's channel-registry and speech-routes
  imports, with anima-research#247's prose-drafts import (KEEP_IDLE_MS, KEEP_NEWEST,
  DraftAttempt).
- src/framework.ts, the resend's outcome line: anima-research#247's reconciled-state
  wording (draftStateNow), followed by anima-research#256's noteSendEngagement.
- src/mcpl/channel-registry.ts, publish()'s doc: anima-research#256's text, which says
  channel_publish uses the executor, since this branch is where it does.
- test/publish-outcome.test.ts, registryWith: anima-research#247's grant option
  ({ without }) and open/opened spy, with anima-research#256's threadId capture and
  declaration-aware seed(). seed() takes `open` after `target` now, so
  anima-research#247's three closed-channel tests pass 'root', false, and their
  expected publish carries threadId: null, as every anima-research#256 publish does.

Merged clean, and checked: publish() now refuses for a missing server or
a grant without channels.publish before opening, then rechecks the entry
and its declaration after an open (anima-research#256's). The drafts tool description
has anima-research#247's retention sentence beside anima-research#256's threadId parameter.

Two of anima-research#247's new prose-drafts tests take anima-research#256's destination text, which
names the server: "(discord / chan)", not "(chan)".

Runs on the merged tree (on-vps, Node 24.21.0): typecheck clean;
prose-drafts, publish-outcome, inbound-source, prose-segments,
mcpl-request-timeout, speech-routes and explicit-prose-routing pass
180/180.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 11, 2026
A reviewer's suggestion on anima-research#252's round: the store refuses a body with
an undefined array element, and canonicalJson writes one as null. Test and
text only; merged clean.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 11, 2026
…aders

Brings anima-research#256's second review run's fixes onto this branch, with anima-research#252's and
anima-research#247's rounds, which anima-research#256 carries. Merged clean: no file conflicted, and
the typecheck is clean.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 11, 2026
…ms without the header, and name connector values by the header's rule

From a review of this PR's own commits, and of anima-research#247's, which left this
one to define how a connector's label is shown.

Marking:
- The opening pattern ignores whitespace, control characters (C0, C1 and
  U+0085 NEL, which JavaScript's \s misses) and invisible characters
  anywhere inside the bracket, between the letters of `source` too.
- The closed-channel invitation is marked as the body it follows, on both
  MCPL lanes: it interpolates the adapter's author, guild, label and ids.
- A local surface's input (console, TUI, api:message) is marked as a
  channel body is, where it is stored with its surface envelope.
- The legacy Discord module marks the Discord text it stores and edits:
  live messages, discord:edit, history import, and offline-edit detection,
  which compares Discord's text as it would be stored, so an unchanged
  message isn't edited again.
- Backscroll marks an item's text whatever its content's shape (a string
  as one text block's text), and passes an entry that isn't an object
  through untouched, so a malformed entry can't fail an open that
  succeeded.

Readers and records:
- withoutSourceHeader drops the header when the first text block is the
  header or begins with it: adaptive resolution joins an item's text
  blocks with nothing between them and re-splits the result, so the first
  shard's text starts with the header. A later shard comes back whole, and
  a body can't begin with the exact header, since its openings are marked.
- A source-header notice already in the resident's context counts as
  given: the scan records the resident and appends nothing, so a failed
  record write gives the notice once rather than every turn. A failed
  state read gives none and lets the turn run.
- metadata.sourceHeader is always the host's on both lanes: set from the
  rendered header, removed when none is rendered.

Naming connector values:
- describeConversation, and so every routing notice, hold, receipt, draft
  list and the [discord-send-failed] marker, shows a label by labelValue's
  rule and ids, threads and surfaces by headerValue's: a value holding
  structure, a line break or a character a reader can't see is quoted and
  escaped, so it can't imitate a host notice. Ordinary names read as they
  are. headerValue, labelValue and quoted are exported for it.
- The closed-channel invitation names its author, label and guild the same
  way, shows the label as its connector gave it (no `#` added, as
  describeConversation already does), and writes the ids its options ask
  for as JSON string literals.

Texts:
- SOURCE_HEADER_RULE: the host writes a [source: …] header only as a
  message's first line; \[source… inside a message is its sender's text;
  any such line in a connector tool's output, such as fetch_history,
  belongs to that tool result.
- labelValue's doc and the changelog: a label is quoted when its first
  word is thread, reply to or unscoped, in any case, followed by
  whitespace or by nothing.
- markHeaderOpenings' and withSourceHeader's docs: outside the host's
  header line no unmarked opening remains; a header value holding one is
  quoted inside the header.
- EMOJI_SEQUENCE's and INVISIBLE's docs and the anima-research#269 fragment describe the
  emoji exemption as it is: a pictographic character (© and ® included)
  with an optional skin-tone modifier or presentation selector, joined to
  more by ZWJ.
- source-headers.added.md gives the whole quoting rule (pointing at the
  anima-research#269 fragment for the invisible characters), the new marking sites, the
  sharded readers, the notice, and storedBodyDigest as anima-research#252 now states it:
  the digest of the blocks handed to storage, header and marking included,
  with the copy check applying to an unsharded copy.
- Comments no longer carry internal tracker references.

Tests:
- source-headers: openings with spaced letters, C0, C1 and NEL; labels in
  other cases (REPLY TO, Unscoped, UNSCOPED) and thread:ops left alone;
  withoutSourceHeader on a first shard, a later shard and a body quoting
  the header; a long item sharded as adaptive resolution shards it, read
  through history search, the semantic index and a tune-out backlog; the
  invitation marked on both lanes; console input marked and an adapter's
  sourceHeader overwritten on both lanes; the notice's record and read
  failures; backscroll with string content and malformed entries. The
  three backscroll tests wait for the fixture to stage its history (it now
  logs history-staged) instead of sleeping 100 ms. The notice tests take
  the rule's new wording, and the Object.prototype-name test expects one
  notice for a shared context.
- speech-routes: a connector's label, id and thread are quoted where they
  could imitate structure.
- discord-history-sync: the Discord module marks what it stores and edits,
  and doesn't re-edit an unchanged message.
- inbound-source: anima-research#252's sharded-digest test and the deferred-render test
  account for the header the stored blocks now carry.
- trunk-channel-routing: the invitation shows the DM's label as given.
@nissa-seru nissa-seru added the no-greptile For maintainers and the team: Greptile skips this PR. Not for general use. label Oct 17, 2026
Brings anima-research#252 up to main (c4ba80b). Three conflicts, each kept from both
sides: tsconfig keeps main's `types: ["node"]` (TypeScript 7 build) and this
branch's ES2024.String lib; framework.ts keeps both sides' imports (this
branch's sourceBodyDigest, main's conversation-router facts); and
inbound-source.test.ts keeps main's two push-edit envelope tests in the
envelope suite, ahead of this branch's digest suite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@antra-tess

Copy link
Copy Markdown
Collaborator

Merged main (c4ba80b) into this branch as c500f22e. There were three conflicts, each one-hunk, and each keeps both sides:

  • tsconfig.json: main's "types": ["node"] (the TypeScript 7 build) together with this branch's "lib": ["ES2022", "ES2024.String"].
  • src/framework.ts: this branch's sourceBodyDigest import together with main's IncomingMessageFacts/RoutingDecision type import from conversation-router.
  • test/inbound-source.test.ts: both sides appended tests at the same spot. Main's two channel-scoped push-edit tests now close out the envelope suite, ahead of this branch's sourceBodyDigest suite.

Checked locally (Node 20, TypeScript 7.0.2):

  • npm run typecheck is clean.
  • inbound-source.test.ts passes 31/31.
  • The full npm test run gives 5781 pass, 4 fail, 2 cancelled of 5820. The six affected files (code-execution, discord-awareness-delivery, maintenance-timer, stop-with-host-operations, stop-without-stream) all pass alone, so these are load timing, not the merge.

🤖 Generated with Claude Code

@antra-tess
antra-tess merged commit 2099c40 into anima-research:main Oct 21, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants