Skip to content

feat(notices): redaction contract and retention policy — close out #99 acceptance item 7 - #437

Merged
ScriptedAlchemy merged 12 commits into
mainfrom
feat/99-redaction-retention
Sep 4, 2026
Merged

feat(notices): redaction contract and retention policy — close out #99 acceptance item 7#437
ScriptedAlchemy merged 12 commits into
mainfrom
feat/99-redaction-retention

Conversation

@ScriptedAlchemy

@ScriptedAlchemy ScriptedAlchemy commented Sep 3, 2026

Copy link
Copy Markdown
Owner

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.ts leftover noted there.

Summary

Redaction contract (@agent-bundle/runtime/notices)

  • notices.publish() takes sensitivity: 'public' | 'internal' | 'secret' (default internal, persisted explicitly; pre-contract notices have no class and are internal). 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) or disclosed with the route's structural shape (AGENT_NOTICE_ROUTE_SHAPES: body / title / signal) and redacted. internal is secret-pattern redacted on every route, public travels as authored, secret only where the host row admits it. Fail closed: absent ceilings mean internal, so secret never leaves the store until a host row says otherwise.
  • The ledger (createAgentNoticeLedger(store, { delivery })), the inbox resource (projection now reports sensitivity + disclosure), event admission (read() deliveries carry disclosure and disclosed content), and the resources/updated signaller (createNoticeInboxSignaller({ delivery })) all honour it; every refusal is recorded durably as withheld[route] = { count, firstAt, lastAt, reason } on the notice without moving its state.
  • Secret pass (redactSecretText, redactNoticeDocument, noticeTitle): credential assignments, provider tokens, URL userinfo — the same patterns as the compiler's redactCredentialText / probe redaction. The runtime is an optional peer of the compiler (and both packages have rootDir: src), so the module cannot be shared; NOTICE_SECRET_PATTERN_SOURCES / CREDENTIAL_TEXT_PATTERN_SOURCES are pinned byte-identical by notice-redaction-parity.test.ts, the same discipline inspect-state.test.ts applies to the state budgets. Both sides now run provider forms before assignments so an unquoted authorization: Bearer <token> no longer keeps the token; the probe service reuses the shared urlUserinfoPattern.
  • Capability rows: every built-in host's supported noticeDelivery rows carry sensitivity + dated sensitivityEvidence (2026-09-03): secret on current-response / next-event (hook response returns to the recipient's own host process), internal on mcp-inbox / mcp-resource-updated (transport-derived identity the host does not authenticate to the plugin). noticeDeliveryAdvertisementFrom rejects unknown classes and undated evidence; intersectNoticeDeliveryAdvertisements takes 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 } in agent-bundle.config.ts; defaults 7d / 500 / 16 MiB; terminalTtl is ms or <n>(ms|s|m|h|d). Malformed, unknown-key, non-positive, or declared-without-src/state.tsAB4833 (AB4829 was taken by fix(routes): reject same-server MCP App resourceUri collisions with AB4829 and sweep orphaned Playground staging files #430 on main while 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 / exhausted attempted, noticeSettledAt()) past the TTL or beyond the cap through one pruned event (reducer skips ids that are live again; records retention = { lastPrunedAt, pruneRuns, prunedTotal }), then compacts the journal when it exceeds maxJournalBytes. 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.
  • State kernel (additive; both drivers; conformance-pinned): AgentStateStore.compact() materializes the head as a compact baseline record at the next revision and deletes everything before it; AgentStateStore.inspect() reports baselineRevision, headRevision, journalBytes, records, lastCompaction. Revisions stay monotonic; exact reads below the baseline are revision-unavailable; the change cursor delivers the baseline as a compact discontinuity; pruned idempotency keys are remembered without results (revision-unavailable on replay, idempotency-conflict on conflicting reuse). SQLite does it in one BEGIN IMMEDIATE / synchronous = FULL transaction and bumps the store to kernel format 2 on first compaction (a format-1 kernel then fails closed with a typed corrupt instead of misreading the truncated journal; never-compacted stores stay format 1). The head-vs-replay check on open accepts a journal that starts at a compact baseline.
  • inspect --state and 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()); inspect runs 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.ts no longer imports from @agent-bundle/runtime/notices: GeneratedNoticeDeliveryBinding / GeneratedNoticePrincipal / GeneratedNoticeInboxSignalOutcome are spelled locally (pinned mutually assignable with the runtime types), agent-bundle/test uses them, and public-api-packed.test.ts asserts 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 build
  • pnpm typecheck
  • pnpm lint (0 errors / 0 warnings)
  • pnpm test:unit
  • pnpm test:route-unit
  • pnpm test:projection
  • pnpm build && pnpm test:integration:run (an earlier run caught the Workbench strict manifest schema rejecting noticeRetention, fixed in 224f8c9; 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.ts compaction step between publish and delivery, notice-redaction-parity.test.ts, notice-retention-config.test.ts, adapter sensitivity ceiling tests, mcp-server-runtime.test.ts binding pin, route-unit publish-with-sensitivity, Workbench routes-page.test.ts retention block + examples-real.e2e State-region assertion, public-api-packed d.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 vs origin/main), two passes.

