Skip to content

feat(provenance): persist mint request preimages; hash the accepted request - #79

Open
nissa-seru wants to merge 24 commits into
anima-research:mainfrom
nissa-seru:feat/mint-preimage-persistence
Open

feat(provenance): persist mint request preimages; hash the accepted request#79
nissa-seru wants to merge 24 commits into
anima-research:mainfrom
nissa-seru:feat/mint-preimage-persistence

Conversation

@nissa-seru

@nissa-seru nissa-seru commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Problem

SummaryEntry.provenance.requestHash is 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) === requestHash verifies green over the wrong request — the one failure an auditor cannot see.

Changes

  • On every accepted L1 and merge mint, the authoring request is persisted as a blob in the same Chronicle store as the summary itself (content-addressed: chronicle keys blobs by sha256 of their bytes, so requestHash IS the retrieval key). Refused and quarantined attempts are not mints and are not stored.
  • New read APIs: 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 present provenance promises a verifiable hash, not a retrievable request.
  • New option persistMintPreimages (default true) for hosts that keep their own durable request log or accept unreadable provenance — mint requests are large.
  • Both mint ladders now track the request the transport ACCEPTED (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. Trace messageCount/estimatedTokens describe the accepted request too (stripping can drop whole messages); quarantine's canonicalRequestHash/variant hashes are untouched — they identify request families for retry planning, not authorship.

Tests

  • New parameterized L1 case covering both degraded-path shapes: (a) tool_use rejection → byte-distinct no-tools retry, (b) carrier 400 → stripped retry. Each captures every membrane call, asserts provenance.requestHash equals 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.
  • Full suite: 503 pass / 0 fail on npm test (node 22.14.0) and on bun against dist/test/ (branch baseline 501, +2 new); 527 pass / 0 fail on the tree as rebased onto current main. tsc clean. Lockfiles and node_modules untouched.

Not verified

  • Store-failure behavior (storeBlob throwing must never block the mint) was NOT unit-tested in this branch as of ea86518 — an earlier revision of this body claimed such a test existed; that claim was wrong, is corrected here on the record, and the test lands with the requested-changes fix. No live Chronicle outage was exercised.
  • Blob-store growth under long-lived agents with 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.

W7515 and others added 20 commits August 24, 2026 13:27
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 antra-tess 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.

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:

  1. 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.

  2. 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 grep across test/ 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.

  3. Default. Our fleet deploys from checkout, so persistMintPreimages: true activates 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 default false, or default true with 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.

@nissa-seru

Copy link
Copy Markdown
Contributor Author

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: test/mint-preimage.test.ts holds six tests and none injects a failing store — the nearest is the persistMintPreimages: false config-off test, which is a different property. The sentence was introduced when I drafted the PR body, after our internal review pass (which reviewed the branch, not the body) — so the one artifact nobody re-verified is exactly where the wrong claim lived. I'm correcting the body line now to say the property was NOT unit-tested, and the fix will add the test you describe (storeBlob throws → mint lands, provenance present, read returns null) so the corrected receipt and the real test land together. The never-blocks contract is, as you say, the load-bearing safety property; it deserves better than an asserted test.

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 sha256(preimage) === requestHash, materialization must reconstruct the original bytes exactly (the stored envelope keeps the placeholder positions and encoding so substitution is byte-faithful; the hash-verification test will run against an image-bearing mint both ways: stored form small, materialized form hashes back).

3 (default): false. Deliberate rollout wins: no retention knob invented, no cap machinery, hosts opt in per-config — and with (1) fixed, opting in is cheap for text-dominant fleets. Stated in the changelog fragment.

Fix branch incoming on this PR.

nissa-seru and others added 4 commits August 26, 2026 20:18
…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>
@nissa-seru
nissa-seru force-pushed the feat/mint-preimage-persistence branch from ea86518 to 7ce59f3 Compare August 26, 2026 22:06
@nissa-seru

Copy link
Copy Markdown
Contributor Author

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 indexOf substituting an identical base64 string in an earlier text block while the actual image stayed embedded), there is no size floor (each means each), and an encoding-form record preserves exact spelling (padding, URL-safe alphabets, whitespace positions) so materialization is byte-faithful: sha256(materialized) === requestHash holds. Measured on the shape you priced: a 12,000,174-byte image request persists as a 439-byte envelope and materializes exactly (480/480 spelling/nesting round-trips in a deterministic probe). Media already in the store is referenced, not duplicated; media the preimage path must store once is stored once and the prose now says which is which.

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 storeBlob throws proves the mint still lands, provenance stays present, and the read path returns null. Damaged-store reads also got a truer promise while we were in there: malformed envelope JSON and malformed segment records now raise MintPreimageMaterializationError with the original error attached as cause, instead of leaking raw SyntaxError/TypeError.

3 (default): persistMintPreimages is opt-in, default false — deliberate rollout, no retention knob invented; with (1) fixed, opting in is cheap for text-dominant fleets. One structural note: current main carries the feature's changelog text directly in CHANGELOG.md/Unreleased (still claiming default-true), and release-changelog.mjs merges direct prose with fragments — so a fragment-only correction would have shipped both contradictory claims. The entry now has one home: the direct entry is removed and changelog.d/mint-preimage.added.md carries the complete corrected text (a throwaway release cut verified the merged output).

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 indexOf mislocation and an over-claiming accounting API, which was removed rather than corrected), directed repairs, and a full-suite gate before this push. 615/615 pre-existing tests green under both node and bun; the targeted suite grew from 16 to 22 with the six review reds.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants