feat(notices): redaction contract and retention policy — close out #99 acceptance item 7 - #437
Conversation
🦋 Changeset detectedLatest commit: 0001216 The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a32db451d1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| * - `internal` (default): for the recipient's own context; delivered after | ||
| * the secret-pattern pass below, so a credential pasted into a coordination | ||
| * message never crosses into another actor's context. |
There was a problem hiding this comment.
Redact content returned by acknowledge
When an internal notice contains a credential, the recipient can obtain its ID from the redacted inbox and then call acknowledge(id), whose implementation returns the persisted acknowledged notice directly from committed.state (ledger.ts:639-646). That notice still contains the content as authored, so this bypasses the new egress redaction contract and exposes the credential the inbox deliberately masked. Return no content or apply an appropriate disclosure before returning the notice.
AGENTS.md reference: AGENTS.md:L31-L34
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in the next push. acknowledge() now returns the notice only as the acknowledging request's route may disclose it: an admitted event is held to the next-event ceiling, every other invocation kind to the mcp-inbox ceiling (the only read surface through which a non-event request could have seen the notice). An internal notice comes back secret-passed, public as authored, and a secret above the ceiling comes back with a [REDACTED] placeholder document — so an id learned from a redacted inbox unlocks nothing (ledger.ts acknowledgedNotice / acknowledgementRoute; test "returns acknowledged notices only as the acknowledging route may disclose them"). The store-level ledger.read() is an embedder API, not a recipient handle, and still returns authored content; the README says so.
| return resolveNoticeDisclosure('mcp-resource-updated', sensitivity, advertisement).kind === 'disclosed' | ||
| && resolveNoticeDisclosure('mcp-inbox', sensitivity, advertisement).kind === 'disclosed'; |
There was a problem hiding this comment.
Record resource-update withholding decisions
When a matching notice exceeds either MCP sensitivity ceiling, this predicate simply removes it from the signaller's eligible set, so no ledger event records withheld['mcp-resource-updated']. This contradicts the documented durable-evidence contract in packages/rsc-runtime/README.md:362-363; an inbox read may later record the distinct mcp-inbox refusal, but the resource-update route's refusal remains permanently absent. Record the withholding before returning the idle outcome.
AGENTS.md reference: AGENTS.md:L31-L34
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in the next push. The signaller now records its own refusal: AgentNoticeLedger.recordWithholding({ at, idempotencyKey, route, withheld }) commits a new withheld ledger event, and the claim loop calls it for every matching, otherwise-eligible notice whose class exceeds the mcp-resource-updated or mcp-inbox ceiling — once per subscription (tracked in the subscription's withheld set) so a refusal is evidence once, not once per render — before any send, then re-reads because the recording moved the revision. The notice then carries withheld['mcp-resource-updated'] = { count, firstAt, lastAt, reason } alongside whatever the inbox later records. Test "never signals resources/updated for a notice the inbox would withhold, and records that refusal once" pins it; README updated.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7b3e40704d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const disclosure = disclosures.get(notice.id) | ||
| ?? Object.freeze({ kind: 'disclosed' as const, redacted: sensitivityOf(notice) === 'internal', shape: 'body' as const }); |
There was a problem hiding this comment.
Honor the current ceiling for replayed deliveries
When an event invocation is reprocessed after its attempt receipt already exists, the exhausted notice is excluded from matching, so this fallback is used. For a secret notice it unconditionally constructs an unredacted body without consulting resolveNoticeDisclosure; if the same durable store is reopened after an adapter/configuration change that lowers or disables next-event, the duplicate invocation returns the authored secret despite the new ceiling. Re-evaluate the current advertisement here, or persist and replay the original disclosure decision, rather than deriving disclosure solely from sensitivity.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in the next push. The replayed-admission fallback in deliveryFor no longer invents a disclosure from the notice's class: it now resolves resolveNoticeDisclosure('next-event', …) against the ledger's current advertisement (shared currentlyDisclosedNotice, the same helper acknowledge() uses). A store reopened under a lowered or disabled next-event ceiling therefore hands the duplicate invocation the [REDACTED] placeholder (with disclosure.redacted: true) instead of the authored secret. Test "discloses a replayed admission against the current ceiling, not the one that held when it was attempted" opens two ledgers over one store — next-event: secret, then next-event: internal — replays the same event invocation and asserts the placeholder with no second attempt receipt.
7b3e407 to
f1d1a42
Compare
|
@codex review |
commit: |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f1d1a42ab3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (value === null || typeof value !== 'object') return value; | ||
| if (Array.isArray(value)) return Object.freeze(value.map((entry) => redactJson(entry as JsonValue))); | ||
| return Object.freeze(Object.fromEntries( | ||
| Object.entries(value as Readonly<Record<string, JsonValue>>).map(([key, entry]) => [key, redactJson(entry)]), |
There was a problem hiding this comment.
Redact values under credential-shaped JSON keys
When an internal notice carries structured content such as { password: "hunter2" } in a JSON node, result metadata, or the document value, this recursion passes only "hunter2" to redactSecretText(). Because the assignment pattern never sees the password key and the value is not a recognizable provider token, the credential is returned unchanged through inbox and next-event delivery. Treat credential-shaped object keys as sensitive and mask their associated values rather than inspecting values without their key context.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in the next push. redactJson now carries an underSecretKey flag: a value under a credential-shaped key — classified by the new runtime isSecretKey(), which mirrors the compiler's isCredentialKey (keyword segments, compact apikey/apitoken/authtoken/accesstoken suffixes, provider env-var names; NOTICE_SECRET_KEY_SOURCES is pinned equal to the compiler's CREDENTIAL_KEY_SOURCES and the two classifiers agree on a key corpus in notice-redaction-parity.test.ts) — is masked whole, recursively, whatever its text looks like, so { password: "hunter2" }, { auth: { password } }, { authorization: [..] }, result.metadata.credential, and value.secret all come back as [REDACTED]. The document test now covers nested credential-keyed objects and arrays.
There was a problem hiding this comment.
Update (94aefeb): per maintainer direction the secret pass is now flare-redact@1.6.1 rather than the hand-rolled isSecretKey classifier described above (that export, NOTICE_SECRET_*_SOURCES, and the cross-package parity test are gone). The library walks the JSON value and masks a string held directly under one of its credential-shaped member names (password, secret, token, api_key/apiKey, access_key, client_secret, private_key, auth/authorization, cookie, session_id, refresh_token, card fields, plus its multilingual vocabulary) whole regardless of content, so { password: "hunter2" }, { auth: { password } }, result.metadata.credential, and value.secret all leave as [REDACTED]; every other string is scanned by the default detectors. One semantic difference from the interim fix: a nested structure under a credential-shaped key (e.g. authorization: ["a", "b"]) is scanned rather than masked whole — documented in the README and pinned by the document test.
| if (isBaselineRecord(record)) { | ||
| state = parseBaselineState(definition, record); |
There was a problem hiding this comment.
Skip pre-migration compaction baselines during replay
When a store is compacted under schema version 1 and then opened with an incompatible version-2 schema, the first migration succeeds and appends a newer migrate baseline, but the retained compact record still contains version-1 state. On the next SQLite reopen—or an exact replay in memory—this loop parses that old compact baseline against the current schema before reaching the migration baseline and throws corrupt, making a valid migrated store unusable. Replay should start at the latest applicable migration baseline or otherwise avoid validating earlier compaction state with the new schema.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in the next push. replayJournal now parses only the latest baseline at or below the target revision instead of every baseline along the way: a compact (or reset) record written under an earlier definition version is never fed to the current schema once a later migrate baseline exists, because the target must already be at or after that migration. New conformance case "a compaction baseline written before a migration is never replayed against the new schema" compacts under v1, migrates to the incompatible v2 (labels required), reads exactly and at the head, commits past it, and — on the SQLite harness — closes and reopens so the head-vs-replay check runs over a journal whose first record is the stale v1 compact baseline, then compacts again to fold it away. It runs on both drivers.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b482987b1f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (ceiling === undefined) { | ||
| return Object.freeze({ kind: 'withheld', reason: 'route-unavailable' }); | ||
| } | ||
| if (compareNoticeSensitivity(sensitivity, ceiling) > 0) { |
There was a problem hiding this comment.
Reject invalid sensitivity ceilings before disclosure
When a JavaScript consumer or custom embedder supplies a supported route with an invalid ceiling such as sensitivity: "secrect", routeSensitivityCeiling() returns that value and compareNoticeSensitivity() produces NaN; this condition is therefore false and a secret notice is disclosed unredacted. createAgentNoticeLedger() reaches this function without calling validateAdvertisement() (which is currently used only by route selection), so validate or withhold unknown ceilings here to preserve the advertised fail-closed behavior.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in the next push, in two layers. routeSensitivityCeiling() now returns undefined for a ceiling outside the vocabulary, so resolveNoticeDisclosure withholds every class on such a row (route-unavailable) instead of comparing NaN; and createAgentNoticeLedger({ delivery }) and createNoticeInboxSignaller({ delivery }) both call the (now exported) validateNoticeDeliveryAdvertisement at construction, so a JavaScript embedder's sensitivity: "secrect" is a typed invalid-input before any request is judged against it. Test "fails closed on an unknown sensitivity in a row" covers the ceiling, the disclosure, the ledger constructor, and the signaller constructor.
| unavailableIds: decisions | ||
| .filter(({ decision }) => decision.state === 'unavailable') | ||
| .map(({ id }) => id), | ||
| ...(withheld.length === 0 ? {} : { withheld: [...withheld] }), |
There was a problem hiding this comment.
Replay prior withholding decisions after ceiling changes
When an event invocation first withholds a secret notice, the notice remains pending and this admitted commit records withheld under the invocation-derived idempotency key. If the same durable invocation ID is retried after the adapter/configuration raises the next-event ceiling, the notice is reconsidered as authorized and this payload no longer contains withheld; reusing the same key with a different payload then throws idempotency-conflict instead of replaying the invocation. Detect and replay the prior admission before recomputing its disclosure decision.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in the next push. Admission keeps its one-commit-per-invocation key, and a retry whose recomputed payload differs — a ceiling raised or lowered, or (the pre-existing variant) a notice published between the attempts — now catches the store's idempotency-conflict and replays: it reads the committed state, which already holds the earlier admission for this invocation, and derives deliveries from the attempt receipts carrying this invocation id (disclosed against the current ceiling, per the earlier thread). Nothing is re-attempted or re-recorded and the revision does not move. Test "replays a prior admission when the same invocation is retried under a changed ceiling" runs the same event under next-event: internal then next-event: secret over one store, then shows a genuinely new invocation under the raised ceiling does attempt the notice.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 698f48ab9d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // Persisted explicitly: only notices from before the redaction contract | ||
| // leave the class absent. | ||
| sensitivity: sensitivity(input.sensitivity), |
There was a problem hiding this comment.
Redact content on deduplicated publishes
When a caller publishes with the same recipient and a predictable dedupeKey as an existing secret notice, the dispatch deduplicates and publishProgram returns the previously persisted notice—including its authored content—without applying any disclosure decision. Publish authorization only permits writing to that recipient; it does not authorize reading the existing notice, so this provides another way to bypass the route ceilings. Return the new caller's content or disclose the persisted result through an appropriate route before returning it.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 088adfb. publishProgram returns the persisted notice's content only when it is the caller's own notice (persisted.id === prepared.id: a fresh publish or the author's idempotent replay). A publish deduplicated onto another author's notice returns its identity, state, and receipts with the content replaced by the redaction placeholder, so a guessed dedupeKey reads nothing. Test in notices-redaction.test.ts covers the publish:guess dedupe and asserts the original author's replay still sees their own text.
| invocationId: request.invocation.id, | ||
| noticeIds, | ||
| ...(withheld.length === 0 ? {} : { withheld: [...withheld] }), |
There was a problem hiding this comment.
Replay inbox exposure after ceiling changes
When the same MCP invocation is retried after the inbox ceiling changes, the recomputed exposed payload changes between withheld and noticeIds, while this dispatch reuses agent-notices:expose:<invocationId> and therefore throws idempotency-conflict. The newly added conflict-replay handling covers only the admitted dispatch, so the equivalent inbox retry remains broken; replay the prior exposure decision rather than failing the invocation.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 088adfb. The inbox exposure dispatch (agent-notices:expose:<invocationId>) now mirrors the admission path: an idempotency-conflict from a retry whose recomputed decision differs (ceiling changed in between) replays the committed exposure via store.read instead of failing, while the returned list is still judged against the current ceiling. Test: "replays a prior inbox exposure when the same invocation is retried under a changed ceiling".
| return Object.freeze(Object.fromEntries( | ||
| Object.entries(value as Readonly<Record<string, JsonValue>>) | ||
| .map(([key, entry]) => [key, redactJson(entry, underSecretKey || isSecretKey(key))]), | ||
| )); |
There was a problem hiding this comment.
Redact secret material used as JSON keys
For an internal notice containing a token as a JSON member name, such as { "sk-proj-…": true }, this recursion checks the key only to decide whether to redact descendant string values and then emits the original key unchanged. Since JSON object keys are themselves strings inside the documented free-text fields, recognizable credentials used as map keys leave through the inbox and next-event routes verbatim. Apply the secret-pattern pass to emitted keys as well as values.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 088adfb. redactJson now runs member names through redactSecretText as well (and masks the name whole under a credential-shaped parent key), so { "sk-proj-…": true } leaves as { "[REDACTED]": true }; two names that mask to the same text collapse onto one member, which loses nothing since the mark carries no information. Covered in the JSON-key cases added to notices-redaction.test.ts.
There was a problem hiding this comment.
Update (94aefeb): still holds with the library-backed pass. flare-redact scans values, not member names, so redactNoticeDocument keeps its own post-walk (freezeRedactedJson) that runs every emitted member name through redactSecretText and deep-freezes the result; { "sk-proj-…": true } still leaves as { "[REDACTED]": true } (test "redacts every prose field of a document and nothing else").
088adfb to
94aefeb
Compare
d5b8790 to
8fb3293
Compare
…acceptance item 7 - sensitivity: 'public' | 'internal' | 'secret' on publish; per-route host ceilings (noticeDelivery.*.sensitivity with dated evidence) honoured by the ledger, inbox resource, event admission, and resources/updated signaller; withholdings recorded on the notice; internal content secret-pattern redacted on egress - notices.retention config (AB4829) -> ledger retain()/inspect(); terminal notices pruned on admitted events; journal compaction via AgentStateStore.compact()/inspect() (compact baseline record, kernel format 2, pruned-key bookkeeping) on both drivers - adapter capability rows + adapterRevision bumps; inspect --state and Workbench State panel show the resolved retention policy - mcp-server-runtime.d.ts spells GeneratedNoticeDeliveryBinding locally; packed assertion that no aliased/public declaration resolves through the notices subpath
…re schema The dev server's manifest now carries the resolved notice retention policy; the browser client's strictObject rejected the unknown key and rendered the whole Routes catalog as unavailable (caught by examples-real.e2e).
…aller withholdings - acknowledge() returns the notice as the acknowledging route may disclose it (next-event for events, the inbox ceiling otherwise); a withheld class comes back as a [REDACTED] placeholder, so an id from a redacted inbox unlocks nothing - the resources/updated signaller records its refusal durably through the new AgentNoticeLedger.recordWithholding() / 'withheld' event, once per subscription
…g; renumber the retention diagnostic to AB4833 - a replayed admission (attempt receipt already present) resolves the next-event ceiling from the ledger's current advertisement instead of the notice's class, so a store reopened under a lowered ceiling never replays the authored secret - AB4829 was taken by #430 on main; the notices.retention diagnostic is AB4833 - inspect-state: the retention CLI assertions get their own bounded test
…baseline; match d.ts specifiers not prose
- redactJson masks every string under a credential-shaped key (isSecretKey, pinned
equal to the compiler's isCredentialKey) so { password: 'hunter2' } never leaks
- replayJournal parses only the latest baseline at or below the target, so a
compact/reset baseline written before a later migration is never fed to the
current schema (conformance case on both drivers)
- the packed d.ts assertion matches import specifiers, not doc comments
… a retried admission instead of conflicting - routeSensitivityCeiling admits nothing for a ceiling outside the vocabulary and the ledger and signaller validate the advertisement when constructed (invalid-input) - a retry of the same event invocation whose recomputed admission differs replays the committed admission on idempotency-conflict instead of failing the request
…ntent A guessed dedupe key must not return another author's notice text, and credential-shaped JSON member names are redacted like any other prose.
…d-rolled patterns Per maintainer direction the secret pass is an npm library, not custom code: flare-redact@1.6.1 (MIT, zero deps, browser-safe root entry) becomes an exact-pinned runtime dependency; the ledger runs its default detectors and credential-shaped member names with every finding replaced whole by [REDACTED]. The hand-rolled pattern/key sources, isSecretKey, and the cross-package parity test are removed; credentials.ts and redactProbeText return to main. README records the evaluated libraries.
…imits; bounded-history wording Self-review findings: a redacted document that grew past the Agent Document byte bound is handed out as the placeholder; RedactionLimitError falls back to the mark instead of failing the inbox; the pinned detectors' assignment and OpenAI length limits are documented as contract; the generated notice page no longer calls the ledger append-only.
…EADME Self-review second pass: the helper is exported from @agent-bundle/runtime/notices on purpose (embedders recognise the placeholder a withholding route hands out), so the release text and the README name it alongside the other new exports.
0001216 to
c3c0721
Compare
Closes the last open scope of #99 (acceptance item 7's two policy halves — the redaction policy and the retention policy — per the 2026-09-03 audit comment), plus the
mcp-server-runtime.d.tsleftover noted there.Summary
Redaction contract (
@agent-bundle/runtime/notices)notices.publish()takessensitivity: 'public' | 'internal' | 'secret'(defaultinternal, persisted explicitly; pre-contract notices have no class and areinternal). The store keeps content as authored; redaction happens on egress, per route.resolveNoticeDisclosure(route, sensitivity, advertisement)is the whole decision:withheld(route-unavailable|sensitivity-exceeds-route) ordisclosedwith the route's structuralshape(AGENT_NOTICE_ROUTE_SHAPES: body / title / signal) andredacted.internalis secret-pattern redacted on every route,publictravels as authored,secretonly where the host row admits it. Fail closed: absent ceilings meaninternal, sosecretnever leaves the store until a host row says otherwise.createAgentNoticeLedger(store, { delivery })), the inbox resource (projection now reportssensitivity+disclosure), event admission (read()deliveries carrydisclosureand disclosedcontent), and theresources/updatedsignaller (createNoticeInboxSignaller({ delivery })) all honour it; every refusal is recorded durably aswithheld[route] = { count, firstAt, lastAt, reason }on the notice without moving its state.redactSecretText,redactNoticeDocument,noticeTitle): credential assignments, provider tokens, URL userinfo — the same patterns as the compiler'sredactCredentialText/ probe redaction. The runtime is an optional peer of the compiler (and both packages haverootDir: src), so the module cannot be shared;NOTICE_SECRET_PATTERN_SOURCES/CREDENTIAL_TEXT_PATTERN_SOURCESare pinned byte-identical bynotice-redaction-parity.test.ts, the same disciplineinspect-state.test.tsapplies to the state budgets. Both sides now run provider forms before assignments so an unquotedauthorization: Bearer <token>no longer keeps the token; the probe service reuses the sharedurlUserinfoPattern.noticeDeliveryrows carrysensitivity+ datedsensitivityEvidence(2026-09-03):secretoncurrent-response/next-event(hook response returns to the recipient's own host process),internalonmcp-inbox/mcp-resource-updated(transport-derived identity the host does not authenticate to the plugin).noticeDeliveryAdvertisementFromrejects unknown classes and undated evidence;intersectNoticeDeliveryAdvertisementstakes the lowest ceiling with its evidence. Adapter revisions bumped (claude 1.25.0, codex/cursor 1.12.0, portable 1.9.0, plugin 1.28.0) and pinned. The generated "Notice delivery matrix" page renders the ceilings and evidence.Retention policy
notices.retention: { terminalTtl, maxTerminal, maxJournalBytes }inagent-bundle.config.ts; defaults7d/500/16 MiB;terminalTtlis ms or<n>(ms|s|m|h|d). Malformed, unknown-key, non-positive, or declared-without-src/state.ts→AB4833(AB4829was taken by fix(routes): reject same-server MCP App resourceUri collisions with AB4829 and sweep orphaned Playground staging files #430 onmainwhile this branch was open; renumbered on rebase). Emitted as a literal into every generated module that mounts the ledger (MCP worker + server process, routed CLI bin, rendered scripts) so all of them retain the same way.AgentNoticeLedger.retain({ at, idempotencyKey }): prunes settled terminal notices (expired/unavailable/withdrawn/acknowledged/ exhaustedattempted,noticeSettledAt()) past the TTL or beyond the cap through oneprunedevent (reducer skips ids that are live again; recordsretention = { lastPrunedAt, pruneRuns, prunedTotal }), then compacts the journal when it exceedsmaxJournalBytes. Runs automatically after event admission under a per-invocation, content-addressed key (V1: no timer).AgentNoticeLedger.inspect()returns policy, live counts by state, terminal count, retention summary, and the journal inspection — never content.AgentStateStore.compact()materializes the head as acompactbaseline record at the next revision and deletes everything before it;AgentStateStore.inspect()reportsbaselineRevision,headRevision,journalBytes,records,lastCompaction. Revisions stay monotonic; exact reads below the baseline arerevision-unavailable; the change cursor delivers the baseline as acompactdiscontinuity; pruned idempotency keys are remembered without results (revision-unavailableon replay,idempotency-conflicton conflicting reuse). SQLite does it in oneBEGIN IMMEDIATE/synchronous = FULLtransaction and bumps the store to kernel format 2 on first compaction (a format-1 kernel then fails closed with a typedcorruptinstead of misreading the truncated journal; never-compacted stores stay format 1). The head-vs-replay check on open accepts a journal that starts at acompactbaseline.inspect --stateand the Workbench State panel show the resolved retention policy and whether it was declared or defaulted. Live counts / last compaction are facts of one installed store (AgentNoticeLedger.inspect());inspectruns against source and Doctor never opens state databases by rule, so no static surface can honestly show them.Leftover from #412:
dist/mcp-server-runtime.d.tsno longer imports from@agent-bundle/runtime/notices:GeneratedNoticeDeliveryBinding/GeneratedNoticePrincipal/GeneratedNoticeInboxSignalOutcomeare spelled locally (pinned mutually assignable with the runtime types),agent-bundle/testuses them, andpublic-api-packed.test.tsasserts the installed tarball's aliased and public declarations carry no such reference.Evidence
See the "Test plan" gates below; per-item evidence against the #99 acceptance list is posted on the issue.
Test plan
All gates run locally on the rebased head (rebased onto
4edbd493b, i.e. after #421 lineage and #430–#435):pnpm buildpnpm typecheckpnpm lint(0 errors / 0 warnings)pnpm test:unitpnpm test:route-unitpnpm test:projectionpnpm build && pnpm test:integration:run(an earlier run caught the Workbench strict manifest schema rejectingnoticeRetention, fixed in224f8c9; the audiobook-curator State-region assertions pass at 1440×900 with the page settled)pnpm docs:site:build(dead-link, anchor, image, and language parity checks; the generated notice matrix renders the ceilings and evidence)New tests:
notices-redaction.test.ts(12),notices-retention.test.ts(8, incl. crash-between-prune-and-compaction recovery and SQLite reopen agreement), state conformance compaction cases ×3 on both drivers,notices-sqlite-cross-process.test.tscompaction step between publish and delivery,notice-redaction-parity.test.ts,notice-retention-config.test.ts, adapter sensitivity ceiling tests,mcp-server-runtime.test.tsbinding pin, route-unit publish-with-sensitivity, Workbenchroutes-page.test.tsretention block +examples-real.e2eState-region assertion,public-api-packedd.ts assertion.Review status
Organic reviewer threads (10, all answered and fixed in-branch; no further review solicited per the 2026-09-03 process change).
Self-review
Local reviewer subagent (
gpt-5.6-sol-high, read-only on the worktree, diff vsorigin/main), two passes.Pass 1 (on
94aefeb34) — four P2 findings:redactNoticeDocumentcould grow a near-bound document pastmaxDocumentBytes(the mark is longer than the shortest values it replaces) — fixed ind5b8790f9: the redacted snapshot is measured andnoticeRedactionPlaceholder(snapshot)is handed out past the bound; flare-redact'sRedactionLimitErrornow fails closed to the mark inredactSecretText/containsSecretText/ the JSON walk. Tests: "hands out the placeholder when redaction would grow a document past the byte bound", "fails closed to the mark when the library refuses to bound a string".mcp-resource-updatedeven when onlymcp-inboxwould withhold — dismissed:withheld[route]is keyed by the route that withheld, and the signaller is themcp-resource-updatedroute (no signal went out); recording undermcp-inboxwould claim an inbox exposure decision that never occurred. The README already states the signaller refuses a notice the inbox would withhold and records that refusal itself. Reviewer accepted on pass 2.d5b8790f9: README "Redaction", the changeset, and the module comment state the ≥4-character assignment value and 64-character OpenAI key limits as contract, with the "publish assecret" guidance.d5b8790f9: journal-backed, settled terminal notices pruned and the journal compacted undernotices.retention, history bounded.Pass 2 (on
d5b8790f9) — fixes 1, 3, 4 confirmed complete (TextEncoderandBuffer.byteLength(..., 'utf8')measure the sameJSON.stringifyoutput); one new P2:noticeRedactionPlaceholderexported from@agent-bundle/runtime/noticesbut absent from the changeset — fixed in0001216f0(kept public on purpose; named in the changeset export list and the README).CI note: the
Release gatesjob'srelease-audit.test.tsfailed on8fb32935ewithnpm auditreturning 503 fromregistry.npmjs.org(the same failure hitmainat284141958and PR #456 in the same window); re-run once the registry recovers.