Pass 1 (on 94aefeb34) — four P2 findings:

  1. redactNoticeDocument could grow a near-bound document past maxDocumentBytes (the mark is longer than the shortest values it replaces) — fixed in d5b8790f9: the redacted snapshot is measured and noticeRedactionPlaceholder(snapshot) is handed out past the bound; flare-redact's RedactionLimitError now fails closed to the mark in redactSecretText / 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".
  2. Signaller refusal recorded under mcp-resource-updated even when only mcp-inbox would withhold — dismissed: withheld[route] is keyed by the route that withheld, and the signaller is the mcp-resource-updated route (no signal went out); recording under mcp-inbox would 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.
  3. Docs promised credential-assignment coverage without the pinned detector limits — fixed in 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 as secret" guidance.
  4. Generated reference (en + zh) still called the ledger "append-only" — fixed in d5b8790f9: journal-backed, settled terminal notices pruned and the journal compacted under notices.retention, history bounded.

Pass 2 (on d5b8790f9) — fixes 1, 3, 4 confirmed complete (TextEncoder and Buffer.byteLength(..., 'utf8') measure the same JSON.stringify output); one new P2: noticeRedactionPlaceholder exported from @agent-bundle/runtime/notices but absent from the changeset — fixed in 0001216f0 (kept public on purpose; named in the changeset export list and the README).

CI note: the Release gates job's release-audit.test.ts failed on 8fb32935e with npm audit returning 503 from registry.npmjs.org (the same failure hit main at 284141958 and PR #456 in the same window); re-run once the registry recovers.

@changeset-bot

changeset-bot Bot commented Sep 3, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0001216

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@agent-bundle/runtime Minor
agent-bundle Minor

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

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-03T20:35:10.594146Z 698f48a Manual request
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@ScriptedAlchemy

Copy link
Copy Markdown
Owner Author

@codex review

ScriptedAlchemy added a commit that referenced this pull request Sep 3, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment on lines +17 to +19
* - `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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.

Comment on lines +147 to +148
return resolveNoticeDisclosure('mcp-resource-updated', sensitivity, advertisement).kind === 'disclosed'
&& resolveNoticeDisclosure('mcp-inbox', sensitivity, advertisement).kind === 'disclosed';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.

@ScriptedAlchemy

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment on lines +415 to +416
const disclosure = disclosures.get(notice.id)
?? Object.freeze({ kind: 'disclosed' as const, redacted: sensitivityOf(notice) === 'internal', shape: 'body' as const });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.

ScriptedAlchemy added a commit that referenced this pull request Sep 3, 2026
@ScriptedAlchemy
ScriptedAlchemy force-pushed the feat/99-redaction-retention branch from 7b3e407 to f1d1a42 Compare September 3, 2026 19:53
@ScriptedAlchemy

Copy link
Copy Markdown
Owner Author

@codex review

@pkg-pr-new

pkg-pr-new Bot commented Sep 3, 2026

Copy link
Copy Markdown
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle@437
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/create-agent-bundle@437
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/runtime@437

commit: c3c0721

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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)]),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.

Comment on lines 360 to 361
if (isBaselineRecord(record)) {
state = parseBaselineState(definition, record);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.

@ScriptedAlchemy

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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] }),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.

@ScriptedAlchemy

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment on lines +367 to +369
// Persisted explicitly: only notices from before the redaction contract
// leave the class absent.
sensitivity: sensitivity(input.sensitivity),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.

Comment on lines 473 to +475
invocationId: request.invocation.id,
noticeIds,
...(withheld.length === 0 ? {} : { withheld: [...withheld] }),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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

Comment on lines +131 to +134
return Object.freeze(Object.fromEntries(
Object.entries(value as Readonly<Record<string, JsonValue>>)
.map(([key, entry]) => [key, redactJson(entry, underSecretKey || isSecretKey(key))]),
));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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").

…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.
@ScriptedAlchemy
ScriptedAlchemy force-pushed the feat/99-redaction-retention branch from 0001216 to c3c0721 Compare September 3, 2026 23:31
@ScriptedAlchemy
ScriptedAlchemy merged commit 833e48f into main Sep 4, 2026
12 of 13 checks passed
@ScriptedAlchemy
ScriptedAlchemy deleted the feat/99-redaction-retention branch September 4, 2026 00:22
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.

1 participant