Skip to content

feat(spec,service-settings): CryptoContext gains a required scope discriminant, bound into a delimiter-safe, versioned AAD (ADR-0128 D1-D3, stage 1) - #21453

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-21326-aad-scope-discriminant
Oct 2, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-21326-aad-scope-discriminant

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #21326
Clause-②: yes

Stage 1 of the staged card: ADR-0128 D1–D3 and the versioned handle. Stage 2, the at-rest re-wrap through rotateKey (ADR-0128 §4.2), is the next claim, and #21326 remains open for it.

⛔ Security family: this body names classes and positions only.

This run resumed a lost one. The container restarted after the first run had pushed three commits (4182cad1a1, 73c1a931f5, ace768e05c; 13 files, +651 / −104) and before any PR, report or gate result existed. This run re-verified every dispatch item against that diff, merged origin/main (c2c21f357c), and finished what was missing. Nothing measured before the restart is cited here; every reading below is from this run.

  • Found done and re-verified: the contract (D1, D2 and D3 prose), the provider's version-2 derivation and the version-1 read path, the three producers' threading, and the pins (including the D2 collision vector and the two pinned ciphertext vectors, both re-opened here independently with node:crypto and a hand-built AAD).
  • Finished in this run: the regenerated api-surface/ and export-origins/ entries for the two new exports, ADR-0128 anchors for the contract and the provider, the changeset, a label correction on the contract's third obligation (the versioned handle is §4's, and "no second try" is D3's), and every gate and ablation leg below.

The contract (packages/spec/src/contracts/crypto-provider.ts)

  • CryptoContext.scope is required. Its type is CryptoContextScope, drawn from the closed set CRYPTO_CONTEXT_SCOPES: settings, object_secret_field and datasource_credential, one member per producer. A new producer adds its own member and never borrows one.
  • Every provider that binds AAD must fold scope in (D1), encode the triple delimiter-safely (D2), and record in what it seals which derivation sealed it. It opens a ciphertext only with the recorded derivation, and an unknown one fails closed. It never tries a second derivation or scope after a failure (D3).
  • The docblock that described the flat shared (namespace, key) space is rewritten, as ADR-0128's Consequences requires when D1 ships. What the older binding still covers (ciphertext sealed before the scope existed) is stated in its own paragraph.

Producer census (measured at c2c21f357c)

Every encrypt, decrypt and rotateKey call site outside tests, over all *.ts, *.mts, *.js and *.mjs in the tree:

producer call sites scope it passes
SettingsService settings-service.ts: the seal in set(), the open in materialiseRow() settings
the ObjectQL engine's secret-field path engine.ts: the seal in the secret-field write path, the open in resolveSecret() (which resolveSecretField() calls) object_secret_field
the datasource secret binder datasource-secret-binder.ts: bind() and resolve() datasource_credential

No fourth producer. The plugins that store secrets (SSO client secret, webhook secret and headers, flow credentials) all reach the provider through the engine's resolveSecretField(), so they are the second producer. rotateKey has no in-tree caller. crypto-adapter.ts builds no AAD: its CryptoAdapter is a separate interface over { namespace, key } for inline sys_setting.value_enc, its only in-tree implementation is the base64 NoopCryptoAdapter, and it never meets a CryptoContext.

The provider (LocalCryptoProvider, packages/services/service-settings)

Version 2, every new seal. The AAD is a lead byte 0xFF, the label objectstack/crypto-context-aad/v2, then scope, namespace and key, each prefixed with its 4-byte big-endian UTF-8 length. The ciphertext is v2: followed by the base64 blob.

  • D2: length-prefixing. The ADR lists three acceptable encodings and prefers none. Length-prefixing has no separator to escape, so no escaping rule can be got wrong, and it is pinned by a byte vector.
  • Disjoint from version 1. 0xFF never occurs in UTF-8, and a version-1 AAD is the UTF-8 of a string. So a body presented under the other version's marker never authenticates (pinned in both directions).
  • Version 1 is read-only. It opens a ciphertext with no marker using the older (namespace, key) derivation, and it never seals.
  • Unknown markers fail closed. Any other marker is refused with UnknownCiphertextVersionError.
  • Scope is never defaulted. A context whose scope is missing or outside the set is refused with CryptoContextScopeError on encrypt, decrypt and rotateKey. That includes a version-1 open, which does not bind the scope.
  • rotateKey opens with the recorded derivation and seals with version 2. That is the seam stage 2 uses.

