feat(provenance): persist mint request preimages; hash the accepted request - #79
feat(provenance): persist mint request preimages; hash the accepted request#79nissa-seru wants to merge 24 commits into
Conversation
Operators cannot read what configuration actually governs a running
strategy. Values coalesce through `??`-chains — a library default under
a caller-supplied value — across 52 default sites covering 35 distinct
keys in src/strategies/ and src/types/strategy.ts, and host applications
stack further layers above the library's two. The effective value of any
one key is recoverable only by reconstructing that chain by hand, and the
layer that supplied it is not recoverable at all.
New src/config-provenance.ts exports `resolveEffectiveConfig(layers)`,
which collapses ordered layers (base first) into `{ effective, provenance }`:
per key, the last layer supplying a value wins, and its source name is
recorded. It is schema-agnostic and dependency-free, so hosts can push
their own named layers (environment, profile, per-agent override) through
the same resolver the library uses for its own.
Null-vs-undefined follows the codebase's live coalescing semantics rather
than raw object spread: every `??` / `??=` default site treats `null` and
`undefined` identically as "not supplied", and previewContext filters both
out before spreading its overrides, for the stated reason that a spread
carrying `foldingStrategy: undefined` erases the live key. So a layer
supplying either does not win a key and does not steal a lower layer's
win, and a key no layer supplies non-nullishly is absent from both maps
instead of present-as-undefined.
AutobiographicalStrategy now resolves its config through the utility with
layers [library-default, caller], replacing the spread-then-`??=` chain in
its constructor. The two conditional default blocks are gated on config
that itself resolves through the layers, so the constructor resolves once
to read the gates, extends the library-default layer, and resolves again;
every key now lands through one site that records which layer won it, and
`strategy.configProvenance` exposes the map at runtime. Effective values
are unchanged: test/config-provenance.test.ts asserts the new path against
the previous algorithm written out literally, over six fixtures.
Visibility is opt-in through the door the library already uses. It is not
silent-by-design — 80 console.* diagnostic sites in src/, including a
structured single-line `console.error(JSON.stringify(payload))` for
compression-quarantine alerts — so this is a `logEffectiveConfig` flag
(default false) emitting one structured line at construction, rather than
a callback. Default silence is asserted.
The mint prompt is deliberately reconstructed as-of the original context:
the same head, the same recall ladder, the same tool definitions the live
instance runs with (setToolDefinitions, 2026-07-09). One layer of that
reconstruction was structurally missing — the system prompt the host serves
the live agent on every activation never reached the summarizer. On hosts
whose identity and conduct live in system voice, every memory was therefore
authored by a system-promptless variant of the agent, and merges
re-summarize summaries, so the drift compounded upward through the pyramid.
ContextManager.setSystemPrompt(text) mirrors setToolDefinitions exactly —
adjacent member, same storage shape, same live getter on StrategyContext
(a drain captures ctx once and must see the latest activation's value).
Both mint request builders serve it as the request's `system` field, which
NormalizedRequest already carries, placed ahead of the head exactly as a
live activation lays it out. KnowledgeStrategy inherits both builders
unchanged; recall-curve variants and every retry rung derive from the
canonical request by spread, so they carry it identically.
Both builders previously carried a worked "NO system prompt" rationale:
a synthetic summarizer header would (a) be something the original instance
never saw, disturbing KV consistency, and (b) compete with the structural
identity the conversation's head already carries. Both objections are
premised on the header being SYNTHETIC and invert when the host serves a
real one every activation — then replaying it IS the KV-honest
reconstruction, and omitting it is what installs the competing identity.
The comments now say that, and keep the original argument as the rationale
for the undeclared path.
Opt-in, and byte-exact when unset: the field is conditionally spread, not
assigned, so a request with no host prompt keeps its previous shape — no
`system` key, unchanged canonicalRequestHash and quarantine identity.
Token accounting needed no change: estimateCompressionRequestTokens and
compressionRequestInputBoundTokens both serialize the COMPLETE normalized
request, so the prompt is counted the moment it is present. The llm-calls
telemetry, which hard-coded `system: null` under the old design, now logs
the same {present, textChars} summary shape used for messages when a prompt
was actually sent, and still logs null when none was.
Tests (test/mint-system-prompt.test.ts): L1 mints carry it; merge mints
carry it; it leads the request rather than being spliced into the replayed
messages; unset leaves every mint request without a `system` key at all;
an empty/undefined later push never downgrades a recorded prompt;
KnowledgeStrategy inherits the threading. Five of the six fail on the
unfixed strategy (the unset byte-shape test passes both sides, by design).
npm test: 503 pass / 0 fail / 89 suites, against a 497/0 baseline on
main @ 56ca912 — no existing test perturbed.
Co-Authored-By: Claude <noreply@anthropic.com>
`SummaryEntry.provenance.requestHash` claimed to key "the exact request in the llm-calls log". No such log is written here: the only library-owned log is `logCompressionCall`'s JSONL telemetry behind `CONTEXT_MANAGER_COMPRESSION_LOG` — off unless a host sets it, and carrying summarized messages rather than the request — while the `llm-calls.*.jsonl` files cited in comments and tests are host-harness artifacts. So provenance was verifiable (re-hash a request you already have) but not readable (recover the request from the hash), and only for as long as somebody else's log survived. Chronicle closes the gap with nothing new invented: `storeBlob` keys content by sha256 of its bytes, which is the same digest `sha256Json` computes for `requestHash`. Storing `JSON.stringify(request)` therefore lands the preimage under the hash the summary already carries — content-addressed, deduplicated across retries, durable in the same store the summary persists to, no second format and no filesystem convention. Both mint sites persist at ACCEPTANCE, not at dispatch: `executeMerge` after the terminal-disposition gate, and `compressChunkHierarchical` for the accepted attempt (recovered through a call-local hash->request map filled in `runAttempt` — references only, and deliberately not hung on the trace objects, which are JSON-serialized into the compression log). Refused, admission-rejected and quarantined attempts are not mints and are not stored; a refusal curve would otherwise multiply store growth for receipts nobody minted against. Persistence never throws — a memory outranks its receipt, so a store that refuses the blob is loud on stderr and non-fatal, the same stance `logCompressionCall` takes. Reads: `getMintRequestByHash(store, hash)` and `getMintRequestPreimageBytes(store, hash)`, both exported. Off-switch: `persistMintPreimages` (default true) for hosts with their own durable request log. Known limit, pre-existing and unchanged: in the carrier-transport degraded path the bytes actually dispatched are `stripReasoningFromRequest(request)` while `requestHash` keys the un-stripped request. The preimage stores what the hash keys, so hash->preimage stays internally consistent; changing which bytes get hashed is a provenance-semantics change, not this one. Tests: test/mint-preimage.test.ts — merge and L1 round-trips (sha256(retrieved) === requestHash, and the stored bytes deep-equal the request the membrane was handed), durability across close/reopen, the off-switch writing nothing without throwing, and provenance's field shape pinned unchanged.
A recall pair's ANSWER side has never carried an end delimiter. The
question side has a label ("[Recall L1-3]", "[CM] Recall memory L1-3.",
or the uniform summaryContextLabel), the answer has nothing but the turn
boundary — and fleet operators report instances losing track of where one
recalled memory stops and the next thing starts.
Adds `recallEnvelope: 'none' | 'xml'` (default 'none'). Under 'xml' each
recall answer's prose is fenced by
<cm-recall id="L1-3" level="1" span="m-12..m-40">
…
</cm-recall>
Applied at one choke point: every recall answer in the codebase — the
presented middle window on both select paths, the L1 mint ladder, the
merge prefix and its expanded sources, and refusal-curve expansion — is
built by summaryAnswerContent, so the delimiter is applied there once
rather than at each of the eight call sites.
Notes on the shape:
- Attributes come from the SummaryEntry (id, level, sourceRange). An
attribute the record cannot answer for is omitted, never invented.
- Content is not entity-escaped. The envelope is a collision-tolerant
delimiter convention, not parseable XML; answer text is model prose and
a literal </cm-recall> in it renders verbatim.
- The tags land on the first and last TEXT blocks. Reasoning carriers in
`responseContent` stay byte-identical (signatures only verify on
unmodified blocks) and thinking stays first in the turn.
- Q-side labels are untouched in both modes, so zero-recall surgery keys
on exactly what it always did and removes enveloped pairs identically.
- capRecallPairs prices each summary's ACTUAL envelope string on top of
the existing +50 question overhead — no second magic constant, and zero
when the envelope is off.
Default 'none' is byte-identical to the previous render. That is pinned
by test/fixtures/recall-envelope-golden.json, captured mechanically from
the pre-change tree by test/fixtures/recall-envelope-golden.generate.ts
over fixtures covering an L1 recall, a merged-level (L2) recall, an
answer replaying signed carriers, and the adaptive path's uniform-label
pairs.
Tests: npm test 510 pass / 0 fail (baseline at 56ca912: 497 / 0).
WORKSPACE PROVISIONING, not an upstream-meaningful change. The host harness this checkout is worked in runs a mandatory regen-idempotency gate at merge time: it resolves the repo's own `regen` script, runs it, and requires the tree to be unchanged afterward. This repo had no such script, so the gate errored rather than failed — its stated remedy is repo-side, "content-free if the repo has nothing to regenerate". `tsc` rather than a no-op stub, because it is the honest answer: dist/ is the repo's only generated artifact, and it is gitignored, so idempotency holds by construction rather than by promise. Verified both ways in the workspace — `bun run regen` and `npm run regen` each exit 0, and two consecutive runs leave zero dirty paths. Upstream CI (.github/workflows/ci.yml) does not reference this script and is unaffected: npm ci, npx tsc --noEmit, npm run build, npm test. A PR to anima-research/context-manager should cherry-pick the feature commit alone and leave this one behind.
Addressed review finding 1: doc contradictions left standing. Two docs still stated the pre-feature absolute after the feature made the system prompt conditional on the host. compressChunkHierarchical's method doc said "No system prompt — framing via message structure only", which is now true only of the undeclared path. It states the split, and points at the request builder for the full rationale rather than duplicating it. AutobiographicalConfig.identityReminder's public JSDoc opened its argument with "Compression requests carry no system prompt" — load-bearing for a reader deciding whether they need the reminder at all. It now names the host's call as the discriminator, and says why the reminder is appended on BOTH paths: it addresses in-chunk dominant-speaker capture, which neither the head window nor a system prompt reaches. The 2026-08-03 observation is kept and dated to the path it was actually made on (the no-system-prompt one, then the only one) rather than being implied to generalize to a path that did not yet exist. Comment-only: no emitted behaviour changes. tsc --noEmit clean.
Addressed review finding 2: the telemetry race — the receipt could lie. Both mint builders serve ctx.systemPrompt into the request's `system` field at ASSEMBLY, but both logged the call from a `finally` that re-read that getter after an awaited membrane round trip. ctx.systemPrompt is a live getter onto the last value the host pushed (deliberately so — a drain captures ctx once and must see the latest activation), so a host activation landing between dispatch and resolution made the receipt attribute the memory to a prompt that call never sent. The compression log exists precisely so prompts can be audited post-hoc with "no reconstruction, no assumption about whether the strategy code matches what produced historical summaries" — a field that can silently misname its own input defeats that. Both sites now log the captured request's own `system`: compress_l1 reads `request`, and merge_l* reads `dispatchRequest`, which is what actually went on the wire (`request`, or its tools-less escalation). Verified that every rung reachable from these sites carries `system` through untouched by spread — buildRecallCurveVariants, withAppendedInstruction, withoutToolsParam and stripReasoningFromRequest each rebuild only messages or drop the tools param — so the canonical request's field is the sent value on every path, including retries and curve fallbacks. No mechanism change: the request bytes, the canonical hash and the quarantine identity are all untouched, and the null-when-absent shape that preserves the pre-threading log format is kept. test/mint-system-prompt-telemetry.test.ts pins it. The harness membrane refreshes the host prompt from inside complete() — strictly between dispatch and resolution — on every call, each refresh a distinct length, and the test asserts each receipt's textChars equals the length its own call dispatched. It guards against passing vacuously: it requires L1 AND merge receipts, requires the race to have actually fired, and requires one receipt per call. Red at 1969d75 ("call 0 (compress_l1) must report the 126-char prompt it SENT, not the 139-char one the host pushed while the call was in flight"), green here. Shared-surface note (review stop-arm): the `system` telemetry field on operation compress_l1 is also written by the quiet-stretch stub path, which logs null. That path makes no membrane call and awaits nothing, so it has no race and no misattribution to fix; the test filters it out explicitly by metadata.stub. bun test --timeout 60000 dist/test/: 504 pass / 0 fail (503 baseline + 1).
…tability Addressed review finding 3: an unstated stability assumption in the rewritten rationale comments. Both builders argued the declared path with "the original instance saw it, so replaying it is the KV-honest reconstruction". That silently assumes the host's system prompt is stable across the compressed span. The mechanism does not supply that: ContextManager holds the prompt in a single slot that setSystemPrompt overwrites on every activation, with no per-message history, so what a mint is served is the host's CURRENT identity policy — not what was in force while the chunk was being lived. When the host has changed the prompt, the older text is not recoverable from here at all. The comments now say that plainly, and keep the KV-honesty claim scoped to the case where it holds: stable prompt, the two coincide; changed prompt, the memory is authored under the identity the host serves now. The conclusion the feature rests on is unaffected either way — omitting the prompt is still what installs a competing, system-promptless identity. The merge site carries the same caveat by reference, noting that its sources may have been authored under a different policy than the merge. Truth-in-comments only: no mechanism change, no request-shape change. Scope judgment, stated for review: the same assumption was carried verbatim by two doc sites the finding did not enumerate — ContextManager.setSystemPrompt's own JSDoc and the public StrategyContext.systemPrompt JSDoc, both asserting the prompt is "part of the instance being reconstructed". Fixing only the two cited comments would have left the identical false framing on the public interface, and on the very symbol the finding cites as evidence that only the latest value is retained. Both are corrected here in the same terms. No other site in the tree carries the claim (grepped for "original instance saw it" / "instance being reconstructed"); the one remaining hit at autobiographical.ts:4841 is about in-band marker placement, unrelated. tsc clean. bun test --timeout 60000 dist/test/: 504 pass / 0 fail.
Review finding 1 (sol, job JRuTA): truncation could destroy the envelope.
`summaryAnswerContent` wrapped first and every presented render then called
`truncateContent`, which retains a prefix and appends its own marker without
reserving the closing tag. A capped render therefore emitted an answer with
no `</cm-recall>`, and a `maxMessageTokens` smaller than the opener emitted
half of one — a delimiter convention that sometimes lies is worse than none,
because a reader that meets one torn envelope stops trusting the intact ones.
Truncate-before-wrap, the decided fix shape, at all four capped sites:
- selectAdaptive merged-run flush and main loop, and selectHierarchical's
positioned pairs, go through `summaryAnswerContentCapped`, which caps the
prose (`summaryAnswerProse`, split out of `summaryAnswerContent`) and
envelopes what survives.
- selectHierarchical's legacy combined turn (`positionedRecallPairs: false`)
concatenates every selected summary under one cap, so the same ordering is
applied per summary against a shared sequential budget in
`combinedRecallAnswerContent`. Its emission shape matches the flat
truncator's — whole summaries until the budget runs out, a marker on the
one that straddles it, nothing after — except that a summary that would
have been cut in half is dropped whole rather than emitted torn. With the
envelope off that method keeps the pre-existing flat expression verbatim,
because those bytes are a compatibility promise.
No envelope-aware truncator was needed: the wrap-after-truncate ordering was
reachable at every site.
The envelope is not charged against `maxMessageTokens`. That cap is already a
soft estimate the truncator itself overshoots — it appends its marker after
spending the budget — and the envelope is priced where the accounting is load
bearing, in `capRecallPairs`. So the documented boundary behaviour at a cap
below the tag text is opener + whatever prose the cap bought + marker +
closer: never an empty envelope, never a torn one.
test/recall-envelope-truncation.test.ts covers both presented paths, the
legacy combined turn, and the tight-cap boundary. It is red on a76298b (8
pass / 9 fail, the first failure printing the torn opener
"<cm-\n\n[truncated - original was 28 tokens]") and green here.
The ratified null golden runs the fixtures UNCAPPED, so it cannot see this
restructure. recall-envelope-truncation-golden.json pins the capped renders
with the envelope OFF, captured mechanically on a76298b in a base worktree,
so a leak into the default path fails byte-for-byte rather than silently.
Review finding 2 (sol, job JRuTA): the option's JSDoc described the render and said nothing about the rest of the blast radius. Recall answers are built at one choke point, so enveloped answers also feed the mint and merge recall ladders — the prompts that produce the next generation of summaries. Turning the envelope on changes the summarizer's own inputs, and what that does to the summaries a model then writes is unmeasured: the evidence behind the feature is that instances read past an unterminated memory, not that enveloping leaves minting unchanged. That was the builder's own epistemic boundary, and it belongs where an operator deciding whether to enable this will meet it. The paragraph names the shared choke point, names the effect as unverified, says default-off is deliberate for that reason, and recommends reading what a mature store MINTS before enabling fleet-wide. Verified rather than inherited: rendering the adaptive fixture with recallEnvelope 'xml' produces 8 compression/merge requests, 6 of which carry recall ladders and all 6 of which carry envelope text in their prompt bodies. Documentation only; no behaviour changes.
…pted
Review finding 1 (sol, JRuTA): the carrier-transport degraded path made
provenance validate while misattributing authorship. Both mint sites sent
`stripReasoningFromRequest(request)` after a carrier 400 but hashed, mapped
and persisted the ORIGINAL request, so the summary written by the stripped
retry carried the hash of bytes the model never read. `sha256(retrieved) ===
requestHash` stayed green over a preimage that was not the authoring request
— the one failure an auditor cannot see.
Both ladders now name the accepted bytes explicitly. `runAttempt` (L1) and
`executeMerge` each hold an `acceptedRequest` that starts as the dispatched
request and becomes the stripped copy when, and only when, the transport
refuses the carriers; the trace hash, the accept-time hash->request map and
the persisted preimage all key off it. The merge site's `requestHash` moved
below the dispatch for the same reason: before the call there is no accepted
request to hash. Composition law from the review: whatever future features
ride the request (envelope, system prompt), the FINAL accepted bytes persist.
The L1 trace's `messageCount`/`estimatedTokens` follow `acceptedRequest` too
— stripping can drop whole messages, and a trace that describes the attempt
should describe the request that was actually made. Quarantine bookkeeping is
untouched: `canonicalRequestHash` and `variant.requestHash` identify request
FAMILIES for retry planning, not authorship, and stay as they were.
Review finding 2: preimage persistence is deliberately best-effort (a
summary outranks its receipt), but the public surfaces named only old-data
and opt-out as absence causes. The store-failure case is now documented in
all of them: `getMintRequestPreimageBytes` (three enumerated causes),
`getMintRequestByHash`, `SummaryEntry.provenance`, the `persistMintPreimages`
option doc, and the CHANGELOG. A present `provenance` promises a verifiable
hash, not a retrievable request.
Review finding 3: the merge site's request-identity comment still claimed
`requestHash` points at an exact llm-calls entry — a log this library never
writes. Rewritten to the blob-store truth. The type-doc twin on main
("keys the exact request in the llm-calls log") was already trimmed by
1b27433; it is further sharpened here to state that the hash keys the
ACCEPTED request.
Review finding 4 (sol's test design, implemented as specified):
test/mint-preimage.test.ts gains a parameterized L1 case — (a) a tool_use
rejection followed by a byte-distinct no-tools retry, (b) a carrier-400
followed by the stripped retry — that captures every membrane call, asserts
`provenance.requestHash` equals sha256 of the ACCEPTED call's JSON, asserts
the retrieved preimage is those bytes byte-for-byte, and asserts the rejected
requests' hashes have no preimage. Against d019e40 the carrier case fails at
the hash assertion (5 pass / 1 fail) and the tool_use case passes, which is
the drift guard on the accept-time map; both pass here.
Verified: tsc clean; bun test --timeout 60000 dist/test/ = 503 pass / 0 fail;
real-runner `npm test` (node 22.14.0) = 503 pass / 0 fail (branch baseline
501, +2 new). Lockfiles and node_modules untouched.
…the report
Remediation for sol's findings 1 and 2 on this branch: the wiring was not
behavior-identical, and the effective-config report described the wrong
strategy with a caller-attributed value the caller never chose. Both are fixed
here because both live in the same constructor and the same equality oracle
covers them.
FINDING 1 — WIRING IS NOT BEHAVIOR-IDENTICAL. `resolveEffectiveConfig` skipped
`undefined` and `null` unconditionally, while the constructor it replaced ran
`{ ...DEFAULT_AUTOBIOGRAPHICAL_CONFIG, ...config }`, where a caller's own key
wins even when explicitly nullish and stays present-as-undefined. Measured over
all 54 keys of AutobiographicalConfig, at both nullish values: 46 keys resolved
differently through the new path (18 defaulted keys where the default was
erased before and stood after; 28 keys with no library default, present-as-null
before and absent after). Only the 8 `??=`-repaired keys were unaffected, and
`recentWindowTokens` — the key the deleted test pinned as CHANGED — was one
divergence of 92, not the class.
`resolveEffectiveConfig` now takes an explicit per-call `semantics`:
`'skip-nullish'` is the `??` reading the general host-facing case wants, and
`'spread-fidelity'` is the spread reading, where a layer's own keys win nullish
included. Nothing infers it; both readings are pinned by their own tests. The
strategy constructor asks for spread fidelity and expresses the conditional
`??=` blocks as what they are — a library-default layer narrowed to the keys
the layers left nullish — so the resolved config is key-for-key what the spread
produced. The same probe over 54 keys x 2 nullish values now reports zero
divergence.
The equality test is direct over divergent inputs: fixtures carry explicit
`undefined` and explicit `null` caller keys, including one setting EVERY
defaulted key nullish, each asserted against the previous algorithm written out
inline as the oracle. The test that asserted the changed `recentWindowTokens`
behavior is inverted: it now pins the spread semantics the strategy preserves.
Receipt: this file's 34 tests run against ac811ed give 19 pass / 15 fail; on
this commit, 34 pass.
FINDING 2 — KNOWLEDGE-STRATEGY VISIBILITY IS FALSE. The report was emitted from
the base constructor, which runs before a subclass's `name` field initializer,
so every KnowledgeStrategy instance reported itself as 'autobiographical'. The
emission is deferred to `initialize` — the one gate every real use passes,
since every other entrypoint throws until a branch is loaded — and it needs no
cooperation from subclasses, which a post-construction hook would (three test
subclasses already exist that would have to remember it).
KnowledgeStrategy forced `hierarchical: true` by spreading it over the caller's
own options, so the provenance map named the host as the source of a value the
library requires. Forced values now ride their own layer above the caller:
`enforcedConfigLayer()` returns null in the base and
`{ source: 'knowledge-enforced', values: { hierarchical: true } }` in
KnowledgeStrategy. Effective values are unchanged — asserted by running the
knowledge constructor's own previous algorithm as an oracle over the same
divergent-input fixtures — while 'caller' now means only what the caller
supplied.
Remediation for sol's finding 3. This branch adds public exports (`resolveEffectiveConfig`, `ConfigLayer`, `ConfigResolutionSemantics`, `EffectiveConfigReport`, `strategy.configProvenance`) and a new option (`logEffectiveConfig`) — both squarely in what CONTRIBUTING.md's changelog section says a host or strategy author would notice, and the `changelog` CI check rejects a PR touching src/ without touching this file. Two entries under the standing `## Unreleased` / `### Added` heading: one for the resolver and the provenance map, naming the per-call semantics and stating that no caller's effective config changed; one for the option, including that it emits at initialization rather than at construction so a subclass instance reports the strategy it actually is. No `### Changed` or `### Breaking` entry: effective values are identical for every caller, verified over all 54 config keys at both nullish values.
Second sol review returned two blocking findings on the effective-config
report; both are addressed here as one change.
Finding 1 — the structured report dropped present-as-undefined keys on the
wire. The resolver deliberately keeps a caller's own `undefined` keys
(spread fidelity), but reportEffectiveConfigOnce handed the map to
JSON.stringify, which drops exactly those keys: for
{ recentWindowTokens: undefined, logEffectiveConfig: true } the emitted
provenance named recentWindowTokens: 'caller' while effective omitted it,
so the two maps structurally disagreed and the "every effective key"
promise was false. The line now carries a third field,
`presentAsUndefined: string[]`, computed at the emission site from the
resolved values — not added to EffectiveConfigReport, so the derived list
cannot drift from the map it describes, and a key an enforced or host
layer supplies as undefined needs no special case. `effective` continues
to omit those keys (JSON law) and `null` stays a JSON-native value with
its own provenance. The invariant is documented where it is emitted:
keys(provenance) === keys(effective) union presentAsUndefined, disjointly.
Finding 2 — the public `logEffectiveConfig` JSDoc still described the
pre-fix behavior: emission "at construction" and sources limited to
'library-default'/'caller'. It now states the implemented truth — emitted
once at strategy initialization (a constructed-never-initialized instance
emits nothing), with 'knowledge-enforced' and host-named layers among the
sources — and describes the new field.
New test constructs with { recentWindowTokens: undefined,
productionBudgetTokens: null, logEffectiveConfig: true } and asserts the
undefined key is absent from effective, 'caller' in provenance and listed
in presentAsUndefined; the null key valued in effective and NOT listed;
and the invariant over the whole emitted line. RED at ca5cf50 (it passed
the effective-omits and provenance asserts and failed at the missing
field), GREEN here. Both runners green: bun 532/0, node 22.14.0 `npm test`
532/0; tsc clean. Lockfiles and node_modules untouched.
…sites it missed Addressed the second sol review's single blocking finding: the prompt-currency correction landed in the source comments (765b0cf) but two tree sites still carried semantic variants of the claim it corrected. A phrase-only grep missed them because neither repeats the corrected wording. CHANGELOG.md, Unreleased/Added, the setSystemPrompt entry: said the mint "reconstructs the agent's own past view" and that the system prompt is "part of that view". test/mint-system-prompt.test.ts's file header: said the summarizer is "reconstructed as-of the original context" and named the system prompt as a missing layer of that reconstruction. Both are false whenever the host changed the prompt during the compressed span -- the mechanism holds one slot with no per-message history, so the request carries the CURRENT-at-mint policy. Both rewritten in the corrected source comments' own vocabulary (single slot, no per-message history, in force AT MINT TIME, stable across the compressed span, older text not recoverable from here), so a future phrase-grep on any of those hits every site at once. The as-of claim is SCOPED rather than deleted: the rest of a mint request genuinely is built as-of the span (same head, same recall ladder, no tail after the chunk) and only the prompt is current-at-mint. The test header also now says which of its cases pin the threading and which one pins the slot's last-value-wins rule, and states that no case asserts a per-span historical prompt. Deviation from the carried-forward wording, stated for review: the CHANGELOG's as-of list also named "same tool definitions", and tool definitions are NOT as-of either. ctx.tools is the identical mechanism -- setToolDefinitions writes one last-write-wins slot (context-manager.ts:909), StrategyContext.tools is a live getter onto it (:952), and both mint builders send `tools: ctx.tools` (autobiographical.ts:5184, :6545). Restating that clause as an as-of replay would have authored a fresh false claim of exactly the class under repair, so tool definitions are dropped from the as-of list rather than asserted. No tools-currency caveat is added here: that claim belongs to the tool-threading feature, not to this entry, and no review finding raised it. Flagged in the wrap as adjacent work. Semantic census (the receipt for "no third site remains"): every comment or doc block in the tree that speaks about the threaded prompt, or about the mint's as-of relationship to the compressed span, was read -- 44 blocks across 16 files: 2 fixed here, 8 already stating the current-at-mint truth (the six sites the fix commits corrected, plus the private-field doc and the telemetry test header), 34 silent on the question. No third semantic-variant site exists. Enumerated block by block with verdicts in the job wrap. Census run with `grep -a`: autobiographical.ts carries a raw NUL byte at ~line 7636, and skip-binary greps report zero matches on the whole file. Prose-only: no mechanism change, no request-shape change, no test assertion touched. tsc clean. bun test --timeout 60000 dist/test/: 504 pass / 0 fail. node v22.14.0 `npm test`: 504 pass / 0 fail.
…a summary The legacy combined XML path (`positionedRecallPairs: false` with `recallEnvelope: 'xml'`) did not spend its one shared cap correctly. `combinedRecallAnswerContent` truncated the next summary against `remainingTokens` and only deducted that summary's preceding separator AFTER the separator and the summary had been emitted. With one token left it therefore emitted a two-token separator plus another one-token truncated envelope instead of stopping: a turn that overran its cap by the separator's own width plus whatever prose the exhausted budget still bought (sol review JYr3T, finding 1). The separator is now priced once from the strategy's own estimator over the hoisted `COMBINED_RECALL_SEPARATOR_TEXT`, and charged BEFORE the admission decision: a summary is admitted only when the remaining budget can pay for the separator in front of it and buy at least one token of prose behind it, otherwise emission stops (forEach to for/break, because the semantics are stop). The method doc now says where that drop-whole boundary sits. The same review's second half: the purported between-envelopes case could not catch this, because its cap of 12 lands inside a 13-token first summary. `combinedSharedBudgetCases()` adds five caps derived via the production estimator over the hoisted fixture prose (first=13, separator=2, second=6), each of which fully admits summary one so the separator arithmetic is what decides the rest: 21 admits both whole, 16 buys the separator plus one token of summary two, and 13/14/15 stop after summary one with no separator and no torn envelope. A budget invariant over all five asserts emitted prose plus separators never exceed the cap. Four of the six fail before this change (cap 16 charges 18 tokens). They are deliberately not members of CAPPED_RENDER_CASES, whose keys are enumerated by the default-mode golden capture. The default/'none' path is untouched and its capped golden is byte-identical. Both runners green: bun 533/0, node 22.14.0 533/0.
# Conflicts: # src/types/strategy.ts
# Conflicts: # CHANGELOG.md
# Conflicts: # CHANGELOG.md
antra-tess
left a comment
There was a problem hiding this comment.
From the 08-26 review sweep. The accepted-request half is correct and wanted — hashing the original while dispatching the stripped copy was exactly the misattribution you describe, and the parameterized test discriminates it properly. Three changes requested on the persistence half:
-
Don't re-embed inline images in preimages. A single mint can carry up to 12MB of base64 image content, and content-addressing can't dedupe it across distinct requests — that's the dominant growth term, unbounded over a long-lived store. Image bytes already live in the same Chronicle store as content-addressed blobs: persist the preimage with image parts replaced by their blob references and materialize on read. Text-only preimages at mint cadence are an acceptable cost.
-
The claimed store-failure test does not exist on the branch. The body says "Store-failure behavior is unit-tested via an injected failing store" —
git grepacrosstest/finds no such test (checked twice, independently). The never-blocks-the-mint contract is the load-bearing safety property of this feature; please add the test the body describes (storeBlob throws → mint still lands, read returns null) and correct the receipt. -
Default. Our fleet deploys from checkout, so
persistMintPreimages: trueactivates for every resident on the next pull with no retention knob. With (1) fixed the growth story improves a lot, but the rollout should still be deliberate: either defaultfalse, or defaulttruewith a loud per-preimage size cap — and state the choice in the changelog fragment.
For what it's worth, we do want this feature: host-side llm-calls logs are summaries-only or refusal-only on several hosts and don't travel with store custody, so self-contained provenance is the right goal. The shape just needs the image dedup before it can ride a fleet pull.
|
Thank you for the sweep — all three taken, and item 2 first because it's the one about my own receipt. 2 (the claimed test): you're right, and the claim was false. Verified against the branch just now: 1 (image re-embedding): agreed, with one contract note. Preimages will persist with image parts replaced by content-addressed blob references and materialize on read — and since the feature's whole point is 3 (default): Fix branch incoming on this PR. |
…edia Maintainer review finding 1 (antra-tess, PR anima-research#79): a single mint can carry up to 12MB of base64 image content, and content-addressing cannot dedupe it — the blob key is sha256 of the WHOLE request and no two mints send the same request, so every image-bearing mint wrote another full copy of every image it replayed. That was the dominant growth term, unbounded over a long-lived store. The bytes were already in the store. MessageStore extracts every base64 media source to a content-addressed blob on add (BlobManager) and resolves them back on read, so the mint request's inline base64 is a re-encoding of blobs the store holds. A media-bearing preimage now persists as an ENVELOPE — the request's own JSON text split into literal spans and references to those existing blobs — and getMintRequestPreimageBytes splices the base64 back in at read time. Byte-faithfulness is the feature's whole contract (sha256(preimage) === requestHash), so it is enforced at BOTH ends rather than assumed: - the splice is textual, never a re-serialization: the envelope holds the exact JSON substrings around each payload, so nothing depends on JSON key order, number formatting or escape choices surviving a parse/stringify round trip; - a payload is extracted only when base64 decode/re-encode reproduces the original characters exactly, so a non-canonical encoding stays inline rather than being "helpfully" normalized; - persistence materializes the envelope in memory and compares it to the request bytes BEFORE indexing it; an envelope that does not splice back falls back to storing the plain request. A preimage is never left readable-but-wrong; - the read path re-hashes what it spliced and refuses to return bytes that do not hash back to the key they were stored under. Lookup: a content-addressed store can only return bytes that hash to the key asked for, so an envelope cannot live under requestHash as a blob. It is indexed in a Chronicle TREE state (mint-preimage-envelopes) — the store's own path-keyed index, point-lookup, branch-visible like the summaries it serves — mapping requestHash to the envelope blob. No second blob store, no new filesystem convention. Reads try the plain blob first, so text-only and pre-existing preimages take exactly the path they took before, and a store that never registered the state reads null rather than throwing (measured: treeGet on an unregistered state returns null). Text-only mints are untouched: no envelope, no index entry, no version marker — the plain whole-request blob, as before. Extraction also skips payloads under 1KB, where a blob record costs more than it saves. New public surface: describeStoredMintPreimage(store, requestHash) reports what a preimage actually costs the store ({form: 'inline'|'envelope'|'absent'}, stored bytes, and for envelopes the blobs it SHARES rather than copies), which is the question the review asked; and MintPreimageMaterializationError makes a damaged store loud instead of returning the null that means "nothing was persisted here". Red first, on the unfixed tip: an L1 mint over a 120KB synthetic image stored a 161,958-byte whole-request preimage blob (`161958 !== 0`). After the fix that same mint stores a 2,276-byte envelope naming the one image blob the messages already put in the store — blob bytes grow by 2,311 total, not by another 161KB — and getMintRequestPreimageBytes returns bytes identical to the request JSON, hashing back to requestHash. All three figures are read out of that one fixture's own run, not stitched from siblings. Five tests: the envelope form and its footprint, the shared-blob receipt, a multi-payload splice (two images plus a repeat of the first, which must come back in document order with the repeat sharing its blob — the cursor walk is the one subtle piece here), close/reopen durability through the tree index, and a PIN that a text-only mint still stores as the plain blob. Suite: 610 pass / 0 fail (605 on this base before these tests). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ing store Maintainer review finding 2 (antra-tess, PR anima-research#79): the PR body claimed "Store-failure behavior is unit-tested via an injected failing store" and no such test existed on the branch — checked twice, independently, and correctly. The never-blocks property is the load-bearing safety promise of preimage persistence (a memory outranks its receipt), and nothing pinned it. The finding was the missing test; the receipt was false. PIN, NOT A FIX. Both tests here passed on their first run against the persist path as written: persistMintRequestPreimage already wraps its whole body in try/catch and reports on stderr, so an injected refusal never reached the mint ladder. The behavior was right and unwitnessed. That is stated plainly rather than dressed up as a repair. Discrimination receipt (the test earns its keep): making the catch rethrow — one line, `throw error;` after the console.error — turns both tests red and only those two (14 pass / 2 fail in test/mint-preimage.test.ts), with `zz-store-refuses-storeBlob` escaping through executeMerge and `zz-store-refuses-treeSet` through compressChunkHierarchical. Reverted, both green again. The seam is the true external one rather than a mock of first-party code: StrategyContext.store is the Chronicle handle the preimage write goes through, and the strategy captured its own archive handle at attach, so a tick given a refusing context store fails ONLY the preimage write while summaries persist normally. Two refusals are covered: - storeBlob throws (a full or closed store): the merge mint lands, its provenance is present with a well-formed requestHash, the preimage read returns null and describeStoredMintPreimage reports 'absent'. The summary is then shown to survive close/reopen — accepted into the ARCHIVE, not merely into memory, which is the property the review actually cares about. - treeSet throws (the envelope index refuses): the media-bearing mint lands too, and an unindexed envelope reads as absent rather than throwing. This covers the machinery added by the previous commit under the same promise. Suite: 612 pass / 0 fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Maintainer review finding 3 (antra-tess, PR anima-research#79): our fleet deploys from checkout, so `persistMintPreimages: true` would activate for every resident on the next pull, with no retention knob. Finding 1's envelope fixes the image term, but the request TEXT is still real growth at mint cadence, so the rollout should be deliberate. Default flipped true -> false; the option is now opt-in and the Unreleased changelog entry says so in those words. The gate reads `!== true`, not `=== false`. The strategy builds its config as `{...DEFAULT_AUTOBIOGRAPHICAL_CONFIG, ...options}`, so a host threading an unset flag through — `persistMintPreimages: hostOptions.preimages` — lands an explicit `undefined` that OVERRIDES the default. Under `=== false` that turns persistence back ON for exactly the host who never asked for it. Absent config means off, and only an explicit true enables. Discrimination, measured rather than assumed. Flipping the default alone turned 8 tests red (the ones that expected a preimage), which is the receipt that they were riding the default rather than testing the switch: every test that wants persistence now asks for it explicitly, at the call site or in a fixture whose whole purpose is preimage persistence. The off-switch test became a four-case table — absent config, false, undefined, true — with the mint landing identically in all four and only `true` writing anything. That table discriminates the gate: reverting it to `=== false` turns the `persistMintPreimages: undefined` case red — 15 pass / 1 fail in test/mint-preimage.test.ts, that one case and no other — while the `absent config` case stays green, because the default merge already supplies false there. Docs corrected wherever they claimed default-on or a shape the envelope work changed: the option's own JSDoc (opt-in, the deploy-from-checkout rationale, absent-means-off), `SummaryEntry.provenance` (readable WHERE the host opted in; absent when it ran without `persistMintPreimages: true`), the `requestHash` field doc (the hash is the blob key for a text-only request and the envelope index key for a media-bearing one — it is no longer simply "the blob key"), and the persist call site in autobiographical.ts. The changelog entry is rewritten to state the choice. It lives in CHANGELOG.md's Unreleased section on this base (the ### Added bullet that read "Mint request preimages are now persisted by default"), so editing it in place is what keeps the changelog true rather than adding a fragment that contradicts a bullet three lines above it. Four bullets now: the feature and its read APIs, the opt-in option with the deploy-from-checkout rationale, media stored by reference with the byte-faithfulness guarantee and its enforcement at both ends, and best-effort absence. Not a breaking change and not filed as one: preimage persistence has never shipped. Its entries sit in CHANGELOG.md's UNRELEASED section, no released version section mentions preimages at all, and the newest release predating this work is 0.6.2 (package version 0.6.3) — so no version of this library has ever had the option default-on for a host to rely on. The entry stays under Added. Suite: 615 pass / 0 fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Native review found three ways the first envelope implementation could retain inline image data despite its byte-faithful readback: it exempted payloads under 1,024 characters, rejected valid non-canonical base64 spellings, and located a payload with requestJson.indexOf. If the same base64 appeared in an earlier text block, that search replaced the text occurrence and left the image occurrence inside the envelope. Derive media value spans by parsing the serialized request structure instead. Every valid payload is now replaced regardless of size. The envelope records a compact encoding form (padding, standard/URL-safe alphabet choices, and exact whitespace positions), so materialization reproduces the original JSON token without storing the base64 body. A 12,000,174-byte request measured a 439-byte envelope and round-tripped exactly; a deterministic 480-case spelling/nesting probe also round-tripped with no whole-request blobs. The six focused regressions were red together on the pre-review tip (16 pass / 6 fail): small payload, unpadded payload, URL-safe payload with line whitespace, earlier-text collision, malformed envelope JSON, and malformed media segment. They are green here (22 pass / 0 fail in mint-preimage.test.ts). Malformed JSON and segment shapes now normalize to MintPreimageMaterializationError with the original SyntaxError or TypeError attached as cause. Remove describeStoredMintPreimage and its root export. It was unrequested and claimed to report storage cost while omitting media blobs introduced by the preimage path: a 120KB generated image measured 354 reported bytes against 120,417 actual blob growth. No number is better than a measured-wrong one. Move the pending preimage release prose from CHANGELOG.md into the repository's fragment flow, corrected for default-false rollout and for media that was not already in MessageStore. The release script is pinned to merge direct entries with fragments, so leaving the stale direct default-true entry would have released contradictory claims. Targeted compiled tests: 46 pass / 0 fail across mint-preimage and release-changelog. Typecheck and regen are clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ea86518 to
7ce59f3
Compare
|
All three changes landed; the branch is rebased onto current main (4eb1ab8) with the already-merged feature commits dropped, so the diff is now just the preimage feature + these fixes, four commits. 1 (image re-embedding): Preimages now persist with every inline media payload replaced by a content-addressed blob reference — envelope form. Location is STRUCTURAL (spans derived from the serialized JSON part, not string search — a review probe caught 2 (the claimed test): You were right — the test did not exist; the PR body has carried the correction since your review. It now exists as its own commit: an injected store whose 3 (default): Process, for the record: these fixes went through our usual loop — implementation, an independent review pass that found five gaps in the first cut (including the |
Problem
SummaryEntry.provenance.requestHashis a hash with no readable preimage: you can verify that stored bytes match it, but nothing in the library retains those bytes, so the request that authored a memory is unrecoverable — and the field's own doc pointed at "the llm-calls log", a log this library never writes (it is a host-harness artifact). Provenance you can verify but not read is an audit trail that answers only half its question.Separately, the hash itself could misattribute. In the carrier-transport degraded path (provider refuses reasoning carriers with a 400), both mint sites dispatched a reasoning-stripped copy of the request but hashed the ORIGINAL: the summary written by the stripped retry carried the hash of bytes the model never read.
sha256(preimage) === requestHashverifies green over the wrong request — the one failure an auditor cannot see.Changes
requestHashIS the retrieval key). Refused and quarantined attempts are not mints and are not stored.getMintRequestPreimageBytes(hash)/getMintRequestByHash(hash); absence has three enumerated causes (pre-feature mints,persistMintPreimages: false, store failure) — persistence is deliberately best-effort, a summary outranks its receipt, and every public surface now says so. A presentprovenancepromises a verifiable hash, not a retrievable request.persistMintPreimages(defaulttrue) for hosts that keep their own durable request log or accept unreadable provenance — mint requests are large.acceptedRequest: the dispatched request, or the stripped copy exactly when the carrier fallback fires); the trace hash, the accept-time hash→request map, and the persisted preimage all key off it. The merge site's hash computation moved below the dispatch — before the call there is no accepted request to hash. TracemessageCount/estimatedTokensdescribe the accepted request too (stripping can drop whole messages); quarantine'scanonicalRequestHash/variant hashes are untouched — they identify request families for retry planning, not authorship.Tests
provenance.requestHashequals sha256 of the accepted call's JSON, asserts the retrieved preimage is byte-for-byte those bytes, and asserts the rejected requests' hashes have no preimage. Against the pre-fix tree, (b) fails at the hash assertion (original-request hash vs accepted-stripped hash) and (a) passes — the drift guard on the accept-time map.npm test(node 22.14.0) and on bun againstdist/test/(branch baseline 501, +2 new); 527 pass / 0 fail on the tree as rebased onto current main.tscclean. Lockfiles andnode_modulesuntouched.Not verified
persistMintPreimages: true(mint requests are large; the option exists for exactly this) has not been measured over weeks-scale runs.🤖 Generated with Claude (Claude Opus 5) — authored, cross-reviewed and twice AI-review-passed on an agent harness; every receipt above was produced by running the named commands.