@blamejs/core 0.14.10 → 0.14.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.14.x
10
10
 
11
+ - v0.14.12 (2026-05-31) — **Vault key rotation re-seals AAD-bound storage under the new root instead of silently orphaning it.** Every AAD-sealed cell derives its key from the live vault root, so rotating the vault keypair changes those keys. `b.vaultRotate.rotate` previously re-sealed only legacy `vault:`-prefixed cells in `db.enc` and skipped `vault.aad:` cells, AAD-bound at-rest files, and operator-supplied AAD stores — leaving them encrypted under the retired keypair while still returning a success result and a passing round-trip verify, so the loss was invisible until the old keypair was discarded and the cells became permanently undecryptable. Rotation now re-seals `db.enc` (preserving its dataDir-bound AAD), `db.key.enc` (location-bound), every `{ aad: true }` table column, and the overflow store under the new root; refuses up front with a fail-closed error when operator-supplied AAD stores (agent idempotency / orchestrator / tenant / snapshot) are reachable unless each has been re-sealed via its module hook and explicitly acknowledged; and the round-trip verify now decrypts AAD-sealed cells under the new root and treats any cell that still opens under the old root as a regression. New explicit-root `b.vault.aad` seal / unseal / reseal primitives carry a cell from the old root to the new one while preserving its AAD tuple; `b.archive.rewrapTenant` re-wraps tenant-scoped archive envelopes; and `b.cluster` can adopt a rotated vault-key fingerprint instead of partitioning the membership during a rolling rotation. **Added:** *`b.vault.aad.sealRoot` / `unsealRoot` / `resealRoot`* — Explicit-root variants of the AAD seal / unseal that take a root-keypair JSON (`b.vault.getKeysJson()` output) instead of reading the live vault singleton. `resealRoot(value, aadParts, oldRootJson, newRootJson)` opens a cell under the old root and re-seals it under the new one while preserving the same AAD tuple (`table` / `rowId` / `column` / `schemaVersion`), which is what lets a rotation worker move AAD-bound state across a keypair change without altering the bound context. The default-root `b.vault.aad.seal` / `unseal` behaviour is unchanged. · *Per-store AAD re-seal hooks on the agent primitives* — `b.agent.idempotency.reseal`, `b.agent.orchestrator.reseal`, `b.agent.snapshot.reseal`, and the `b.agent.tenant` registry / tenant-cell reseal paths re-seal that module's AAD-bound rows from an old root to a new root over the operator's own store. Each module also exposes an `AAD_ROTATION` descriptor naming the store the rotation pipeline cannot reach on its own, so an operator can enumerate exactly what to re-seal before a rotation. · *`b.archive.rewrapTenant`* — Re-wraps a `recipient: "tenant"` archive envelope from an old vault root to a new one for a given `tenantId`, so a keypair rotation does not strand tenant-scoped archives. Opens the blob under the old root + tenantId, refuses a blob that is not a tenant-recipient envelope or that does not open under the supplied old root, and emits a fresh envelope bound to the new root. This is offered alongside the documented re-export path (decrypt with the old keypair, re-archive with the new one) for operators who hold the envelope but not the source. · *Cluster vault-key rotation acceptance* — A vault-key rotation changes the public-key fingerprint recorded in the canonical cluster-state row, which a peer would otherwise report as `VAULT_KEY_DRIFT`. `b.cluster` configuration gains `acceptVaultKeyRotation: true` to declare the change legitimate — the node adopts the rotated fingerprint and bumps a rotation epoch instead of refusing — and an optional `expectedVaultKeyFp` that narrows acceptance to a single blessed post-rotation fingerprint. The drift guard stays in force whenever a rotation is not declared; supplying `expectedVaultKeyFp` without `acceptVaultKeyRotation` is rejected at configuration time as a misconfiguration. **Changed:** *`b.vaultRotate.rotate` refuses when reachable AAD stores are not acknowledged* — Because the rotation pipeline walks only `db.enc` and cannot introspect an operator's own AAD-backed stores, it now detects which AAD-store modules are loadable and throws `vault-rotate/external-aad-unresealed` unless `opts.externalAadResealed` is either `true` (you do not use those features) or an array naming every detected store (you have re-sealed each via its hook). This converts a path that previously discarded data and reported success into a fail-closed gate. The error names each store and the hook to call. **Fixed:** *Rotation re-seals `vault.aad:` cells and AAD-bound at-rest files* — `db.enc` is re-written bound to its dataDir-scoped AAD (it was previously re-written un-bound, silently stripping the at-rest AAD binding on every rotation), `db.key.enc` retains its location-bound AAD, and every `{ aad: true }` table column plus the overflow store is re-sealed under the new root. Previously only `vault:`-prefixed cells were carried across, so AAD-sealed data was left encrypted under the retired keypair and lost once it was discarded. · *Round-trip verify no longer reports a false success* — `b.vaultRotate.verify` now samples and decrypts AAD-sealed cells under the new root and treats any cell that still decrypts under the old root as a regression, so an incomplete rotation fails verification instead of passing it. The prior verify checked only `vault:` cells and therefore reported `ok` even when AAD-sealed cells had been orphaned. **Security:** *A vault key rotation can no longer silently destroy encrypted data* — The orphaning path lost agent idempotency / orchestrator / tenant / snapshot state, `{ aad: true }` columns, and tenant archives with no error and a passing verify; the data became unrecoverable the moment the old keypair was retired. Rotation is now fail-closed end to end: it re-seals what it can reach, refuses to proceed past what it cannot until you acknowledge it, and verifies the result under the new root. If you performed a rotation on v0.14.11 or earlier and still hold the retired keypair, re-seal the affected cells under the current root with the explicit-root primitives before discarding it. **Detectors:** *AAD-backed store modules must expose a rotation reseal path* — A new check flags a module that registers an external `{ aad: true }` store but does not expose an `AAD_ROTATION` descriptor and reseal hook, which would leave its state unreachable by the rotation pipeline. · *A root-keyed seal family must ship its reseal* — A new check flags adding a `sealRoot` / `unsealRoot` pair without the matching `resealRoot`, since without it a rotated cell cannot be carried from the old root to the new one. · *Live-root AAD seals need a reseal path* — A new check flags a primitive that AAD-seals under the live vault root without a way to re-seal that state under a new root during rotation. · *Tenant archive re-wrapping must compose `b.archive.rewrapTenant`* — A new check flags tenant-scoped archive re-wrapping that opens and re-seals a tenant envelope by hand instead of routing through `b.archive.rewrapTenant`. · *Cluster vault-key drift needs the rotation-epoch accept gate* — A new check flags a cluster vault-key fingerprint comparison that hard-rejects a mismatch without honouring the `acceptVaultKeyRotation` epoch window. **Migration:** *Re-seal operator AAD stores before rotating* — Before calling `b.vaultRotate.rotate`, re-seal each AAD-backed store you use via its hook (`b.agent.idempotency.reseal`, `b.agent.orchestrator.reseal`, `b.agent.snapshot.reseal`, the `b.agent.tenant` `AAD_ROTATION` reseal paths) with the old and new root JSON, re-wrap tenant archives with `b.archive.rewrapTenant`, then pass `opts.externalAadResealed` as an array naming each re-sealed store. If you use none of these features, pass `opts.externalAadResealed: true`. Declare the rotation to each cluster node with `acceptVaultKeyRotation: true` so the membership adopts the new fingerprint rather than reporting drift.
12
+
13
+ - v0.14.11 (2026-05-31) — **Defensive LLM model-I/O primitives, C2PA timestamp countersignatures with CAWG identity assertions, and signed EU AI Act GPAI adherence declarations.** Closes the output side of the LLM trust boundary and hardens content provenance and AI-Act attestation. b.ai.output.sanitize treats model output as untrusted and neutralizes XSS, gates every markdown-image / link and HTML src/href URL against SSRF (the EchoLeak zero-click exfiltration class, CVE-2025-32711), and flags SQL- and command-shaped fragments; b.ai.output.redact strips PII and secret disclosures. b.ai.input.classifyWithSources classifies a prompt together with its retrieval-augmented sources under a stricter, trust-tier-relative threshold, and the new b.ai.prompt namespace assembles prompts with escape-by-default boundaries — untrusted context / user segments are fenced in a per-render crypto-nonce delimiter the content cannot forge and stripped of bidi, control, zero-width, and Unicode-Tags smuggling characters. b.contentCredentials COSE signatures now carry an RFC 3161 timestamp countersignature (C2PA sigTst2, RFC 9921) verified entirely through b.tsa, so a signed manifest stays verifiable after its signing certificate expires, plus a CAWG identity assertion with trust-anchored verification. b.compliance.aiAct.gpai.declareAdherence emits a tamper-evident, ML-DSA-87-signed GPAI Code-of-Practice adherence declaration whose obligation set is derived from the regulation rather than operator-asserted. **Added:** *`b.ai.output.sanitize` and `b.ai.output.redact`* — A new `b.ai.output` namespace that treats LLM output as untrusted before it reaches a browser, a downstream fetcher, a SQL / command sink, or a log. `sanitize(text, opts)` neutralizes active markup via `b.guardHtml`, gates every markdown image / link and HTML `src` / `href` URL through `b.safeUrl.parse` (scheme + credential) and `b.ssrfGuard.classify` (internal / loopback / link-local / cloud-metadata IP-range) so auto-fetch URLs to attacker or internal hosts are neutralized, and flags SQL- and command-shaped fragments rather than silently repairing them. `redact(text, opts)` strips PII and secret disclosures via `b.redact` plus an entity-selectable pass (`pan` / `ssn` / `ein` / `iban` / `jwt` / `aws` / `phi` / `email` / `phone`). Defends OWASP LLM05:2025 Improper Output Handling and LLM02:2025 Sensitive Information Disclosure; the markdown-image URL gate closes the EchoLeak zero-click exfiltration class (CVE-2025-32711, CVSS 9.3). · *`b.ai.input.classifyWithSources`* — Classifies a prompt together with its retrieval-augmented (RAG) sources, applying a stricter, trust-tier-relative threshold to retrieved data. Each source is `{ id, text, trust? }` with `trust` of `trusted` / `internal` / `untrusted` (unset defaults to `untrusted`, fail-closed); untrusted and internal sources escalate to `suspicious` on a single severity-2 signal and to `malicious` on any severity-3, where the direct prompt keeps the baseline threshold. The aggregate verdict is the worst across the prompt and all sources, and every malicious source is reported in `taintedSources`. Defends indirect prompt injection from poisoned context (OWASP LLM01:2025; NIST AI 600-1). · *`b.ai.prompt.template`* — A new `b.ai.prompt` namespace for assembling LLM prompts with escape-by-default boundaries. The `system` segment is operator-trusted; `context` and `user` segments are treated as untrusted (no global opt-out — mark a segment `{ text, trusted: true }` individually). Untrusted segments are wrapped in a per-render, high-entropy delimiter nonce the content cannot forge, with any forged boundary stripped before wrapping (spotlighting / datamarking, Microsoft 2024; NIST AI 100-2e2025), and stripped of bidi overrides (CVE-2021-42574 Trojan Source), C0 controls, zero-width characters, null bytes, and Unicode Tags (U+E0000..U+E007F ASCII-smuggling). Run `b.ai.input.refuseIfMalicious` on the untrusted content as defense in depth. · *C2PA RFC 3161 timestamp countersignature and CAWG identity assertion* — `b.contentCredentials.signCose` attaches an RFC 3161 timestamp countersignature (C2PA `sigTst2`, RFC 9921) and `b.contentCredentials.verifyCose` verifies it. Pass `timestamp:{ token }` to embed a TimeStampToken, or `timestamp:{}` to get back the DER `application/timestamp-query` to POST to a timestamp authority. `b.contentCredentials.attachIdentityAssertion` / `verifyIdentityAssertion` add the CAWG Identity Assertion v1.2: a signed creator / organization identity hash-bound to a manifest's referenced assertions, where the `x509` binding reports `verified:true` only when an identity trust anchor is supplied and the leaf chain verifies, and the `identity-claims-aggregator` and self-asserted paths stay `verified:false`. · *`b.compliance.aiAct.gpai.declareAdherence` / `verifyAdherence`* — Signed, tamper-evident GPAI Code-of-Practice adherence declarations (Regulation (EU) 2024/1689 Art. 53(1)(a-d); Art. 55 for systemic-risk models under Art. 51(2)). The in-scope obligation set is derived from the classifier, never operator-asserted — a model at or above the 10^25-FLOP systemic-risk threshold that omits the Art. 55 chapter is refused. Each commitment's evidence reference must be a SHA3-512 digest; a malformed hash is rejected so a hollow attestation cannot bind. The declaration ships inside an ML-DSA-87-signed CycloneDX 1.6 ML-BOM via `b.ai.modelManifest`; verify re-canonicalizes before trusting any field and rejects a declaration past its validity window. Cites the GPAI Code of Practice (10 July 2025), Annex XI/XII, and Directive (EU) 2019/790 Art. 4(3). **Security:** *Model output is now an untrusted channel by default* — When feeding retrieved documents into an LLM, classify them with `b.ai.input.classifyWithSources` (untrusted sources escalate on a single signal) rather than trusting model input; assemble prompts with `b.ai.prompt.template` so untrusted context / user text is fenced in a per-render crypto-nonce boundary it cannot forge; and pass model output through `b.ai.output.sanitize` / `b.ai.output.redact` before it is rendered, fetched, or logged. Each primitive is on by default and fail-closed — no opt-in flag enables the protection. · *Timestamp verification routes only through `b.tsa.verifyToken`* — C2PA `sigTst2` verification performs the full RFC 3161 check (CMS signature over the signed attributes, messageDigest recompute, critical sole `id-kp-timeStamping` EKU) — never a chain-only shortcut — closing the timestamp-validation-bypass class (CVE-2025-52556, CWE-347). Supply `timestampTrustAnchorsPem` to `verifyCose` to check the timestamp certificate chain; `verifyCose` returns `{ valid, reason, claims, alg, timestamp }` and never throws. **Detectors:** *LLM output URLs must keep the SSRF gate* — A new check requires the output sanitizer to gate every extracted URL through both `b.safeUrl.parse` and `b.ssrfGuard.classify`, so the markdown-image SSRF gate (the EchoLeak class) cannot be silently dropped. · *RAG sources must compose `classifyWithSources`* — A new check flags any code that maps `b.ai.input.classify` over a sources array by hand, which would lose the trust-tier-relative threshold for retrieved data. · *Prompt boundaries must use a per-render nonce* — A new check flags prompt-assembly that wraps untrusted content in a fixed, guessable literal fence (`<user_input>`, `[DATA]`) instead of a per-render high-entropy delimiter the content cannot forge. · *C2PA timestamp verification must route through `b.tsa`* — A new check flags any bespoke certificate-chain-only walk on a timestamp token in place of `b.tsa.verifyToken`, preventing a re-introduction of the timestamp-validation-bypass class. · *GPAI adherence declarations must be signed* — A new check flags any code that emits the GPAI Code-of-Practice adherence property without routing it through the `b.ai.modelManifest` signed envelope, keeping the declaration tamper-evident.
14
+
11
15
  - v0.14.10 (2026-05-31) — **Full-text-search token hashes move to a keyed MAC; existing mail-store search indexes rebuild automatically on upgrade.** The mail-store full-text-search index hashed its tokens with a hand-rolled salted-SHA3 derived hash. It now routes through the framework's sealed-column hashing primitive in keyed mode (HMAC-SHAKE256 off the per-deployment MAC key), so a search-index token hash is unforgeable and un-correlatable across deployments without that key — the same posture the sealed-column lookup hashes already use. Because the keyed hash changes the stored token values, a mail-store opened after upgrade detects its index as old-format and rebuilds it once from the sealed message rows. The rebuild runs under a format marker: the index is marked `rebuilding` before it is cleared and only marked current after every row is re-hashed inside an explicit transaction, and search falls back to its cursor path (rather than returning partial hits) whenever the marker is not current — so an interrupted rebuild leaves the old index intact and queryable and retries on the next open, never serving a half-built index. A new `b.cryptoField.computeNamespacedHash` primitive backs the keyed hashing for callers that hash outside the registered-column path. **Added:** *`b.cryptoField.computeNamespacedHash`* — A mode-aware namespaced hash for indexed-lookup callers that hash a value outside the registered-column derived-hash path. `computeNamespacedHash(ns, value, { mode, truncateBytes })` routes through the same engine as `computeDerived` — `salted-sha3` (default) or the keyed `hmac-shake256` — with optional hex truncation. The mail-store full-text index is the first consumer. **Changed:** *Mail-store full-text index rehashes to a keyed MAC on upgrade* — The full-text-search token hash now uses `b.cryptoField.computeNamespacedHash` in `hmac-shake256` mode instead of a hand-rolled salted-SHA3. The first time a store is opened after upgrade, its index is detected as old-format and rebuilt once from the sealed message rows; subsequent opens are no-ops. Search is unaffected once the rebuild completes. The rebuild requires the vault to be initialized and fails closed (a clear error) at construction if it is not, rather than leaving a stale searchable index. **Security:** *Keyed, un-correlatable full-text-search token hashes* — A search-index token hash is now a keyed MAC over a per-deployment key, not a static-salted digest — it cannot be forged or correlated across deployments without that key, closing the low-entropy-token correlation gap on the search index. The index remains unrecoverable from a database dump alone, as before. **Detectors:** *Hand-rolled lookup-hash check covers the split form* — The check that requires sealed-column lookup hashes to compose the framework primitive now also catches the across-lines hand-roll (`var salt = getDerivedHashSalt(); var hex = salt.toString(...); sha3(hex + ns + value)`), not only the single-expression form, so the bypass that the mail-store index used can't reappear. **Migration:** *Automatic, one-time full-text index rebuild* — No operator action is required: the rebuild runs automatically and idempotently on first open after upgrade, atomically and crash-safe (an interrupted rebuild keeps the old index and retries). The only requirement is that the vault is initialized before the mail-store is constructed. One caveat for shared stores: do not run a pre-upgrade and post-upgrade node against the same backend file concurrently across this format change — the old node would write old-format hashes the new node cannot match. Roll the deployment fully across the upgrade. This re-open condition is lifted once all nodes are on 0.14.10 or later.
12
16
 
13
17
  - v0.14.9 (2026-05-30) — **Corrects EU AI Act doc paths that named an uncallable namespace, plus source-comment hygiene and two new codebase checks.** A documentation fix and internal hygiene. The `@primitive` / `@signature` / `@example` blocks for the EU AI Act fundamental-rights-impact-assessment and GPAI training-data-summary helpers advertised `b.complianceAiAct.*`, which is undefined — the callable path is `b.compliance.aiAct.*` — so an operator copying the documented call got `undefined is not a function`. The documented paths now match the real surface. Alongside that: a duplicate parser entry in a doc block is removed, version stamps embedded in section-divider comments are stripped, and two codebase checks are added — one that fails the build when a `@primitive` block documents a wholly-unresolvable namespace (the gap that hid the AI Act paths), and one that flags a version stamp left inside a section divider. No exported API, error code, wire format, or runtime behaviour changes. **Changed:** *Source-comment hygiene* — Removed a duplicate `env` entry from the parsers `@module` doc block, and stripped internal version stamps (`vX.Y.Z`) from `// ---- ... ----` section-divider comments across several files, keeping the descriptive label. Comment-only; no behaviour change. **Fixed:** *EU AI Act helper documentation named an uncallable path* — `b.compliance.aiAct.fundamentalRightsImpactAssessment` and `b.compliance.aiAct.gpai.trainingDataSummary` were documented as `b.complianceAiAct.*` in their `@primitive` / `@signature` / `@example` blocks (and one returned reference string). `b.complianceAiAct` is undefined, so the documented call failed; the documented paths now match the callable surface. **Detectors:** *`@primitive` reachability covers wrong-namespace paths* — The reachability check previously only flagged a missing leaf on a resolved namespace; a `@primitive` whose entire dotted prefix is unresolvable (the shape that hid the AI Act doc paths) was silently skipped. It now walks each prefix segment and fails the build on any unresolvable one, while preserving the factory-instance-shorthand exemption. · *Version-stamp-in-divider check* — A new check flags a version stamp (`vX.Y.Z`) left immediately after a section divider's dashes (`// ---- vX.Y.Z ...`) — internal release vocabulary that does not belong in shipped source comments — without matching legitimate `@since` tags or prose version references.
package/README.md CHANGED
@@ -98,7 +98,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
98
98
  - **At-rest envelope** — envelope-versioned PQC (ML-KEM-1024 + P-384 hybrid, XChaCha20-Poly1305, SHAKE256); vault sealing (`b.crypto`, `b.vault`)
99
99
  - **Power-on self-test** — `b.crypto.selfTest()` runs FIPS 140-3-style integrity checks: NIST FIPS 202 known-answer tests (SHA3-256/512, SHAKE256), AEAD round-trip + tamper-detect, and ML-KEM-1024 / ML-DSA-87 / SLH-DSA-SHAKE-256f pairwise-consistency + negative tests; fails closed (throws) on any mismatch
100
100
  - **Field-level + crypto-shred** — `b.cryptoField.eraseRow`; per-column data residency tagging + per-row keys (`K_row = HKDF(K_table, rowId)`) so erasing the per-row key makes WAL / replica residuals undecryptable (`b.cryptoField.declareColumnResidency`, `b.cryptoField.declarePerRowKey`)
101
- - **AAD-bound sealed columns** — AEAD tag tied to `(table, rowId, column, schemaVersion)`; copy-paste between rows or schema-version replay surfaces as refused decrypt (`b.vault.aad`). The database encryption key is sealed the same way — bound to its purpose, data directory, and key path — so a relocated key file fails to unseal; an older unbound key upgrades itself on first load
101
+ - **AAD-bound sealed columns** — AEAD tag tied to `(table, rowId, column, schemaVersion)`; copy-paste between rows or schema-version replay surfaces as refused decrypt (`b.vault.aad`). The database encryption key is sealed the same way — bound to its purpose, data directory, and key path — so a relocated key file fails to unseal; an older unbound key upgrades itself on first load. A vault-key rotation re-seals every AAD-bound cell, the database key, and tenant archives under the new keypair and refuses rather than silently orphaning a store it cannot reach (`b.vaultRotate`, `b.vault.aad.resealRoot`, `b.archive.rewrapTenant`)
102
102
  - **Keyed lookup hashes** — sealed-column equality-lookup hashes default to salted SHA3-512 and can opt into a keyed `hmac-shake256` MAC off a per-deployment key (`cryptoField.registerTable({ derivedHashMode })`, `b.vault.getDerivedHashMacKey`), making the lookup hash unforgeable and un-correlatable across deployments
103
103
  - **Signed webhooks + API encryption** — SLH-DSA-SHAKE-256f default; ML-DSA-65 opt-in; ECIES API encryption (`b.webhook`, `b.crypto`)
104
104
  - **HPKE / HTTP signatures** — RFC 9180 HPKE with ML-KEM-1024 + HKDF-SHA3-512 + ChaCha20-Poly1305 (`b.crypto.hpke`); RFC 9421 HTTP Message Signatures with derived components and ed25519 / ML-DSA-65 (`b.crypto.httpSig`); RFC 9530 Content-Digest / Repr-Digest body-integrity fields (SHA-256 / SHA-512, legacy algorithms refused — `b.contentDigest`) to sign the digest rather than the whole body
@@ -183,13 +183,16 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
183
183
  - `b.mcp.capability.create` — least-privilege capability scopes (OWASP LLM08)
184
184
  - `b.mcp.validateToolInput` — JSON Schema 2020-12 input enforcement
185
185
  - **GraphQL Federation** — `_service.sdl` trust-boundary with router-token + nonce store (`b.graphqlFederation`)
186
- - **Prompt-injection classification** — OWASP LLM01:2025 / NIST COSAIS RFI (`b.ai.input.classify`)
186
+ - **Prompt-injection classification** — OWASP LLM01:2025 / NIST COSAIS RFI (`b.ai.input.classify`), with per-source trust-tier classification for retrieval-augmented context (`b.ai.input.classifyWithSources`) and escape-by-default prompt assembly that fences untrusted segments in a per-render crypto-nonce delimiter the content can't forge (`b.ai.prompt.template`)
187
+ - **LLM output handling** — treats model output as untrusted before it reaches a browser / downstream fetcher / SQL / log: XSS neutralization with SSRF-gated markdown-image and link URLs (the EchoLeak zero-click exfiltration class, CVE-2025-32711) and SQL / command-shape flagging (`b.ai.output.sanitize`), plus PII / secret redaction (`b.ai.output.redact`); OWASP LLM05:2025 + LLM02:2025
187
188
  - **Agent identity** — A2A signed agent-card primitive (Linux Foundation Agentic AI Foundation v1.x, ML-DSA-87) (`b.a2a`)
188
- - **Content provenance** — C2PA 2.1 + California SB-942 / AB-853 manifest builder for AI-generated media (provider, model id + version, timestamp, content ID, signed) (`b.contentCredentials`)
189
+ - **Content provenance** — C2PA 2.1 + California SB-942 / AB-853 manifest builder for AI-generated media (provider, model id + version, timestamp, content ID, signed) (`b.contentCredentials`); COSE signatures carry an RFC 3161 timestamp countersignature (C2PA `sigTst2`, RFC 9921) verified through `b.tsa` so a manifest stays verifiable after its signing certificate expires, plus a CAWG identity assertion with trust-anchored verification
189
190
  - **AI usage quotas** — per-tenant / per-model budgets metered by tokens / requests / cost-usd / compute-hours over calendar-aligned windows, with an atomic conditional reserve (no charge-then-refund race) + hard/soft/warn enforcement and an optional cross-node store; defends OWASP LLM10:2025 unbounded consumption / denial-of-wallet (`b.ai.quota`)
190
191
  - **AI capability routing** — model-capability registry (context window / modalities / tool use / reasoning tier / cost rates) + a router that picks the cheapest model satisfying a request's requirements, refusing capability mismatches before the inference call (NIST AI RMF MAP + Model Cards); composes with `b.ai.quota` cost budgets (`b.ai.capability`)
191
192
  - **AEDT bias audit** — NYC Local Law 144 bias-audit figures (`b.ai.aedtBiasAudit`): selection / scoring rates and EEOC four-fifths-rule impact ratios across sex, race/ethnicity, and their intersection, with the most-selected group and adverse-impact flags (impact ratio < 0.8) for the annual published summary; sub-2% categories excludable per DCWP §5-301
192
193
  - **Frontier AI protocol** — California SB 53 (Transparency in Frontier AI Act) obligations (`b.ai.frontierModelProtocol`): classify the frontier-model (>10²⁶ training FLOPs) and large-frontier-developer (>$500M revenue) thresholds, enumerate the resulting obligations, check a safety framework for required elements, and build a critical-safety-incident report with the 15-day / 24-hour California OES notification deadline (`.incidentReport`)
194
+ - **GPAI Code-of-Practice adherence** — signed, tamper-evident EU AI Act Art. 53 / 55 adherence declarations with a regulation-derived obligation set (a systemic-risk model omitting the Art. 55 chapter is refused) and SHA3-512 evidence binding, emitted inside an ML-DSA-87-signed CycloneDX 1.6 ML-BOM and replay-checked on verify (`b.compliance.aiAct.gpai.declareAdherence` / `verifyAdherence`)
195
+
193
196
  ### Compliance regimes
194
197
 
195
198
  - **Posture coordinator** — `b.compliance` cascades operator-declared regime into retention / audit / db / cryptoField via POSTURE_DEFAULTS:
package/index.js CHANGED
@@ -139,6 +139,8 @@ var sse = require("./lib/sse");
139
139
  var mcp = require("./lib/mcp");
140
140
  var graphqlFederation = require("./lib/graphql-federation");
141
141
  var aiInput = require("./lib/ai-input");
142
+ var aiOutput = require("./lib/ai-output");
143
+ var aiPrompt = require("./lib/ai-prompt");
142
144
  var a2a = require("./lib/a2a");
143
145
  var darkPatterns = require("./lib/dark-patterns");
144
146
  var budr = require("./lib/budr");
@@ -471,6 +473,8 @@ module.exports = {
471
473
  ai: {
472
474
  adverseDecision: require("./lib/ai-adverse-decision"),
473
475
  input: aiInput,
476
+ output: aiOutput,
477
+ prompt: aiPrompt,
474
478
  aiContentDetect: require("./lib/ai-content-detect"),
475
479
  modelManifest: require("./lib/ai-model-manifest"),
476
480
  disclosure: require("./lib/ai-disclosure"),
@@ -69,6 +69,8 @@ var safeJson = require("./safe-json");
69
69
  var guardIdempotencyKey = require("./guard-idempotency-key");
70
70
  var agentAudit = require("./agent-audit");
71
71
  var { boundedMap } = require("./bounded-map");
72
+ var vaultAad = require("./vault-aad");
73
+ var validateOpts = require("./validate-opts");
72
74
 
73
75
  // The default in-memory backend is keyed on (method, actorId, keyHash) —
74
76
  // the key hash comes from request-supplied idempotency keys, so a flood of
@@ -540,10 +542,121 @@ function _safeAudit(auditImpl, action, actor, metadata) {
540
542
  agentAudit.safeAudit(auditImpl, action, actor, metadata);
541
543
  }
542
544
 
545
+ // ---- Vault-key rotation: out-of-band reseal hook -------------------------
546
+ //
547
+ // The cached-result column is AAD-sealed on an OPERATOR-SUPPLIED backing
548
+ // store (opts.store / the in-memory default), NOT in the framework's db.enc.
549
+ // The vault-key rotation pipeline (b.vaultRotate.rotate) only walks tables
550
+ // that live inside db.enc, so it cannot reach this store — the rows would be
551
+ // ORPHANED under the old vault root after a rotation (CWE-320 cryptographic-
552
+ // key-management failure: ciphertext stranded under a retired key, then
553
+ // unreadable once the old keypair is destroyed). This reseal hook lets an
554
+ // operator rotate the store out-of-band, composing the SAME explicit-root
555
+ // primitive the in-tree pipeline uses (vaultAad.resealRoot) and the SAME
556
+ // AAD builder the seal path used (cryptoField._aadParts) so the re-sealed
557
+ // AAD tuple is byte-identical — one source of truth, no drift.
558
+ //
559
+ // Reseal store contract (the durable SQL / Redis backend the operator
560
+ // already wired for opts.store also exposes):
561
+ // - listAll() → array of every stored row (each row carries the
562
+ // sealed `resultBlob` column + its keyHash identity).
563
+ // - putResealed(row) → write the row back, addressed by its own stored
564
+ // identity (keyHash). Distinct from the per-request
565
+ // put(method, actorId, key) because reseal addresses
566
+ // a row by the row identity already on disk, not by
567
+ // the raw idempotency key (which is never stored —
568
+ // only its namespaced keyHash is).
569
+ /**
570
+ * @primitive b.agent.idempotency.reseal
571
+ * @signature b.agent.idempotency.reseal(opts)
572
+ * @since 0.14.12
573
+ * @status stable
574
+ * @compliance gdpr, soc2
575
+ * @related b.vault.getKeysJson, b.cryptoField.sealRow
576
+ *
577
+ * Re-seals every AAD-bound cached-result cell on an operator-supplied
578
+ * store from the OLD vault keypair to the NEW one, out-of-band. The
579
+ * in-tree vault-key rotation pipeline only walks tables inside `db.enc`,
580
+ * so an operator-supplied idempotency store is unreachable to it — after a
581
+ * keypair rotation its cells would otherwise be orphaned under the retired
582
+ * root (CWE-320). Composes the same AAD-cell re-seal the rotation pipeline
583
+ * uses, rebuilding each cell's AAD from the registered schema (one source
584
+ * of truth). Only AAD-sealed cells are touched; plain rows pass through.
585
+ *
586
+ * @opts
587
+ * store: Object, // { listAll(): rows[], putResealed(row) } (sync or async)
588
+ * oldRootJson: string, // b.vault.getKeysJson() of the retired keypair
589
+ * newRootJson: string, // b.vault.getKeysJson() of the new keypair
590
+ *
591
+ * @example
592
+ * await b.agent.idempotency.reseal({ store: durableStore, oldRootJson: oldKeys, newRootJson: newKeys });
593
+ * // → { table: "agent_idempotency", resealed: 12 }
594
+ */
595
+ function reseal(args) {
596
+ args = args || {};
597
+ validateOpts.requireNonEmptyString(args.oldRootJson,
598
+ "reseal: oldRootJson (b.vault.getKeysJson() of the OLD keypair)",
599
+ AgentIdempotencyError, "agent-idempotency/bad-root");
600
+ validateOpts.requireNonEmptyString(args.newRootJson,
601
+ "reseal: newRootJson (b.vault.getKeysJson() of the NEW keypair)",
602
+ AgentIdempotencyError, "agent-idempotency/bad-root");
603
+ var store = args.store;
604
+ validateOpts.requireMethods(store, ["listAll", "putResealed"],
605
+ "reseal: operator store (so every persisted row can be re-sealed out-of-band)",
606
+ AgentIdempotencyError, "agent-idempotency/bad-reseal-store");
607
+ _ensureSealTable();
608
+ var schema = cryptoField().getSchema(SEAL_TABLE);
609
+ // listAll / putResealed may be sync (in-memory) or async (durable SQL /
610
+ // Redis). Thread both through Promise.resolve so either shape works.
611
+ return Promise.resolve(store.listAll()).then(function (rows) {
612
+ if (!Array.isArray(rows)) {
613
+ throw new AgentIdempotencyError("agent-idempotency/bad-reseal-store",
614
+ "reseal: store.listAll() must resolve to an array of rows");
615
+ }
616
+ var chain = Promise.resolve();
617
+ var resealed = 0;
618
+ rows.forEach(function (row) {
619
+ if (!row || typeof row !== "object") return;
620
+ var changed = false;
621
+ for (var f = 0; f < schema.sealedFields.length; f += 1) {
622
+ var column = schema.sealedFields[f];
623
+ var value = row[column];
624
+ // Only AAD-sealed cells need rotating. Vault-less / pre-sealing rows
625
+ // carry plain JSON; a plain `vault:` cell would have been written by
626
+ // a non-AAD path that doesn't exist for this table — leave both
627
+ // untouched (resealRoot would throw not-sealed on a plain value).
628
+ if (typeof value !== "string" || !vaultAad.isAadSealed(value)) continue;
629
+ var aadParts = cryptoField()._aadParts(schema, SEAL_TABLE, column, row);
630
+ row[column] = vaultAad.resealRoot(value, aadParts, args.oldRootJson, args.newRootJson);
631
+ changed = true;
632
+ }
633
+ if (changed) {
634
+ resealed += 1;
635
+ chain = chain.then(function () { return store.putResealed(row); });
636
+ }
637
+ });
638
+ return chain.then(function () { return { table: SEAL_TABLE, resealed: resealed }; });
639
+ });
640
+ }
641
+
543
642
  module.exports = {
544
643
  create: create,
644
+ reseal: reseal,
545
645
  AgentIdempotencyError: AgentIdempotencyError,
546
646
  guards: {
547
647
  key: guardIdempotencyKey,
548
648
  },
649
+ // AAD_ROTATION — the vault-key rotation descriptor every framework module
650
+ // that seals an {aad:true} table on an OPERATOR-SUPPLIED store (outside
651
+ // db.enc) exports, so an operator can register it with a rotation eager-
652
+ // sweep and the codebase-patterns detect-and-refuse gate can confirm no
653
+ // such external-store table is silently orphaned. `backend: "external"`
654
+ // flags that the in-tree b.vaultRotate.rotate pipeline cannot reach it.
655
+ AAD_ROTATION: {
656
+ table: SEAL_TABLE,
657
+ rowIdField: "keyHash",
658
+ schemaVersion: "1",
659
+ backend: "external",
660
+ reseal: reseal,
661
+ },
549
662
  };
@@ -58,6 +58,8 @@ var { defineClass } = require("./framework-error");
58
58
  var guardAgentRegistry = require("./guard-agent-registry");
59
59
  var bCrypto = require("./crypto");
60
60
  var agentAudit = require("./agent-audit");
61
+ var vaultAad = require("./vault-aad");
62
+ var validateOpts = require("./validate-opts");
61
63
 
62
64
  var audit = lazyRequire(function () { return require("./audit"); });
63
65
  var cluster = lazyRequire(function () { return require("./cluster"); });
@@ -796,13 +798,119 @@ function _safeAudit(ctx, action, actor, metadata) {
796
798
  agentAudit.safeAudit(ctx.audit, action, actor, metadata);
797
799
  }
798
800
 
801
+ // ---- Vault-key rotation: out-of-band reseal hook -------------------------
802
+ //
803
+ // Registry rows are AAD-sealed on an OPERATOR-SUPPLIED backend (opts.backend /
804
+ // the in-memory default), NOT in the framework's db.enc. The vault-key
805
+ // rotation pipeline (b.vaultRotate.rotate) only walks tables inside db.enc,
806
+ // so it cannot reach this backend — sealed tenantId + metadata cells would be
807
+ // ORPHANED under the old vault root after a rotation (CWE-320 cryptographic-
808
+ // key-management failure: ciphertext stranded under a retired key, then
809
+ // unreadable once the old keypair is destroyed). This reseal hook rotates the
810
+ // backend out-of-band, composing the SAME explicit-root primitive the in-tree
811
+ // pipeline uses (vaultAad.resealRoot) and the SAME AAD builder the seal path
812
+ // used (cryptoField._aadParts) so the re-sealed AAD tuple is byte-identical —
813
+ // one source of truth, no drift.
814
+ //
815
+ // Reseal store contract: the durable backend the operator wired for
816
+ // opts.backend already exposes list() (enumerate every row) + set(name, row)
817
+ // (write by name). The row identity column `name` is the AAD anchor and is
818
+ // never sealed, so it is always present in plaintext for the write-back.
819
+ // `tenantId` is a plain sealed string; `metadata` is a sealed JSON string —
820
+ // both are AAD-sealed cells, so each is re-sealed in place under the same AAD
821
+ // without unwrapping the metadata JSON.
822
+ /**
823
+ * @primitive b.agent.orchestrator.reseal
824
+ * @signature b.agent.orchestrator.reseal(opts)
825
+ * @since 0.14.12
826
+ * @status stable
827
+ * @compliance gdpr, soc2
828
+ * @related b.vault.getKeysJson, b.cryptoField.sealRow
829
+ *
830
+ * Re-seals every AAD-bound registry cell (tenantId / metadata) on an
831
+ * operator-supplied backend from the OLD vault keypair to the NEW one,
832
+ * out-of-band. The in-tree vault-key rotation pipeline only walks tables
833
+ * inside `db.enc`, so an operator-supplied orchestrator backend is
834
+ * unreachable to it — after a keypair rotation its cells would otherwise be
835
+ * orphaned under the retired root (CWE-320). Rebuilds each cell's AAD from
836
+ * the registered schema (one source of truth); only AAD-sealed cells are
837
+ * touched. The `name` row-identity column is the AAD anchor and is never
838
+ * sealed, so it is always present for the write-back.
839
+ *
840
+ * @opts
841
+ * store: Object, // { list(): rows[], set(name, row) } (the create() backend contract)
842
+ * oldRootJson: string, // b.vault.getKeysJson() of the retired keypair
843
+ * newRootJson: string, // b.vault.getKeysJson() of the new keypair
844
+ *
845
+ * @example
846
+ * await b.agent.orchestrator.reseal({ store: backend, oldRootJson: oldKeys, newRootJson: newKeys });
847
+ * // → { table: "agent_orchestrator_registry", resealed: 4 }
848
+ */
849
+ function reseal(args) {
850
+ args = args || {};
851
+ validateOpts.requireNonEmptyString(args.oldRootJson,
852
+ "reseal: oldRootJson (b.vault.getKeysJson() of the OLD keypair)",
853
+ AgentOrchestratorError, "agent-orchestrator/bad-root");
854
+ validateOpts.requireNonEmptyString(args.newRootJson,
855
+ "reseal: newRootJson (b.vault.getKeysJson() of the NEW keypair)",
856
+ AgentOrchestratorError, "agent-orchestrator/bad-root");
857
+ var store = args.store;
858
+ validateOpts.requireMethods(store, ["list", "set"],
859
+ "reseal: operator store (same backend contract as create({ backend }))",
860
+ AgentOrchestratorError, "agent-orchestrator/bad-reseal-store");
861
+ _ensureSealTable();
862
+ var schema = cryptoField().getSchema(SEAL_TABLE);
863
+ return Promise.resolve(store.list()).then(function (rows) {
864
+ if (!Array.isArray(rows)) {
865
+ throw new AgentOrchestratorError("agent-orchestrator/bad-reseal-store",
866
+ "reseal: store.list() must resolve to an array of rows");
867
+ }
868
+ var chain = Promise.resolve();
869
+ var resealed = 0;
870
+ rows.forEach(function (row) {
871
+ if (!row || typeof row !== "object") return;
872
+ var changed = false;
873
+ for (var f = 0; f < schema.sealedFields.length; f += 1) {
874
+ var column = schema.sealedFields[f];
875
+ var value = row[column];
876
+ // Only AAD-sealed cells need rotating. Vault-less / pre-sealing rows
877
+ // carry plain values (sealRow leaves them untouched when vault-less);
878
+ // resealRoot would throw not-sealed on a plain value, so skip.
879
+ if (typeof value !== "string" || !vaultAad.isAadSealed(value)) continue;
880
+ var aadParts = cryptoField()._aadParts(schema, SEAL_TABLE, column, row);
881
+ row[column] = vaultAad.resealRoot(value, aadParts, args.oldRootJson, args.newRootJson);
882
+ changed = true;
883
+ }
884
+ if (changed) {
885
+ resealed += 1;
886
+ chain = chain.then(function () { return store.set(row.name, row); });
887
+ }
888
+ });
889
+ return chain.then(function () { return { table: SEAL_TABLE, resealed: resealed }; });
890
+ });
891
+ }
892
+
799
893
  module.exports = {
800
894
  create: create,
801
895
  shardFor: shardFor,
896
+ reseal: reseal,
802
897
  AgentOrchestratorError: AgentOrchestratorError,
803
898
  guards: {
804
899
  registry: guardAgentRegistry,
805
900
  },
901
+ // AAD_ROTATION — the vault-key rotation descriptor every framework module
902
+ // that seals an {aad:true} table on an OPERATOR-SUPPLIED backend (outside
903
+ // db.enc) exports, so an operator can register it with a rotation eager-
904
+ // sweep and the codebase-patterns detect-and-refuse gate can confirm no
905
+ // such external-store table is silently orphaned. `backend: "external"`
906
+ // flags that the in-tree b.vaultRotate.rotate pipeline cannot reach it.
907
+ AAD_ROTATION: {
908
+ table: SEAL_TABLE,
909
+ rowIdField: "name",
910
+ schemaVersion: "1",
911
+ backend: "external",
912
+ reseal: reseal,
913
+ },
806
914
  // Test-only — flush the salted FNV basis cache so a vault reset
807
915
  // between tests forces re-derivation.
808
916
  _resetForTest: function () { _saltedFnvBasisCache = null; },
@@ -53,6 +53,8 @@ var bCrypto = require("./crypto");
53
53
  var guardSnapshotEnvelope = require("./guard-snapshot-envelope");
54
54
  var agentAudit = require("./agent-audit");
55
55
  var safeJson = require("./safe-json");
56
+ var vaultAad = require("./vault-aad");
57
+ var validateOpts = require("./validate-opts");
56
58
 
57
59
  var audit = lazyRequire(function () { return require("./audit"); });
58
60
  var auditSign = lazyRequire(function () { return require("./audit-sign"); });
@@ -163,6 +165,117 @@ function create(opts) {
163
165
  };
164
166
  }
165
167
 
168
+ // ---- Wrapped-AAD root re-seal (vault-key rotation pipeline) ----------------
169
+
170
+ /**
171
+ * @primitive b.agent.snapshot.reseal
172
+ * @signature b.agent.snapshot.reseal(opts)
173
+ * @since 0.14.12
174
+ * @status stable
175
+ * @related b.agent.snapshot.create, b.vault.getKeysJson
176
+ *
177
+ * Re-seal every persisted snapshot envelope from the OLD vault root to
178
+ * the NEW vault root under the SAME column-shaped AAD, for a vault-key
179
+ * rotation. The snapshot seal is a `vault.aad:` ciphertext hidden behind
180
+ * the `snap-sealed-v1:` wrapper prefix and written to an operator
181
+ * backend, so a `db.enc` scan for the bare `vault.aad:` prefix can
182
+ * neither detect nor reach it — the rotation pipeline drives the re-key
183
+ * through this explicit backend walk. Each row is unsealed under the old
184
+ * root and re-sealed under the new root in memory (composing
185
+ * `b.vault.aad.resealRoot`); the plaintext envelope is never written to
186
+ * operator-readable storage. The decorative wrapper fields the backend's
187
+ * `list()` filters on (`snapshotId` / `takenAt` / `tenantId`) are
188
+ * preserved, so the index is untouched.
189
+ *
190
+ * `allowPlaintext` envelopes (no `sealed` wrapper) carry no AAD-sealed
191
+ * blob to re-key and are skipped; the returned `resealed` count reflects
192
+ * only re-sealed rows. A row sealed by a non-default KMS sealer (the
193
+ * inner blob is not a `vault.aad:` value) is refused — re-key it through
194
+ * the operator's own KMS, not this path.
195
+ *
196
+ * @opts
197
+ * backend: { put, get, list }, // the same backend create() was wired with
198
+ * oldRootJson: string, // b.vault.getKeysJson() of the OLD keypair
199
+ * newRootJson: string, // b.vault.getKeysJson() of the NEW keypair
200
+ *
201
+ * @example
202
+ * var result = await b.agent.snapshot.reseal({
203
+ * backend: operatorBackend,
204
+ * oldRootJson: oldKeysJson,
205
+ * newRootJson: newKeysJson,
206
+ * });
207
+ * result.table; // → "agent.snapshot"
208
+ * result.resealed; // → <count of re-keyed snapshots>
209
+ */
210
+ async function reseal(opts) {
211
+ opts = opts || {};
212
+ var backend = opts.backend;
213
+ validateOpts.requireMethods(backend, ["put", "get", "list"],
214
+ "reseal: opts.backend (same backend create() was wired with)",
215
+ AgentSnapshotError, "agent-snapshot/bad-backend");
216
+ validateOpts.requireNonEmptyString(opts.oldRootJson,
217
+ "reseal: opts.oldRootJson (b.vault.getKeysJson() of the OLD keypair)",
218
+ AgentSnapshotError, "agent-snapshot/bad-root");
219
+ validateOpts.requireNonEmptyString(opts.newRootJson,
220
+ "reseal: opts.newRootJson (b.vault.getKeysJson() of the NEW keypair)",
221
+ AgentSnapshotError, "agent-snapshot/bad-root");
222
+
223
+ var entries = await backend.list();
224
+ if (!Array.isArray(entries)) return { table: SNAPSHOT_TABLE, resealed: 0 };
225
+ var resealed = 0;
226
+ for (var i = 0; i < entries.length; i += 1) {
227
+ var snapshotId = entries[i] && entries[i].snapshotId;
228
+ if (typeof snapshotId !== "string" || snapshotId.length === 0) continue;
229
+ var raw = await backend.get(snapshotId);
230
+ if (!raw) continue;
231
+ // Only the sealed-wrapper shape carries a re-keyable blob. The
232
+ // allowPlaintext path stores the bare envelope (no `sealed`) — skip.
233
+ if (!raw.sealed || typeof raw.sealed !== "string" ||
234
+ raw.sealed.indexOf(SEALED_PREFIX) !== 0) {
235
+ continue;
236
+ }
237
+ var innerBlob = raw.sealed.slice(SEALED_PREFIX.length);
238
+ // The inner blob is a vault.aad: ciphertext (when sealed by the
239
+ // default b.vault.aad sealer — the only sealer resealRoot can
240
+ // re-key). A custom KMS sealer's blob isn't a vault.aad: value, so
241
+ // refuse rather than silently no-op: the operator must drive the
242
+ // re-key through their own KMS.
243
+ if (!vaultAad.isAadSealed(innerBlob)) {
244
+ throw new AgentSnapshotError("agent-snapshot/not-vault-sealed",
245
+ "reseal: snapshot " + snapshotId + " was sealed by a non-vault sealer " +
246
+ "(no " + JSON.stringify(vaultAad.AAD_PREFIX) + " prefix on the inner blob); " +
247
+ "re-key it through the KMS the operator wired as opts.sealer at create() time");
248
+ }
249
+ // Rebuild the EXACT AAD the envelope was sealed under via the
250
+ // module's own _snapshotAad builder — single source of truth with
251
+ // the seal (_persist) + unseal (_unwrapAndVerify) paths. The wrapper
252
+ // carries snapshotId; schemaVersion mirrors the unseal path's
253
+ // `raw.schemaVersion || SCHEMA_VERSION` fallback so an envelope
254
+ // written under an older SCHEMA_VERSION re-keys under its original
255
+ // AAD, not the current one.
256
+ var aad = _snapshotAad({
257
+ snapshotId: snapshotId,
258
+ schemaVersion: raw.schemaVersion != null ? raw.schemaVersion : SCHEMA_VERSION,
259
+ });
260
+ var rekeyed;
261
+ try {
262
+ rekeyed = vaultAad.resealRoot(innerBlob, aad, opts.oldRootJson, opts.newRootJson);
263
+ } catch (e) {
264
+ throw new AgentSnapshotError("agent-snapshot/reseal-failed",
265
+ "reseal: snapshot " + snapshotId + " failed to re-key — the value may not have " +
266
+ "been sealed under oldRootJson + this AAD, or the bytes are tampered (" +
267
+ ((e && e.message) || String(e)) + ")");
268
+ }
269
+ // Re-apply the prefix + preserve every decorative wrapper field
270
+ // (snapshotId / takenAt / tenantId the backend's list() filters on)
271
+ // so the rotation leaves the index untouched.
272
+ var rewritten = Object.assign({}, raw, { sealed: SEALED_PREFIX + rekeyed });
273
+ await backend.put(snapshotId, rewritten);
274
+ resealed += 1;
275
+ }
276
+ return { table: SNAPSHOT_TABLE, resealed: resealed };
277
+ }
278
+
166
279
  // ---- Signer + sealer resolution -------------------------------------------
167
280
 
168
281
  function _resolveSigner(ctx) {
@@ -666,11 +779,35 @@ function _frameworkVersion() {
666
779
  catch (_e) { return "unknown"; }
667
780
  }
668
781
 
782
+ // AAD_ROTATION — the eager-register descriptor the vault-key rotation
783
+ // pipeline consumes. backend "external" because snapshot envelopes live
784
+ // in an operator-supplied backend (not the framework db.enc store), so a
785
+ // `db.enc` scan for the bare "vault.aad:" prefix can't reach them — the
786
+ // pipeline drives the re-key through `reseal` against the same backend.
787
+ // The descriptor's `reseal({ store, oldRootJson, newRootJson })` maps the
788
+ // pipeline's generic `store` term onto this module's `backend` (the
789
+ // snapshot backing store), then defers to the module's own reseal.
669
790
  module.exports = {
670
791
  create: create,
792
+ reseal: reseal,
671
793
  SCHEMA_VERSION: SCHEMA_VERSION,
794
+ SEALED_PREFIX: SEALED_PREFIX,
672
795
  AgentSnapshotError: AgentSnapshotError,
673
796
  guards: {
674
797
  envelope: guardSnapshotEnvelope,
675
798
  },
799
+ AAD_ROTATION: {
800
+ table: SNAPSHOT_TABLE,
801
+ rowIdField: "snapshotId",
802
+ schemaVersion: String(SCHEMA_VERSION),
803
+ backend: "external",
804
+ reseal: function (rotationOpts) {
805
+ rotationOpts = rotationOpts || {};
806
+ return reseal({
807
+ backend: rotationOpts.store || rotationOpts.backend,
808
+ oldRootJson: rotationOpts.oldRootJson,
809
+ newRootJson: rotationOpts.newRootJson,
810
+ });
811
+ },
812
+ },
676
813
  };