Why an existing handle is unambiguously version 1 (no row is read)

  • CryptoHandle.version cannot carry it. It is already a rotation counter (kmsKeyId is local:v followed by it), so a rotated handle sealed before this change already reads version 2 or more. The docblock now says so.
  • The ciphertext is the provider's own record. sys_secret.ciphertext and CryptoHandle.ciphertext are documented as provider-defined and round-tripped verbatim. So the marker lives there, with no schema column and no producer having to carry it.
  • No earlier seal contains :. Every earlier seal was bare standard base64, whose alphabet has no :. This was measured over the full history of both file names the provider has had: 10 commits from 4f6bae0c60 (Phase 3 sys_secret crypto) to 222ecc27f9. Each version's seal path reads ciphertext: blob over Buffer#toString('base64'), on both the node:crypto path and the WebContainer path.
  • So the rule is total over what is stored: no : means version 1, v2: means version 2, and anything else is refused.

Pins

  • local-crypto-provider.test.ts (ADR-0128 block):
    • every scope seals v2:;
    • the pinned version-1 vector opens, and still binds its coordinate;
    • version 1 does not bind the scope (the documented older guarantee);
    • the pinned version-2 vector opens;
    • a byte pin on the version-2 AAD;
    • D1: a full scope × scope matrix, where only the sealing scope opens;
    • D2: the collision vector. Two contexts whose unescaped join is equal produce different AAD bytes and do not open each other;
    • an unknown marker is refused, with a positive control;
    • a marker swap fails in both directions;
    • rotateKey lifts version 1 to version 2;
    • a missing or invalid scope is refused on every entry point, with a positive control.
  • Producers: settings-service.test.ts reads every call's scope and checks that no other scope opens the stored ciphertext; secret-fields.test.ts (objectql) pins object_secret_field on the seal and on both opens; datasource-secret-binder.test.ts pins datasource_credential on bind and resolve, and refuses a row sealed under another scope at the binder's own coordinate (with a positive control).

Verification (this run, at 0b398e8899; shared container, every build and test under the verify lock)

  • Build: turbo run build over the dependency closures of service-settings, service-datasource and objectql (21 packages in scope, spec included): 20 of 20 tasks succeeded.
  • Tests:
    • @objectstack/spec (--project local): 600 files, 17684 passed, 1 todo.
    • @objectstack/service-settings: 33 files, 603 passed.
    • @objectstack/service-datasource: 35 files, 709 passed.
    • @objectstack/objectql (--project local, 3 shards): 2353 + 2287 + 2739 passed, and 1 failed in shard 1. The failure was a 5-second clocked test that dynamically imports the package barrel. It timed out under shared-box load (load average about 23), timed out again when run alone at the default 5 s (5.05 s), and passed with a longer timeout (3.26 s). This diff adds no import to objectql. secret-fields.test.ts, which carries the new pin, also passed on its own.
    • @objectstack/cli, integration tier, the two touched files: 46 passed.
  • Typecheck: spec, service-settings, service-datasource, objectql and cli (the full tsc --noEmit && check:test-typecheck) all exit 0. --listFiles confirms that service-settings' program includes all 33 of its test files and service-datasource's all 35.
  • check:generated (spec): all 15 artifacts are up to date after api-surface/ and export-origins/ were regenerated.
  • dispatch-gates --ran: 106 derived, 106 run, 0 NOT-MEASURED. Five families at first refused for a missing prerequisite: one needed deeper history, and four needed the whole-workspace dist. Each passed once its prerequisite was in place.
  • Roster families the derivation marks as silent, also run (all exit 0): changeset-fixed, published-list-mirrors, meta-url-spelling, spec-changes, authz-resolver, console-injection, error-code-casing, filter-alias-parity, i18n-stale-fill, published-readme-exports, and the dts-references self-test.
  • Changeset level axis: driven offline with a PR payload declaring Clause-②: yes, it passes, grading @objectstack/spec and @objectstack/service-settings at minor.

D2 ablation (both legs from the committed state)

  • Mutation. Run inside the verify lock with scripts/ablation-replace.mjs. The length-prefixed body of aadForVersion2 is replaced by an unescaped join of the same three components. The scope stays in, so only D2 is ablated. The mutation landed: the anchor count went from 1 to 0, the replacement count from 0 to 1, and the blob from 77423577310b to 0a2fa7a2a5d6.
  • Leg 1, naive join: red, as predicted. 4 failed, 31 passed. The D2 collision vector failed at its AAD-bytes assertion. The pinned version-2 vector, the AAD byte pin and the unknown-marker test's positive control failed with it. The D1 scope matrix stayed green, as it should while the scope is still in the join.
  • Restore. The blob is back to 77423577310b, equal to HEAD's, and git diff HEAD is empty.
  • Leg 2, restored: 35 of 35 green.
  • No build leg. The test imports the provider through a relative source path, so no dist/ lies on its resolution path.

Release

Acceptance notes


Generated by Claude Code

claude added 6 commits October 2, 2026 16:24
…ter-safe versioned AAD

Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM
Co-authored-by: Claude <noreply@anthropic.com>
…d, versioned AAD and each producer's scope

Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM
Co-authored-by: Claude <noreply@anthropic.com>
…scope, not a call count

Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM
Co-authored-by: Claude <noreply@anthropic.com>
… and the versioned-handle label on the contract

The contract's third provider obligation is labelled for what it is: the
versioned handle ADR-0128 section 4 calls for, with D3 (no fallback, the fix
at the producer of the AAD) named on the no-second-try sentence.

ADR-0128 anchors for the contract and for LocalCryptoProvider, so the scoped,
length-prefixed, versioned derivation cannot be "simplified" back into a join
without the gate naming the record.

One changeset: spec and service-settings minor with the BREAKING banner (the
keyedDigest precedent's level and header), objectql and service-datasource
patch (each passes its own scope; no public surface moves).

Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM
Co-authored-by: Claude <noreply@anthropic.com>
…TEXT_SCOPES and CryptoContextScope

The two contract exports ADR-0128 D1 adds. check:generated named exactly these
two stale artifacts (+2 / -0 each) against a dist built from this branch's
source, and reads all 15 up to date after the two generators ran.

Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/objectql, @objectstack/service-datasource, @objectstack/service-settings, @objectstack/spec, touching 29 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/spec/api-surface/contracts.json, packages/spec/export-origins/contracts.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/external-datasources.mdx (via ICryptoProvider (symbol, a top-level interface; a top-level type))
  • content/docs/data-modeling/validation-rules.mdx (via ICryptoProvider (symbol, a top-level interface; a top-level type))
  • content/docs/kernel/runtime-services/settings-service.mdx (via setMany (symbol, a method of class SettingsService))
  • content/docs/protocol/kernel/config-resolution.mdx (via CryptoHandle (symbol, a top-level interface; a top-level type), ICryptoProvider (symbol, a top-level interface; a top-level type), LocalCryptoProvider (symbol, a top-level class), setMany (symbol, a method of class SettingsService))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via ICryptoProvider (symbol, a top-level interface; a top-level type))
  • content/docs/releases/v14.mdx (via setMany (symbol, a method of class SettingsService))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/spec/api-surface/contracts.json, packages/spec/export-origins/contracts.json) — pages documenting those are invisible to this run
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 141 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 086ad0aa684f73d9703fa3dadd501f23d08533c6 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 60e941af91b824a832275af53e3c9e06275172d7 — the merge of head 0b398e88997002982d665248001adf697a7bfeee into base 086ad0aa684f73d9703fa3dadd501f23d08533c6, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 60e941af91b824a832275af53e3c9e06275172d7 && git checkout 60e941af91b824a832275af53e3c9e06275172d7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 086ad0aa684f73d9703fa3dadd501f23d08533c6 0b398e88997002982d665248001adf697a7bfeee && git checkout -B drift-repro 086ad0aa684f73d9703fa3dadd501f23d08533c6 && git merge --no-ff 0b398e88997002982d665248001adf697a7bfeee

node scripts/docs-audit/affected-docs.mjs --json 086ad0aa684f73d9703fa3dadd501f23d08533c6

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 086ad0aa684f73d9703fa3dadd501f23d08533c6 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 0b398e88997002982d665248001adf697a7bfeee
Local-runs: none

Scope read: card #21326 (body, triage 5945843248, claim 5956307339, dev report 5959101207), PR #21453 (body, the 18-file list, the net diff against main, and its one thread comment, the advisory docs-drift check), the check-runs on the head, ADR-0128 and ADR-0112 at the head, and the repository files the diff names, read at the head and at the provider's historical blobs through git show and the contents API. Nothing built, run or re-run. ⛔ Security family: classes and positions only.

① Derived judgments

D1, a required and closed scope discriminant: right. CryptoContext.scope: CryptoContextScope is a required member; CRYPTO_CONTEXT_SCOPES is a frozen three-member tuple (settings, object_secret_field, datasource_credential), one per producer, with the never-borrow rule in its docblock. The type carries the obligation at compile time and requireScope carries it at runtime on all three entry points (encrypt through aadForVersion2, decrypt before the marker is read, rotateKey through decrypt), including a version-1 open that does not bind the scope. A missing or non-member scope is refused with CryptoContextScopeError, never defaulted. Pinned: the scope by scope matrix, and the every-entry-point refusal with a positive control.

D2, a delimiter-safe encoding: right. The version-2 AAD is a 0xFF lead byte, a versioned label, then scope, namespace and key, each as a 4-byte big-endian UTF-8 length followed by the bytes. No separator exists for a component to carry, so distinct triples give distinct bytes. Pinned by a byte vector and by the collision vector ADR-0128 §1.5 already names. The D2 ablation the body reports (a naive join red on exactly the D2 pins, green once restored) is the expected shape for this change.

D3, a producer-side fix with no consumer-side fallback: right. The fix lives in LocalCryptoProvider. decrypt reads the ciphertext's marker FIRST and tries exactly one derivation; the version-1 path is selected by the record, never reached after a failure, so it is not the "try the other vocabulary's AAD" fallback D3 forbids. None of the three producers tries a second scope: settings materialiseRow logs and returns null on any decrypt failure (pre-existing), the binder's resolve returns undefined (pre-existing), the engine's resolveSecret throws. The settings inline CryptoAdapter branch is selected by row shape (the sec_ prefix), not by a failed decrypt.

The versioned handle, with the derivation in the ciphertext marker and not in CryptoHandle.version: right, and grounded in the diff and its pins. (a) version is a rotation counter: rotateKey returns version: handle.version + 1 and kmsKeyId as local:v plus it, so a rotated pre-versioning handle already reads 2 or more and the field cannot carry the derivation without ambiguity; the docblock now says so. (b) ciphertext is round-tripped verbatim by all three producers (secret.ciphertext, row.ciphertext), so the marker needs no schema column and no producer carries it. (c) "An existing handle is unambiguously version 1" rests on no earlier seal containing a colon. I re-read the provider at every one of its historical commits under both file names (4f6bae0c60, 97efe3bef0, 0a40bd1941, 1d73fa1856, 9096dfe30d, 55866f5fb0, c121d731c2, a58eac3e27, 222ecc27f9): every seal path, node:crypto and WebContainer alike, emits Buffer#toString('base64'), and every aadOf is the | join of namespace and key and nothing else (the earliest docblocks mention the tenant; the code never folded it in). So the version-1 read path's derivation matches every release's seal, and the rule "no colon means version 1, v2: means version 2, anything else is refused" is total over what is stored. Pinned: the pre-versioning vector opens and still binds its coordinate; the version-2 vector opens; a marker swap fails in both directions (the 0xFF lead keeps the two AAD spaces disjoint). The unknown-marker path is fail-closed. readCiphertext throws UnknownCiphertextVersionError for any marker other than v2; pinned with a positive control. A rollback past this release cannot open a v2: seal (GCM authentication fails), and the changeset states it.

The producer census, exactly three with one scope each: right. Independently measured at the head over every non-test .encrypt(, .decrypt( and .rotateKey( call site under packages/, apps/, examples/ and scripts/: settings-service.ts (the set() seal and the materialiseRow() open; settings), engine.ts (the secret-field seal and the resolveSecret() open, which resolveSecretField() calls; object_secret_field), datasource-secret-binder.ts (bind() and resolve(); datasource_credential). The remaining hits are the provider's own rotateKey and the two this.crypto.* calls on the separate CryptoAdapter interface (inline sys_setting.value_enc, {namespace, key} only, sole in-tree implementation NoopCryptoAdapter, no AAD, never meets a CryptoContext). rotateKey has no in-tree caller. One in-tree implementer, LocalCryptoProvider; in-memory-crypto-provider.ts is a re-export shim. The plugins that store secrets reach the provider through the engine, so they are producer two.

Every accept-set and public-surface change the diff implies, each judged:

  • @objectstack/spec: CryptoContext narrows its caller accept set (required scope), right per D1, breaking for direct callers and implementers, named in the changeset with the compiler error; two additive exports, CRYPTO_CONTEXT_SCOPES and CryptoContextScope, right, with api-surface/ and export-origins/ regenerated for exactly those two.
  • @objectstack/service-settings: LocalCryptoProvider.encrypt output gains the v2: marker on a provider-defined blob, right, documented, the rollback consequence stated; decrypt and rotateKey refuse a non-member scope and an unknown marker, right, fail-closed; rotateKey now re-seals under the current derivation, right, the contract text says so. The three new module exports (CryptoContextScopeError, UnknownCiphertextVersionError, aadForVersion2) are NOT on the package barrel (src/index.ts is a named list, and package.json exports only the root), so no published surface widens there, which is where KeyedDigestKeyUnavailableError already sits.
  • @objectstack/objectql and @objectstack/service-datasource: behaviour only (each passes its own scope on seal and open); no public surface moves, right at patch. The DatasourceSecretBinderDeps.namespace prose is corrected to the scoped reading.
  • @objectstack/cli: tests only; no changeset owed, right.
  • The old CryptoContext docblock paragraph describing the flat space is rewritten, as ADR-0128's Consequences requires when D1 ships; what version-1 ciphertext still carries is stated in its own paragraph, right.

What stage 2 (the at-rest rewrap) must still carry, which stays on the card ("Part of"):

  1. the rewrap itself, through rotateKey, over every row whose ciphertext carries no marker: resumable, safe against a live deployment, fail-closed on any row it cannot read (ADR-0128 §4.2);
  2. per-row producer attribution: rotateKey(handle, ctx) seals under the CALLER's scope and sys_secret does not record the producer, so each row's scope must come from the holder that references it (sys_setting.value_enc, a secret: ref on a business row, a sys_secret: credentialsRef); the orphan-report and orphan-sweep reachability classification is the existing seam; a row with no reachable holder is not re-wrapped under a guessed scope (D3);
  3. whether version-1 opening is retired once the rewrap completes (the dev's first finding), with the contract's "what it does not cover yet" paragraph rewritten in the same change;
  4. ADR-0128's dated implementation note (Tier H, a separate card, as the PR body says);
  5. the ADR-0112 question in ③ re-opens if the rewrap gains an operator-facing surface that answers with either refusal.

② Semver level

Changeset .changeset/21326-crypto-context-scope-discriminant.md: @objectstack/spec minor and @objectstack/service-settings minor, with the BREAKING banner naming the implementer and direct-caller break and the compiler error; @objectstack/objectql and @objectstack/service-datasource patch; the adr-0087 marker comment reads not-required (no-migration-prescription) with its reason (TypeScript contracts, no Zod schema, no authorable key, no export renamed or removed, no stored row changes shape, every existing ciphertext still opens). This is the level and header of the precedent the card names, PR #21292's .changeset/21263-crypto-provider-keyed-digest.md (a feat(spec): header, spec minor plus service-settings minor, the BREAKING banner, the same disposition), read at the head. Under the launch-window convention the bump level is not the breaking-ness carrier; the banner and the disposition are, and both are present. Check Changeset concluded success on the head. The levels match what the diff publishes: the two patch packages move no public surface, and cli publishes nothing.

The Clause-②: line reads Clause-②: yes on the PR body's second line and in the changeset, with no direction arm, the precedent's exact spelling. A widening is declared (two new spec exports) and takes at least minor, satisfied. The diff also narrows a published accept set (the required scope), which the optional arm could have spelled as yes (narrowing); the absent arm declares no direction, is legal, and the BREAKING banner carries the fact. Noted, not owed.

③ Boundary flags

The dev's six deviations, each answered:

  1. Resume of a lost run with the predecessor's commits kept and re-verified: accepted; the three commits sit on the claimed branch, and the body cites only post-restart readings.
  2. The first phase-1 runner killed while holding the lock, to add a lock retry: accepted; its own recorded process, the build re-run from scratch, the tree clean afterwards.
  3. The phase-2 gate runner stopped after two commands to regenerate the spec artefacts: accepted; all 106 derived families then ran at the head.
  4. git fetch --shallow-since deepened the shared object store and advanced the shared origin/main: accepted; declared, the four new main commits checked for overlap by diff, the branch not re-merged, and the queue rebuilds on current main regardless.
  5. An empty label set, with the path labeler's labels: accepted; the dispatch named none.
  6. A scratch cli tsconfig superseded by the full cli typecheck: accepted; only the full result is cited.

The dev's five out-of-scope findings, each answered or escalated:

  1. Version-1 ciphertext keeps the older binding until re-wrapped, and nothing retires version-1 opening: right carrier (stage 2); folded into the stage-2 list in ① together with the attribution obligation it implies.
  2. The dev's question for this review: CryptoContextScopeError and UnknownCiphertextVersionError carry no ADR-0112 ledger code. Right as the diff stands; not owed now. ADR-0112's own scope line is that the catalog governs the code a failing REQUEST answers with. Neither class carries a .code member at all, so even at a door the shared resolver derives error.code from the status and nothing is demoted to declaredCode: no unregistered spelling can reach the wire. The three producers absorb them (settings: a warning and null; the binder: undefined; the engine: a throw to privileged in-process callers), and the only crypto-adjacent ledger code at the head, SETTINGS_CRYPTO_UNAVAILABLE, is a different, wire-facing refusal. The precedent, KeyedDigestKeyUnavailableError, has no ledger row either (verified in error-code-ledger.zod.ts). ADR-0128's Builds-on line binds refusals that answer a request; these answer none. It becomes owed the moment either refusal answers a request, most likely stage 2's operator-facing rewrap surface, and is then one ledger row plus a status. No follow-up row is owed from this PR.
  3. objectql's system-write-organization.test.ts barrel test dynamic-imports the whole barrel inside its 5-second clocked window and timed out twice under load. Escalated to the dispatching seat to file: a reproducible defect with its repro (Prime Directive chore: version packages #10) against the rule that clocked windows measure behaviour and never loading, pre-existing on main, outside this diff, and not caught by check:test-source-alias. Not a verdict item here.
  4. CryptoAdapter takes {namespace, key} with no scope: verified at the head as a separate interface for inline value_enc, the base64 NoopCryptoAdapter its only in-tree implementation, no AAD, outside ADR-0128's producer line. Right; nothing owed.
  5. Out-of-repo direct callers of encrypt, decrypt and rotateKey not measured (a 403 in a repository-bound session): an accepted blind spot. Census 5945420994 measured zero implementers; a direct caller fails loud either way (TS2741 at compile, CryptoContextScopeError at runtime for a caller the compiler never saw), never silently. Escalated to the seat with organisation-wide read access, to grep the cloud repositories' call sites before the release that carries this changeset.

Further flags from this review:

  • The advisory docs-drift comment (5959118709, posted after the dev report) lists four hand-written pages naming touched symbols. All four were read at the head: none states the AAD binding or the ciphertext shape, and config-resolution.mdx describes CryptoHandle.version as the monotonic rotation counter rotateKey bumps, which stays true. No page is falsified; no docs edit is owed.
  • The security-family rule holds: the PR body, the changeset and the contract docblock name classes and positions only; the diff REMOVES the old docblock's worked cross-vocabulary example; the test's D2 collision pair is the one ADR-0128 §1.5 already publishes.
  • Governed surfaces: none in the file list (.changeset/, packages/**, scripts/adr-anchors/**); Governed Surface Queue Guard concluded success. The two ADR anchors are one new file each, named for the path each anchors, as the register requires.
  • Check-runs on the head, read 2026-10-02T18:56Z: 34 runs; 29 completed (26 success; 3 skipped: Build Docs, Console Pin Gate, Packed-tarball smoke); 5 in_progress (Lint & Repo Gates, Test Core 1/6, 3/6, 5/6 and 6/6); none failed. in_progress is an honest reading, not a pass: landing still waits on those five. The PR is a draft with no auto-merge, as the dispatch requires.
  • The serial constraint holds: the provider's previous commit is 222ecc27f9 (feat(spec): ICryptoProvider gains a required keyedDigest member; LocalCryptoProvider implements it #21292 landed), so the branch builds on the precedent triage required.

Implemented-by: claude/issue-21326-aad-scope-discriminant
Reviewed-by: session_01YDt3PzwfrkuFzUBF89WPmM

VERDICT: PASS


Generated by Claude Code

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

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants