Repository navigation
spec+service-settings: build ADR-0128 D1–D3, a producer-discriminated, delimiter-safe AAD on CryptoContext, with a versioned handle and an at-rest rewrap #21326
Description
Ruled: 5964319930 · letter B · 2026-10-03T01:55Z
Filed by the domain:spec seat 1 (session_01UtnxvdiN376GF3sgXwAw4d, seat post #6017) as an execution parameter of the maintainer's ruling A on #21263 (5945612493, batch #267 item 1, 「其他同意」). The ruling reads: "D1: the claiming seat files one card for ADR-0128 D1, scoped as ADR-0128 §2 and §4 state it (priority from triage). It is not this PR." ⛔ Not a claim. ⛔ Not graded here: priority and lane are triage's.
Why now
ADR-0128 (Accepted 2026-09-07) recorded D1–D3 as a direction and deferred the work (§4), naming four triggers that fund it. One of them is met: "ICryptoProvider is opened for another breaking change". PR #21292 (#21263) adds the required keyedDigest member. The ruling lands that break alone, because the organisation's repositories hold zero out-of-repo implementers (census 5945420994), so bundling would save a second break for no measured population. The deferral is therefore over, and this card schedules the work. A separate Tier H card carries ADR-0128's dated note.
Scope (ADR-0128 §2 and §4, as written)
- D1:
CryptoContext(packages/spec/src/contracts/crypto-provider.ts) carries a required scope discriminant from a closed set, with one member per producer: settings, object secret field and datasource credential. Every provider that binds AAD folds it in. - D2: the AAD encoding is delimiter-safe: length-prefixed, escaped or canonical structured. ⛔ No unescaped join.
- D3: the fix is at the producer of the AAD (
ICryptoProviderandLocalCryptoProviderinpackages/services/service-settings). ⛔ No consumer-side fallback that tries another vocabulary's AAD. - §4.2, the expensive half: an at-rest rewrap of every existing ciphertext. It reads under the old AAD and writes under the new one, so the handle needs a version that says which derivation sealed it. The rewrap must be resumable, safe against a live deployment, and fail closed on any row it cannot read.
rotateKeyis the seam §4 names. - The producers to thread the discriminant through (ADR-0128's Consumers line): the settings service, the engine's secret-field path (
@objectstack/objectql) and the datasource secret binder (@objectstack/service-datasource). Out-of-repo, every cloud host constructsLocalCryptoProviderthrough alink:dependency (census5945420994), so a framework change reaches them with the implementation.
What triage decides
- Priority, and whether this splits into stages, for example contract + provider + versioned handle first and the rewrap second. ADR-0128 §4's ordering note says both derivations must coexist during the migration.
- Lanes: the work spans
domain:spec(the contract) anddomain:services(the provider and the producers). - ⛔ This card does not reopen the direction (ADR-0128 D1–D4 stand). It only schedules them.
Dedupe
The 100 most recently updated open issues and PRs were listed over REST (repo-scoped) and grepped for ADR-0128, producer-discriminated and rewrap. The hits are seat post #6017 and PR #21292, the ruling's PR, and neither schedules D1. #12599 is the closed source card. #21263 is the ruling's anchor.
Dedupe words: ADR-0128 D1 producer-discriminated AAD · CryptoContext scope discriminant · sys_secret rewrap versioned handle
Activity
objectstack-fleet commentedon Oct 2, 2026
Triage: first grade —
enhancement·security·priority:p3·domain:spec·area:access·pm:queue. ADR-0128 D1–D3 as written, scheduled by the maintainer's ruling A on #21263Triage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-02T04:54Z. ⛔ Not a claim, ⛔ not a dispatch.⛔ Classes and positions only.
Why p3. ADR-0128 §1.4 measured the confusion as constructible, with zero live instances. The census (
5945420994) measured zero out-of-repo implementers. So this is hardening with no exposed population. It is scheduled because the maintainer ruled it a card of its own (5945612493), not because it is urgent.Routing. The contract (
packages/spec/src/contracts/**,Clause-②: yes) isdomain:spec.service-settingsis a declared cross-lane surface, as on #21263.Direction: ADR-0128 §2 and §4 as written: D1 (a required, closed scope discriminant), D2 (a delimiter-safe encoding), D3 (a producer-side fix, ⛔ no consumer fallback), with a versioned handle and the at-rest rewrap migration. The card's scope is accepted.
Serial: after PR #21292 (#21263) lands, because both edit
crypto-provider.ts.Generated by Claude Code
objectstack-fleet commentedon Oct 2, 2026
Claim: PM loop round 1 (stage 1 of a staged card: ADR-0128 D1–D3 and the versioned handle)
Session:
session_01YDt3PzwfrkuFzUBF89WPmMAccount:
os-tesla(the seat's linked user asGET /useranswers it; the card's assignee)Branch:
claude/issue-21326-aad-scope-discriminantWorktree:
objectstack-issue-21326Domain:
domain:specSeat:
domain:spec#2File surface (stage 1, at
8b123c0aec):packages/spec/src/contracts/crypto-provider.ts(CryptoContext,ICryptoProvider): D1, a required scope discriminant from a closed set, one member per producer.packages/services/service-settings/src/(local-crypto-provider.ts,in-memory-crypto-provider.ts,crypto-adapter.ts): D2, a delimiter-safe AAD encoding; D3, the fix at the producer; and a handle version that names the derivation that sealed it.@objectstack/objectql), andservice-datasource'sdatasource-secret-binder.ts.link:dependency).Staging (the seat's sequencing, which the card's "What triage decides" leaves open; triage
5945843248accepted the scope without splitting it):rotateKey(§4.2). It is resumable, safe against a live deployment, and fails closed on any row it cannot read.Container & model:
L,mode:subagent,model: opus(dispatch-gates --tierat8b123c0aec: no path-derived mandate; floor sonnet · default opus · ceilingCONTRACT_REVIEW_TIER). This is a security-family contract change, and the at-tier contract review runs as a separate isolated subagent after the PR opens.Clause-②: yes
Thread-read: 5945843248
Serial constraints cleared:
ICryptoProvidergains a required keyed digest — the server-held-key HMAC that #21207's served content hash is ruled onto (option B) #21263) landed as222ecc27f9.crypto-provider.ts,service-settings,service-datasource's secret binder or objectql's secret path. The one hit, PR chore: version packages #21352, is the Version Packages PR, through changeset names.Seat terms: p3, under the maintainer's 「p3 的卡也可以派了」 (
5952442849). This PR isPart of #21326(stage 1); stage 2 closes the card.⛔ Security family: comments on this card name classes and positions only.
Generated by Claude Code
objectstack-fleet commentedon Oct 2, 2026
os-dev-report
{
"issue": 21326,
"status": "done",
"branch": "claude/issue-21326-aad-scope-discriminant",
"pr": "#21453",
"session": "session_01YDt3PzwfrkuFzUBF89WPmM — subagent run, so this is the parent's id (the Claude-Session trailer on every commit)",
"premise_still_valid": true,
"summary": "RESUMED RUN: the container restarted mid-flight, so this run resumed a lost one. Found done and re-verified (not assumed): 4182cad, 73c1a93 and ace768e (13 files, +651/-104). They carry the required CryptoContext.scope from the closed CRYPTO_CONTEXT_SCOPES (settings, object_secret_field, datasource_credential), LocalCryptoProvider's version-2 AAD, the version-1 read-only path, the threading through all three producers, and the pins, including the D2 collision vector. Both pinned ciphertext vectors were re-opened independently with node:crypto and a hand-built AAD. Finished in this run: origin/main merged (c2c21f3); api-surface/ and export-origins/ regenerated for the two new exports; ADR-0128 anchors added for the contract and the provider; the changeset (spec and service-settings minor with the BREAKING banner, objectql and service-datasource patch, ADR-0087 not-required (no-migration-prescription), as in PR #21292); a label correction in the contract docblock (the versioned handle is ADR-0128 section 4's, and no-second-try is D3's); and every gate and both ablation legs. Draft PR #21453 is 'Part of #21326', with stage 2 (the at-rest re-wrap) left on the card.",
"tests": "All at HEAD 0b398e8 except where named, every build and test under scripts/pm/os-verify-lock.sh. BUILD: turbo build over the closures of service-settings, service-datasource and objectql: 'Tasks: 20 successful, 20 total' (VERDICT command-exit 0). spec test --project local: 'Test Files 600 passed (600) / Tests 17684 passed | 1 todo'. service-settings: '33 passed (33) / 603 passed'. service-datasource: '35 passed (35) / 709 passed'. objectql --project local in 3 shards: 2353 passed + 1 failed, then 2287 passed, then 2739 passed. The one failure: system-write-organization.test.ts 'publishes both names from the package BARREL' timed out at 5000 ms (it dynamically imports the whole barrel under load). Run alone it timed out again at 5054 ms, and with --testTimeout=120000 it passed at 3260 ms. This diff adds no objectql import. secret-fields.test.ts passed on its own, including 'seals and dereferences under its own scope, object_secret_field (ADR-0128 D1)'. cli integration tier, the two touched files: '2 passed (2) / 46 passed'. (A first try with --project unit selected nothing, exit 1, NOT a measurement; both files are integration-tier.) TYPECHECK exit 0: spec (check:test-typecheck OK), service-settings, service-datasource, objectql (check:test-typecheck OK) and cli (full tsc --noEmit plus check:test-typecheck OK). --listFiles: service-settings' program includes 33 test files and service-datasource's 35. ABLATION: see the ablation_legs field.",
"gates": {
"derived": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 0b398e8: 106 commands, each exit code written to disk before it was read",
"reconcile": "dispatch-gates --ran final-ran.list, exit 0: '✓ dispatch-gates --ran: 106 derived famil(ies) accounted for — 106 run, 0 NOT-MEASURED (a DERIVED zero — all 106 recorded an exit code and none of them is 3)'",
"first_refusals_then_rerun": [
"node scripts/check-engine-split-ratio.mjs --days 90: exit 2 (refused on a shallow clone whose window was not covered), then exit 0 after git fetch --shallow-since=2026-06-27",
"pnpm check:dual-build-cjs-loads: exit 3 (whole-workspace dist absent), then exit 0",
"pnpm check:i18n: exit 3, then exit 0",
"pnpm check:i18n-coverage: exit 3, then exit 0",
"pnpm check:i18n-walk-parity: exit 3, then exit 0 (the four were re-run once check:type-check-debt's own whole-workspace build had produced the dists)"
],
"named_in_dispatch": [
"pnpm --filter @objectstack/spec build (inside the closure build): exit 0",
"pnpm --filter @objectstack/spec check:generated: exit 0 — '✓ All 15 generated artifacts are up to date' (it first named exactly api-surface/ and export-origins/ stale, +2 lines each; regenerated in 0b398e8)",
"spec test / typecheck: exit 0 / exit 0",
"service-settings, service-datasource and objectql test and typecheck: all exit 0, except the objectql shard-1 load timeout above",
"node scripts/check-adr-0087-registration.mjs --base origin/main: exit 0 — '✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.'",
"node scripts/check-changeset-no-major.mjs --base origin/main: exit 0 — '✓ This diff introduces no
majorbump.'; level axis driven offline with --event (PR payload body 'Clause-②: yes'): exit 0 — '✓ LEVEL AXIS: this PR declares clause-②yes, and it grades a package whosepackages/**/src/**it moves atminoror above'","node scripts/check-empty-changeset.mjs --base origin/main: exit 0",
"pnpm check:changeset-gate-self-tests: exit 0",
"pnpm check:doc-authoring: exit 0 — '✓ doc authoring guard: sibling-package prose ids hold the baseline — 204 pinned site(s) across 62 file(s) ...'",
"pnpm check:nul-bytes: exit 0 — 'check-nul-bytes: OK (scanned 9773 text file(s) ... no raw ASCII control bytes)'; a manual control-byte scan of the 18 changed files found 0 lines"
],
"selected_verdicts": [
"pnpm --filter @objectstack/spec run check:api-surface: '@objectstack/spec public API surface + factory signatures unchanged ✓'",
"pnpm check:adr-anchors: 'check-adr-anchors: OK (59 anchored file(s), every governing ADR still referenced ...)'",
"pnpm check:engine-double-contract: 'OK — 921 pinned, 129 in the DEBT ledger, 3 exempt.'",
"pnpm check:type-check-debt: 'check-type-check-coverage --re-measure: OK — 1 ledger entr(ies) re-measured in 1008.8s, 26 raw tsc error(s) total, none above its recorded number.'",
"pnpm check:test-source-alias: 'check-test-source-alias OK — 73 packages with tests scanned ...'",
"pnpm check:cross-package-test-inputs: 'OK: 30 package(s) read outside themselves, all declared ...'"
],
"roster_families_also_run": "Twelve extra commands, all exit 0: check:generated, check-changeset-fixed, check-published-list-mirrors, spec check:meta-url-spelling, spec check:spec-changes, check:authz-resolver, check:console-injection, check:error-code-casing, check:filter-alias-parity, check:i18n-stale-fill, check:published-readme-exports and check-dts-references --self-test",
"ci": "PR head 0b398e8: 32 check runs, 9 success, 3 skipped, 20 in_progress (read once; not waited on)"
},
"line_budget": "829 changed lines (+725 / -104) over 18 files, measured with git diff --shortstat from merge base c2c21f3. The human-merge threshold is 5000. The resumed branch held 651 / 104 of them; this run added 74 (changeset 54, anchors 14, generated 4, contract docblock 2 net). No skills/** or governed surface is touched, so no skill-line reading applies.",
"files_changed": [
".changeset/21326-crypto-context-scope-discriminant.md (new)",
"packages/spec/src/contracts/crypto-provider.ts",
"packages/spec/api-surface/contracts.json (generated)",
"packages/spec/export-origins/contracts.json (generated)",
"packages/services/service-settings/src/local-crypto-provider.ts",
"packages/services/service-settings/src/local-crypto-provider.test.ts",
"packages/services/service-settings/src/settings-service.ts",
"packages/services/service-settings/src/settings-service.test.ts",
"packages/services/service-settings/src/settings-routes.test.ts",
"packages/services/service-settings/src/sys-secret-orphan-report.test.ts",
"packages/services/service-datasource/src/datasource-secret-binder.ts",
"packages/services/service-datasource/src/tests/datasource-secret-binder.test.ts",
"packages/objectql/src/engine.ts",
"packages/objectql/src/secret-fields.test.ts",
"packages/cli/src/utils/secret-reference-union.test.ts",
"packages/cli/src/utils/sys-secret-orphan-sweep.test.ts",
"scripts/adr-anchors/packages__spec__src__contracts__crypto-provider.ts.json (new)",
"scripts/adr-anchors/packages__services__service-settings__src__local-crypto-provider.ts.json (new)"
],
"producer_census": "Measured at c2c21f3 over every non-test encrypt, decrypt and rotateKey call site in *.ts, *.mts, *.js and *.mjs. Exactly three producers: (1) SettingsService, settings-service.ts, the seal in set() and the open in materialiseRow(), scope settings. (2) The ObjectQL engine's secret-field path, engine.ts, the seal in encryptSecretFields and the open in resolveSecret() (which resolveSecretField() calls), scope object_secret_field. (3) The datasource secret binder, datasource-secret-binder.ts, bind() and resolve(), scope datasource_credential. NO fourth producer. The SSO client secret, the webhook secret and headers, and the flow credentials all reach the provider through the engine's resolveSecretField(), so they are producer 2. 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, and its only in-tree implementation is the base64 NoopCryptoAdapter. InMemoryCryptoProvider is a value alias of LocalCryptoProvider.",
"handle_version_design": "The derivation is recorded IN THE CIPHERTEXT, which the provider owns, not in CryptoHandle.version. Every new seal is 'v2:' + base64(iv||tag||ct). Its AAD is 0xFF || 'objectstack/crypto-context-aad/v2' || lp(scope) || lp(namespace) || lp(key), where lp is a 4-byte big-endian UTF-8 length followed by the bytes. decrypt and rotateKey dispatch on the marker and try exactly one derivation: no ':' is version 1 (UTF-8 'namespace|key', read-only, never seals); 'v2:' is version 2; any other marker throws UnknownCiphertextVersionError (fail closed). A missing or non-member scope throws CryptoContextScopeError on every entry point, including a version-1 open. WHY AN EXISTING HANDLE IS UNAMBIGUOUSLY VERSION 1, with no row read: (a) CryptoHandle.version is already a rotation counter (kmsKeyId is 'local:v' plus it), so a rotated pre-versioning handle already reads 2 or more, and the field cannot carry the derivation. (b) sys_secret.ciphertext and CryptoHandle.ciphertext are documented as provider-defined and round-tripped verbatim. (c) Every release's seal emitted bare standard base64 (Buffer#toString('base64'), alphabet A-Z a-z 0-9 + / =, no ':'). This was measured over the provider's full history under both file names via the REST commit list: 10 commits from 4f6bae0 to 222ecc2, each version's seal lines read on both the node:crypto and WebContainer paths. (d) 0xFF never occurs in UTF-8, so the version-1 and version-2 AAD spaces are disjoint, and a marker swap never authenticates (pinned both ways). Also verified: a pre-versioning release handed a v2 ciphertext refuses it (GCM auth failure), so a rollback fails closed. The changeset states this.",
"ablation_legs": "Expected direction, recorded before running: leg 1 RED on the D2 collision vector, leg 2 GREEN. Both legs ran from the committed state (HEAD 0b398e8, provider blob 77423577310b), inside the verify lock, via scripts/ablation-replace.mjs in WRAP mode. MUTATION: aadForVersion2's length-prefixed Buffer.concat (7-line anchor) was replaced by Buffer.from([requireScope(ctx), ctx.namespace, ctx.key].join('|'), 'utf8'). That is a naive join with the scope kept, so only D2 is ablated. On-disk proof: 'anchor x1 → x0', 'replace x0 → x1', 'blob 77423577310b → 0a2fa7a2a5d6'. LEG 1: exit 1, 'Tests 4 failed | 31 passed (35)'. The D2 test failed at line 281, 'expected true to be false' on aadForVersion2(left).equals(aadForVersion2(right)). The pinned v2 vector, the AAD byte pin and the unknown-marker test's positive control failed with it. The D1 scope matrix stayed green, as expected while the scope is in the join. RESTORE: tool output 'ok restored: blob == HEAD (77423577310b) and
git diff HEADis empty'. Independent check: POST_BLOB equals HEAD_BLOB, git diff HEAD 0 bytes, anchor line count 1, naive-join count 0. LEG 2: exit 0, 'Tests 35 passed (35)'. NO BUILD LEG: the test imports the subject as './local-crypto-provider.js' (relative source), so no dist/ is on its resolution path.","semver": "@objectstack/spec minor and @objectstack/service-settings minor, with the BREAKING banner; @objectstack/objectql and @objectstack/service-datasource patch (their public surface does not move). This is the PR #21292 precedent's level and header. The fixed group versions in lockstep. Judging lines: check-changeset-no-major '✓ This diff introduces no
majorbump.' and the offline level axis '✓ LEVEL AXIS: this PR declares clause-②yes... atminoror above'. check-adr-0087-registration '✓ ... 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.' with not-required (no-migration-prescription). Clause-② 'yes' is on the PR body's second line and in the changeset. No gate refused.","deviations": [
"Resume: the predecessor's commits were kept, not redone. Every item was re-verified, and nothing measured before the restart is cited.",
"The first phase-1 runner was killed while it held the lock and was building, to add a retry on lock exit 99. The build re-ran from scratch, and the tree was clean afterwards.",
"The first phase-2 gate runner was stopped after 2 commands to regenerate the spec artifacts first. All 106 gates then ran at 0b398e8.",
"git fetch --shallow-since=2026-06-27 origin main deepened the shared object store, as check-engine-split-ratio prescribes. It also advanced the shared origin/main to 535d1d2. The 4 new main commits touch no file or crypto call site on this branch (checked by diff), so the branch was not re-merged.",
"Labels: the dispatch named no label by name, so the label set written is empty and the one label-write call made only the assign. The path labeler added documentation, size/l, tests and tooling on its own.",
"Focused cli tsc via a scratch tsconfig: it inherited the package include and compiled all of cli (95 errors, all missing-dist TS2307/TS2882/TS7016 in other files, 0 in the touched files). It was superseded by the full cli typecheck, which passed once the whole-workspace dist existed."
],
"mcp_calls": "0",
"api_writes": "3 relay strokes, each one POST /repos/objectstack-ai/objectstack/dispatches (HTTP 204) carrying one write that the fleet-write workflow executes as objectstack-fleet[bot]: (1) pr_create, POST /repos/objectstack-ai/objectstack/pulls, run 37049417309, #21453, 12528 bytes sent and stored identically. (2) assign, POST /repos//issues/21453/assignees (os-tesla), run 37049516941, read back. (3) this os-dev-report comment, POST /repos//issues/21326/comments, via post-stamped.mjs. Not REST: 3 git pushes in this run (a2dcfd2, 629f48a, 0b398e8). Reads only: card, comments, rate_limit, commit history and file contents at 10 historical shas, PR read-back and check-runs. An org-wide search/code read was refused 403 (repository-bound session).",
"open_questions": [],
"out_of_scope_findings": [
"carrier: stage 2 of #21326 (the next claim) · version-1 ciphertext keeps opening with the older (namespace, key) binding until it is re-wrapped, and nothing yet retires version-1 opening after the re-wrap · noted, not filed",
"carrier: the at-tier contract review of PR #21453 · CryptoContextScopeError and UnknownCiphertextVersionError carry no ADR-0112 ledger code. They are in-process only: the settings read path logs and returns null, the binder returns undefined, and the engine throws to privileged in-process callers. The precedent is KeyedDigestKeyUnavailableError. ADR-0128's Builds-on line names ADR-0112 for implementation refusals · noted, not filed",
"carrier: 承接者:无 · objectql src/system-write-organization.test.ts 'publishes both names from the package BARREL' dynamic-imports the whole barrel inside its 5 s clocked window, so it timed out twice on this shared box (shard 1/3, and alone at 5054 ms) and passed at 3260 ms with a longer timeout. AGENTS.md: 'Clocked windows measure behaviour, never loading' · noted, not filed",
"carrier: 承接者:无 · service-settings' CryptoAdapter (inline sys_setting.value_enc) takes {namespace, key} with no scope. It is a separate interface from CryptoContext, its vocabulary is settings-only, and no in-tree implementation binds AAD · noted, not filed",
"carrier: 承接者:无 · NOT MEASURED: out-of-repo direct callers of encrypt, decrypt and rotateKey (org-wide code search answered 403 in this repository-bound session). Census 5945420994 measured zero out-of-repo implementers · noted, not filed"
]
}
objectstack-fleet commentedon Oct 2, 2026
ACCEPT — PR #21453 @
0b398e88(stage 1 of #21326)domain:specseat 2 (session_01YDt3PzwfrkuFzUBF89WPmM), holder of claim5956307339· 2026-10-02T19:12Zmain. The first line isPart of #21326, andClause-②: yessits at a line start, with no closing keyword. 18 files, all inside the claim's stage-1 surface: theCryptoContextcontract, theservice-settingsprovider, the three producers, their pins, the regeneratedapi-surface/andexport-origins/rows, two ADR-anchor records and one changeset.5959407278on this exact head.0b398e88: 32 success and 3 skipped by design:Build Docspath-filtered,Console Pin Gatewith no.objectui-shachange, and the opt-in packed-tarball smoke.check-governed-merges --pr 21453reads NOT governed. 829 changed lines, under the human-merge threshold.5945843248) and the claim's staging:minorfor@objectstack/specand@objectstack/service-settingswith the BREAKING banner, andpatchfor@objectstack/objectqland@objectstack/service-datasource. This is the PR feat(spec): ICryptoProvider gains a required keyedDigest member; LocalCryptoProvider implements it #21292 precedent's level and header; the ADR-0087 disposition isnot-required (no-migration-prescription).Stage 2 (the at-rest rewrap) stays on this card. The review record
5959407278lists what it must carry. Above all, the rewrap must attribute each stored row's scope from the row's holder. A row with no reachable holder is not resealed under a guessed scope (D3).Out-of-scope findings:
@objectstack/objectqltest outside this diff imports the whole barrel inside a clocked window and timed out under load on the dev's shared box. → handled by this seat separately.Next: ready, auto-merge, the queue.
Generated by Claude Code
objectstack-fleet commentedon Oct 2, 2026
Landed: PR #21453 →
57cc695062(stage 1 of #21326)domain:specseat 2 (session_01YDt3PzwfrkuFzUBF89WPmM), holder of claim5956307339· 2026-10-02T19:46Z57cc695062, with one parent (9b7a0ef3fa). It isPart of #21326, so this card stays open.0b398e88(ACCEPT5959625381, at-tier PASS5959407278): 18 of 18 files are blob-equal.CryptoContextcarries a required scope discriminant from a closed set, one member per producer, and every provider entry point refuses a missing or unknown scope.minorfor@objectstack/specand@objectstack/service-settingswith the BREAKING banner, andpatchfor@objectstack/objectqland@objectstack/service-datasource.rotateKey. It must be resumable, safe against a live deployment, and fail closed on any row it cannot read. Record5959407278lists what it must carry. Above all, it must attribute each stored row's scope from the row's holder, and leave a row with no reachable holder as it is, never resealed under a guessed scope.system-write-organization.test.tscase loads the whole package barrel inside vitest's default 5 s window, and timed out twice under load #21457, an@objectstack/objectqltest outside this diff that loads the package barrel inside a 5 s window.pm:dispatched→pm:queue, and the assignee is removed in this act. This seat has no free slot for stage 2 now, so the card goes back to the queue for whicheverdomain:specseat claims it next. Domain, area, type andsecuritylabels stay.⛔ Security family: comments on this card name classes and positions only.
Edited for wording only: the stage-2 sentence first said "unsealed", which could read as decrypted. It means left as it is.
Generated by Claude Code
6 remaining items
objectstack-fleet commentedon Oct 2, 2026
os-dev-report
{
"issue": 21326,
"status": "done",
"branch": "claude/issue-21326-at-rest-rewrap",
"pr": "#21469",
"session": "session_01YDt3PzwfrkuFzUBF89WPmM — subagent run, so this is the parent's id (the Claude-Session trailer on every commit)",
"premise_still_valid": true,
"summary": "Stage 2 of #21326, the at-rest rewrap from ADR-0128 §4.2. The new subcommand
os secret rewrapsits besideos secret orphansand reuses its boot, connection, guards and output conventions. It is a dry run by default and writes only with --apply. It re-seals every version-1 sys_secret row through rotateKey, under the scope of the producer whose holder references that row. The holder comes from the cross-producer reference union the orphan sweep already uses, and each union reference already carries its holder family, so the classifier needed no change and there is no second holder walk. Rows with no holder, rows whose holders belong to different producers, and every row while the union is incomplete are left as they are and reported by class. The run is resumable (a row that readscurrentis skipped as done). It is safe against a live deployment (one updateMany keyed on id plus the ciphertext the run read). It fails closed, and it verifies each re-seal before writing it. @objectstack/service-settings now publishes ciphertextDerivationStatus, the provider's own marker reading, so the CLI never restates the marker grammar. PR #21469 is a draft,Part of #21326withClause-②: noand no closing keyword. Premise check: stage 1 (57cc695) is an ancestor of the base 1fd5664 (merge-base --is-ancestor exit 0).","tests": "Every run below happened under scripts/pm/os-verify-lock.sh. (1) service-settings: test exit 0, 'Test Files 33 passed (33) / Tests 605 passed (605)'; typecheck exit 0. The new pins are ciphertextDerivationStatus against decrypt's own dispatch on the pinned v1 and v2 vectors and the unknown marker. (2) cli typecheck exit 0, covering tsc --noEmit and 'check:test-typecheck: OK — 3 file(s) / 28 error(s) / 6 pinned signature(s) held'; this is pre-existing debt and none of the new test files is in it. (3) cli unit tier: 'Test Files 2 failed | 246 passed (248)'. Both failures were a prerequisite: 'packages/cli is not built (./dist/index.js is absent)'. After the full workspace build, those two files passed, 'Tests 29 passed (29)'. (4) cli integration tier, run at HEAD 6a7c27c on the touched files plus the secret family (sys-secret-rewrap, rewrap.guards, rewrap.driver-contract, orphans.guards, orphans.driver-contract, sys-secret-orphan-sweep, secret-reference-union): 'Test Files 7 passed (7) / Tests 87 passed (87)'. (5) The one-shot-family integration pin was filtered to the new member and the family table: the family table cases pass, plus 'secret rewrap' no-write byte-identical, 'secret rewrap' no missing sqlite file created, and 'secret rewrap --apply' no seed write. All pass. (6) The json-stdout-purity e2e is nightly tier and was not run. Its three assertions for the new member were measured by a direct tsx invocation at 6a7c27c. stdout parses as ONE JSON document (the scan_failed refusal), stdout carries 0 logger records, and all three boot diagnostics are on stderr. No db file was created, and the isolated key home stayed empty. (7) Lint was narrowed and the narrowing is declared. The population, read from eslint.config.mjs, is '/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'. eslint --no-inline-config --format json over the 10 changed TS files reported 10 files, 0 errors and 0 warnings. The config never enables type-aware linting (no parserOptions.project), so the diff cannot move any untouched file's verdict. ABLATION: see ablation_legs.",
"gates": {
"derived": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 6a7c27c derived 107 commands. Each exit code was written to disk before it was read, and all 107 exited 0.",
"reconcile": "dispatch-gates --ran ran.list, exit 0: '✓ dispatch-gates --ran: 107 derived famil(ies) accounted for — 107 run, 0 NOT-MEASURED (a DERIVED zero — all 107 recorded an exit code and none of them is 3).'",
"named_in_dispatch": [
"cli test (unit tier, plus the integration-tier files that hold the secret command's tests) and typecheck: exit 0 (see tests)",
"service-settings test and typecheck: exit 0 / exit 0",
"node scripts/check-changeset-no-major.mjs --base origin/main: exit 0 — '✓ This diff introduces no
majorbump.' The level axis was driven offline with --event, using the PR body as the payload: exit 0 — '✓ LEVEL AXIS: this PR declares clause-②no, so no package here is declared to have grown a published surface.'","node scripts/check-adr-0087-registration.mjs --base origin/main: exit 0 — '✓ check-adr-0087-registration: this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen).'",
"node scripts/check-empty-changeset.mjs --base origin/main: exit 0 — '✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added).'",
"pnpm check:changeset-gate-self-tests: exit 0",
"pnpm check:doc-authoring: exit 0 — '✓ doc authoring guard: sibling-package prose ids hold the baseline — 204 pinned site(s) across 62 file(s) ...'",
"pnpm check:nul-bytes: exit 0 — 'check-nul-bytes: OK (scanned 9800 text file(s) -- 9800 tracked, 0 untracked-not-ignored; skipped 7 binary; no raw ASCII control bytes).'"
],
"selected_verdicts": [
"pnpm check:cli-command-ids: '65 module(s) under packages/cli/src/commands examined, all of them default-export a class whose inheritance chain reaches oclif's
Command'","pnpm check:cli-examples-parity: '1 declared pair(s) agree as SETS'",
"pnpm check:test-source-alias: 'check-test-source-alias OK — 73 packages with tests scanned'",
"pnpm check:engine-double-contract: 'OK — 921 pinned, 129 in the DEBT ledger, 3 exempt.'",
"pnpm check:adr-anchors: 'check-adr-anchors: OK (60 anchored file(s), every governing ADR still referenced ...)'",
"pnpm check:type-check-debt: 'check-type-check-coverage --re-measure: OK — 1 ledger entr(ies) re-measured ..., 26 raw tsc error(s) total, none above its recorded number.'",
"pnpm check:cross-package-test-inputs: 'OK: 30 package(s) read outside themselves, all declared ...'",
"pnpm check:dual-build-cjs-loads: '105 published require entry point(s) across 66 package(s) load'",
"pnpm check:i18n: 'check-i18n-bundles: OK (9 package(s) — all bundles in sync ...)'"
],
"ci": "PR head 6a7c27c at 21:39Z: 32 check runs, 12 completed with no failure and 20 in_progress. Read once and not waited on."
},
"line_budget": "2299 changed lines (+2298 / -1) over 13 files, measured with git diff --shortstat from the merge base 1fd5664. The human-merge threshold is 5000. By file: planner 540, its pins 634, command 393, guards test 247, driver-contract test 276, provider +43, provider pins +43, barrel +5, docs +48, changeset 47, anchor 7, family ledgers +16 / -1. The diff touches no skills/ and no governed surface.",
"files_changed": [
".changeset/21326-secret-rewrap.md (new)",
"content/docs/deployment/cli.mdx",
"packages/cli/src/commands/secret/rewrap.ts (new)",
"packages/cli/src/commands/secret/rewrap.guards.test.ts (new)",
"packages/cli/src/commands/secret/rewrap.driver-contract.test.ts (new)",
"packages/cli/src/utils/sys-secret-rewrap.ts (new)",
"packages/cli/src/utils/sys-secret-rewrap.test.ts (new)",
"packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts",
"packages/cli/test/json-stdout-purity.e2e.test.ts",
"packages/services/service-settings/src/local-crypto-provider.ts",
"packages/services/service-settings/src/local-crypto-provider.test.ts",
"packages/services/service-settings/src/index.ts",
"scripts/adr-anchors/packages__cli__src__utils__sys-secret-rewrap.ts.json (new)"
],
"ciphertext_census_A1": "Measured at 1fd5664 over every non-test encrypt/decrypt/rotateKey call site and every stored ciphertext column. (1) sys_secret.ciphertext is the ONLY store of provider-sealed ciphertext. All three producers write it: SettingsService.set(), the engine's encryptSecretFields and the datasource binder's bind(). Version 1 means no ':' marker. rotateKey reaches all of it. (2) sys_setting.value_enc inline values are sealed by the separate CryptoAdapter interface, not by ICryptoProvider: no CryptoContext and no provider AAD, and the only in-tree implementation is the base64 NoopCryptoAdapter. They are not version-1 ciphertext of this provider and rotateKey cannot reach them, so they are out of this class. ⛔ No second sealing path was built; the run already counts these rows as legacy-inline in os secret orphans. (3) sys_two_factor.backup_codes is sealed by better-auth's symmetricEncrypt under the auth secret, not by the provider. No other class exists.",
"attribution_design_A2": "The 'classifier' is the cross-producer reference union, collectSecretReferenceUnion, which is the reference side of the orphan sweep. Each SecretReference it returns already carries
family, the holder's kind, so NO classifier extension was needed. The planner groups union.references by handleId (holderScopesByHandle) and maps families through SCOPE_OF_HOLDER_FAMILY, a Record over the closed SecretReferenceFamily set: settings→settings, object-field→object_secret_field, datasource→datasource_credential. A fourth family therefore stops compiling until it is mapped. attributeRewrapScope decides in this order: (1) more than one distinct scope → left_conflicting_scope, whatever the union's completeness, because a missing family can add holders but never remove one; (2) union incomplete → left_union_incomplete, because a single visible scope does not prove there is no other; (3) no holder → left_orphan; (4) otherwise the one scope. Several holders of one scope are one attribution, and the row gets one write (pinned). Under --apply an incomplete union is refused outright, naming the family, as orphans --delete does.","A3_properties": {
"resumable": "All state is in the rows. ciphertextDerivationStatus reads
currentfor a re-sealed row, so it is skipped as done, and there is no run log. Pin: the writer dies at its second write. Run 1 gives rewrap 3 and write_failed 1, and the stopped row is stillsuperseded. The re-run re-wraps exactly that row (done 4). A third run re-wraps nothing and writes nothing. The real SQLite pin: a second --apply is alldoneand the table is byte-equal.","live_safe": "The write is ONE updateMany('sys_secret', {where: {id, ciphertext: the ciphertext read}}, patch), and a count of 0 is write_conflict. Source read of all five drivers: SqlDriver and Turso are a single UPDATE … WHERE; the Turso remote face is UPDATE … WHERE with rowsAffected; Mongo uses collection.updateMany; the memory driver has no await between its filter and its write. A driver without updateMany is refused before anything is opened (asCompareAndSetWriter, mirroring asDeletingDriver). Pins: (a) a producer write lands between the read and the write: write_conflict, the producer's value and version are kept, the other rows are written, and the where keys are exactly [ciphertext, id]; (b) the real SqlDriver through the command's own boot: a stale ciphertext gives 0 and leaves the row unchanged, and the stored ciphertext gives 1.",
"fails_closed": "A row that does not open, or whose fields do not form a handle, is refused_unreadable. An unknown marker is refused_unknown_derivation and never opened. Neither is written, the run finishes the rest, and it exits 1 (rewrapUnfinished). The single statement means there is no partial write. Pins: a row sealed under another key stays byte-equal while 4 others are re-wrapped; the unknown marker is never decrypted.",
"verify_before_write": "resealHolds requires all of: same id,
currentderivation, a usable version, and decrypt(next, the same ctx) === the plaintext opened from the stored row. It is checked before the write; otherwise refused_verify_failed. Pins: a re-seal that opens to a different value, and one that does not open, are both refused and never written. Positive control: the honest provider re-wraps the same row.","dry_run": "This is the default. The boot is read-only (deferSchemaDdl + readOnlyProbe, the os migrate plan boot), and writer is null. Every attributed row is opened, re-sealed and verified in memory, so the dry run reports refused_unreadable exactly as --apply would. Pins: store unchanged and updateMany never called; --apply over the same store lands exactly the dry run's byClass; the one-shot-family pin shows a byte-identical database, boot included."
},
"key_handling": "LocalCryptoProvider is resolved BEFORE the boot, from a key that already exists. It is built in mode 'production' with OS_CRYPTO_AUTOKEY withheld, so it never mints a key. It is handed to the composed SettingsServicePlugin, so that service never builds its own default provider, which in a development posture mints a key file. With no key, the settings service gets a provider that refuses every call, and the run returns crypto_key_unavailable once the plan has a row to open. Pins: the guards test (no key → refusal, and nothing in the key home, even with OS_CRYPTO_AUTOKEY=1); the driver-contract test (real boot, development posture, no key → crypto_key_unavailable, no key file, table unchanged, with a positive control that a default-posture provider does mint in that kind of home).",
"ablation_legs": "Expected direction, recorded before running: both legs RED. Both ran from the committed state, HEAD 6a7c27c, with planner blob 75d607ac5dc0, inside the verify lock, through scripts/ablation-replace.mjs in WRAP mode, wrapped by an outer script with trap 'git checkout HEAD -- ABS_PATH' EXIT INT TERM and an independent blob check. LEG 1, the scope-attribution refusal removed: the anchor
if (!scopes || scopes.size === 0) return { left: 'left_orphan' };was replaced by a guessed{ scope: 'settings' }. On disk: 'anchor x1 -> x0', 'replace x0 -> x1', 'blob 75d607ac5dc0 -> 3c509ef4edb1'. Result: exit 1, 'Tests 6 failed | 8 passed (14)'. The orphan pin failed at line 392 ('expected +0 to be 1' on left_orphan), with the count pins that include the orphan (full apply 'rewrap: 5' vs 4, resumable, live-safe, fail-closed, report). Restore: 'ok restored: blob == HEAD (75d607ac5dc0) andgit diff HEADis empty'; independently POST_BLOB == HEAD_BLOB and DIFF_HEAD_BYTES=0. LEG 2, verify-before-write removed: the 3-line resealHolds block was deleted. 'blob 75d607ac5dc0 -> 4fd879b7d1a1'. Result: exit 1, 'Tests 1 failed | 13 passed (14)', exactly the VERIFY BEFORE WRITE pin, at line 563 ('expected +0 to be 1' on refused_verify_failed). Restore proven the same way. NO BUILD LEG: the pins import the subject as './sys-secret-rewrap.js' (relative source), so no dist/ is on the resolution path. After both legs, the union is green at 6a7c27c (7 files / 87 tests).","command": {
"name": "os secret rewrap (packages/cli/src/commands/secret/rewrap.ts). The
secretfamily names its subcommand after its object or act, andrewrapis ADR-0128's own word. The write flag is --apply, the repo-wide spelling for 'write what the dry run showed' (migrate value-shapes, summary-nulls and the rest); orphans' --delete is specific to deletion.","flags": "--apply, --declared-datasources FILE, --no-declared-datasources, -y/--yes, --json, --database-url",
"dry_run_output_shape": "With --json, stdout is ONE document: {mode, report}. report = {mode, keySource (a source name or null, never a value), families: {settings|object-field|datasource: {status, referenceCount, reason on a gap}}, refusal (null, or {gaps, message}), counts: {total, rewrap, done, left, refused, notWritten}, byClass: {rewrap, done, left_orphan, left_conflicting_scope, left_union_incomplete, refused_unreadable, refused_unknown_derivation, refused_verify_failed, write_conflict, write_failed}, rewrapByScope: {settings, object_secret_field, datasource_credential}, notes}. Refusals are one document with
error: union_incomplete (with refused + report), driver_cannot_compare_and_set, crypto_key_unavailable, confirmation_required, declared_datasources_unreadable, boot_failed or scan_failed. The human output prints the same classes and counts. Pinned: the report holds no plaintext, ciphertext, row id or holder coordinate. Exit code: --apply exits 1 when any refused_* or write_* class is non-zero, or when it refuses. The dry run exits 0 after reporting."},
"semver": "@objectstack/cli is minor (a new operator command) and @objectstack/service-settings is minor (a new export, ciphertextDerivationStatus, plus its type). Neither is breaking, so no ADR-0087 marker applies. The PR body and the changeset both say Clause-②: no. The lines that judged it: check-changeset-no-major '✓ This diff introduces no
majorbump.' and the offline level axis '✓ LEVEL AXIS: this PR declares clause-②no, so no package here is declared to have grown a published surface.'; check-adr-0087-registration '✓ ... this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen).' No gate refused.","deviations": [
"Key-file side effect in the shared container. A direct probe of
os secret rewrapat 5015d0d, before it handed the settings service its own provider, minted ~/.objectstack/dev-crypto-key in this container's HOME at 2026-10-02T20:35:20Z. It came from the composed SettingsServicePlugin's default provider in a development posture. I left it in place, because deleting a key file another process may already have adopted is the riskier act. Commit 3200804 closes that path for this command, and the closure is pinned. The later probe at 6a7c27c used an isolated key home and left it empty.","A2's 'extend that one classifier if it needs to return the holder's kind': measured, and it does not need to. SecretReference.family already carries the holder's kind, so no classifier file changed.",
"Design choice for review: the dry run opens, re-seals and verifies rows IN MEMORY. A3 asks the dry run to report 'refused (unreadable)', and only opening a row can tell that. The plaintext never leaves the step for its row and is never returned or printed. The orphans report's 'without decrypting' ruling governs that report, not this command.",
"The composition matches orphans (PlatformObjectsPlugin + SettingsServicePlugin) because the settings service registers sys_setting, the settings family's holder. A first composition without it failed the driver-contract pin with 'no such table: sys_setting'.",
"service-settings tests were invoked as
pnpm test -- --maxWorkers=2. The bare--dropped the flag and the whole package ran, which was the intent, so the reading cited is whole-package.","Memory, Mongo and Turso conditional-write semantics were measured by reading source only. The real SqlDriver's is pinned through the command's own boot.",
"One harness safety check refused an
rm -rf ./*after acdin a probe command. Nothing ran or was removed, and the probes used fresh mktemp directories in the scratchpad instead.","Labels: the dispatch named none, so the label-write call made only the assign. The path labeler added documentation, size/xl, tests and tooling on its own."
],
"mcp_calls": "0",
"api_writes": "3 relay strokes. Each was one POST /repos/objectstack-ai/objectstack/dispatches (HTTP 204) carrying one write, executed by the fleet-write workflow as objectstack-fleet[bot]: (1) pr_create, POST /repos/objectstack-ai/objectstack/pulls, run 37067957286, #21469, 13981 bytes sent and stored identically (read back, draft true); (2) assign, via label-write.mjs: POST /repos//issues/21469/assignees (os-tesla), run 37068033503, read back; (3) this os-dev-report comment, POST /repos//issues/21326/comments, via post-stamped.mjs. Not REST: 9 git pushes (the empty-branch probe, then 8 commit pushes ending at 6a7c27c). Reads only: the card, its comments, stage 1's review/ACCEPT/landing, the PR read-back, check-runs and rate-free relay liveness.",
"open_questions": [],
"out_of_scope_findings": [
"carrier: the next stage of #21326 (retiring version-1 opening) · rows the rewrap LEAVES (orphan, conflicting scope, and every row while a family is a gap) keep the version-1 binding and would stop opening once version-1 opening is retired. The rewrap's by-class counts are the measured input for that stage's decision · noted, not filed",
"carrier: 承接者:无 · pre-existing: os secret orphans composes SettingsServicePlugin with its default provider, which in a development posture with no key mints a key file in the key home during a report that 'writes nothing' (true of the database). Measured through the same composition in this container at 20:35:20Z · noted, not filed"
]
}
objectstack-fleet commentedon Oct 2, 2026
Claim amendment (stage 2):
Clause-②corrected toyes (widening)domain:specseat 2 (session_01YDt3PzwfrkuFzUBF89WPmM), holder of claim5960437209· 2026-10-02T22:01ZThe correction: stage 2's claim read
Clause-②: no("a new operator command; no contract or authorable surface moves"). That was this seat's error. The criterion (execution-duties.md: 「本卡放宽接受集或扩大公开面吗」) asks whether the public surface grows, and stage 2 grows it twice:@objectstack/service-settings(ciphertextDerivationStatusand its type);@objectstack/cli(os secret rewrap).So
Clause-②: yes (widening). The at-tier review5962153191failed PR feat(cli,service-settings): os secret rewrap, the at-rest re-wrap of version-1 sys_secret ciphertext under each holder's producer scope (ADR-0128 §4.2, stage 2) #21469 on exactly this declaration.Nothing else moves. The levels are already
minorfor both packages, no ADR-0087 marker applies, and the file surface is unchanged.Next: the dev corrects the PR body's line and the changeset, adds the two refusal codes the review found missing from the body's list, and the new head gets a review scoped to that change.
⛔ Security family: comments on this card name classes and positions only.
Generated by Claude Code
objectstack-fleet commentedon Oct 2, 2026
os-dev-report
{
"issue": 21326,
"status": "done",
"branch": "claude/issue-21326-at-rest-rewrap",
"pr": "#21469",
"session": "session_01YDt3PzwfrkuFzUBF89WPmM — subagent run, so this is the parent's id (the Claude-Session trailer on every commit)",
"premise_still_valid": true,
"summary": "FIX ROUND for PR #21469, after at-tier review 5962153191 FAILED item ② and the seat amended its claim (5962171746). The PR head is now 219457c (previously 6a7c27c). This round made exactly two changes. (1) In .changeset/21326-secret-rewrap.md,
Clause-②: nobecameClause-②: yes (widening). The levels stay minor for both packages, the prose is unchanged, and there is still no ADR-0087 marker. (2) In the PR body, line 2 now readsClause-②: yes (widening), and the list of refusals gainsno_engineandno_sys_secret_driver. The body was patched through the relay (issue_patch) and keeps its existing session-URL footer as its last lines. No code, test, doc or other file changed. Everything the review judged right stands as delivered at 6a7c27c (see the earlier report 5961923912). Premise check: stage 1 (57cc695) is still an ancestor of the merge base 1fd5664.","tests": "No code changed in this round: the diff 6a7c27c..219457c is one line of .changeset/21326-secret-rewrap.md. No test was re-run. Every test reading in report 5961923912 was taken at 6a7c27c and is unchanged by this round: service-settings 33 files / 605 tests, cli integration 7 files / 87 tests, cli unit 248/248 files, both typechecks exit 0, and both ablation legs RED then restored. The gates this round owes were run on the new head (see gates).",
"gates": {
"head": "219457c029, merge base 1fd5664 (from the gate's own line 'Diffing HEAD from 1fd5664 (merge base with origin/main).')",
"named_in_fix_round": [
"node scripts/check-changeset-no-major.mjs --base origin/main --event EVENT, where EVENT is a pull_request payload whose body is the corrected PR body: exit 0 — '✓ This diff introduces no
majorbump.' / '✓ LEVEL AXIS: this PR declares clause-②yes (widening), and no package whosepackages/**/src/**it moves is gradedpatch.' / '· declaration line:Clause-②: yes (widening)' / '· direction arm:widening'","node scripts/check-adr-0087-registration.mjs --base origin/main: exit 0 — '✓ check-adr-0087-registration: this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen).'",
"node scripts/check-empty-changeset.mjs --base origin/main: exit 0 — '✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added).' / '✓ No changeset from the merge base modified or deleted by this diff (#17712).'",
"pnpm check:nul-bytes: exit 0 — 'check-nul-bytes: OK (scanned 9800 text file(s) -- 9800 tracked, 0 untracked-not-ignored; skipped 7 binary; no raw ASCII control bytes).'"
],
"earlier_round": "The 107 derived families, all exit 0 and reconciled with --ran (0 NOT-MEASURED), were run at 6a7c27c. They were not re-derived for a one-line changeset edit.",
"ci": "PR head 219457c at 22:04Z: 39 check runs, 16 completed with no failure, 18 in_progress and 5 queued. Read once and not waited on."
},
"line_budget": "2299 changed lines (+2298 / -1) over 13 files from the merge base 1fd5664. This round is a 1-line in-place change to the changeset, so the totals are unchanged. The human-merge threshold is 5000. No governed surface is touched.",
"files_changed": [
".changeset/21326-secret-rewrap.md (new)",
"content/docs/deployment/cli.mdx",
"packages/cli/src/commands/secret/rewrap.ts (new)",
"packages/cli/src/commands/secret/rewrap.guards.test.ts (new)",
"packages/cli/src/commands/secret/rewrap.driver-contract.test.ts (new)",
"packages/cli/src/utils/sys-secret-rewrap.ts (new)",
"packages/cli/src/utils/sys-secret-rewrap.test.ts (new)",
"packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts",
"packages/cli/test/json-stdout-purity.e2e.test.ts",
"packages/services/service-settings/src/local-crypto-provider.ts",
"packages/services/service-settings/src/local-crypto-provider.test.ts",
"packages/services/service-settings/src/index.ts",
"scripts/adr-anchors/packages__cli__src__utils__sys-secret-rewrap.ts.json (new)"
],
"ciphertext_census_A1": "Measured at 1fd5664 over every non-test encrypt/decrypt/rotateKey call site and every stored ciphertext column. (1) sys_secret.ciphertext is the ONLY store of provider-sealed ciphertext. All three producers write it: SettingsService.set(), the engine's encryptSecretFields and the datasource binder's bind(). Version 1 means no ':' marker. rotateKey reaches all of it. (2) sys_setting.value_enc inline values are sealed by the separate CryptoAdapter interface, not by ICryptoProvider: no CryptoContext and no provider AAD, and the only in-tree implementation is the base64 NoopCryptoAdapter. They are not version-1 ciphertext of this provider and rotateKey cannot reach them, so they are out of this class. ⛔ No second sealing path was built; the run already counts these rows as legacy-inline in os secret orphans. (3) sys_two_factor.backup_codes is sealed by better-auth's symmetricEncrypt under the auth secret, not by the provider. No other class exists.",
"attribution_design_A2": "The 'classifier' is the cross-producer reference union, collectSecretReferenceUnion, which is the reference side of the orphan sweep. Each SecretReference it returns already carries
family, the holder's kind, so NO classifier extension was needed. The planner groups union.references by handleId (holderScopesByHandle) and maps families through SCOPE_OF_HOLDER_FAMILY, a Record over the closed SecretReferenceFamily set: settings→settings, object-field→object_secret_field, datasource→datasource_credential. A fourth family therefore stops compiling until it is mapped. attributeRewrapScope decides in this order: (1) more than one distinct scope → left_conflicting_scope, whatever the union's completeness, because a missing family can add holders but never remove one; (2) union incomplete → left_union_incomplete, because a single visible scope does not prove there is no other; (3) no holder → left_orphan; (4) otherwise the one scope. Several holders of one scope are one attribution, and the row gets one write (pinned). Under --apply an incomplete union is refused outright, naming the family, as orphans --delete does.","A3_properties": {
"resumable": "All state is in the rows. ciphertextDerivationStatus reads
currentfor a re-sealed row, so it is skipped as done, and there is no run log. Pin: the writer dies at its second write. Run 1 gives rewrap 3 and write_failed 1, and the stopped row is stillsuperseded. The re-run re-wraps exactly that row (done 4). A third run re-wraps nothing and writes nothing. The real SQLite pin: a second --apply is alldoneand the table is byte-equal.","live_safe": "The write is ONE updateMany('sys_secret', {where: {id, ciphertext: the ciphertext read}}, patch), and a count of 0 is write_conflict. Source read of all five drivers: SqlDriver and Turso are a single UPDATE … WHERE; the Turso remote face is UPDATE … WHERE with rowsAffected; Mongo uses collection.updateMany; the memory driver has no await between its filter and its write. A driver without updateMany is refused before anything is opened (asCompareAndSetWriter, mirroring asDeletingDriver). Pins: (a) a producer write lands between the read and the write: write_conflict, the producer's value and version are kept, the other rows are written, and the where keys are exactly [ciphertext, id]; (b) the real SqlDriver through the command's own boot: a stale ciphertext gives 0 and leaves the row unchanged, and the stored ciphertext gives 1.",
"fails_closed": "A row that does not open, or whose fields do not form a handle, is refused_unreadable. An unknown marker is refused_unknown_derivation and never opened. Neither is written, the run finishes the rest, and it exits 1 (rewrapUnfinished). The single statement means there is no partial write. Pins: a row sealed under another key stays byte-equal while 4 others are re-wrapped; the unknown marker is never decrypted.",
"verify_before_write": "resealHolds requires all of: same id,
currentderivation, a usable version, and decrypt(next, the same ctx) === the plaintext opened from the stored row. It is checked before the write; otherwise refused_verify_failed. Pins: a re-seal that opens to a different value, and one that does not open, are both refused and never written. Positive control: the honest provider re-wraps the same row.","dry_run": "This is the default. The boot is read-only (deferSchemaDdl + readOnlyProbe, the os migrate plan boot), and writer is null. Every attributed row is opened, re-sealed and verified in memory, so the dry run reports refused_unreadable exactly as --apply would. Pins: store unchanged and updateMany never called; --apply over the same store lands exactly the dry run's byClass; the one-shot-family pin shows a byte-identical database, boot included."
},
"key_handling": "LocalCryptoProvider is resolved BEFORE the boot, from a key that already exists. It is built in mode 'production' with OS_CRYPTO_AUTOKEY withheld, so it never mints a key. It is handed to the composed SettingsServicePlugin, so that service never builds its own default provider, which in a development posture mints a key file. With no key, the settings service gets a provider that refuses every call, and the run returns crypto_key_unavailable once the plan has a row to open. Pins: the guards test (no key → refusal, and nothing in the key home, even with OS_CRYPTO_AUTOKEY=1); the driver-contract test (real boot, development posture, no key → crypto_key_unavailable, no key file, table unchanged, with a positive control that a default-posture provider does mint in that kind of home).",
"ablation_legs": "Expected direction, recorded before running: both legs RED. Both ran from the committed state, HEAD 6a7c27c, with planner blob 75d607ac5dc0, inside the verify lock, through scripts/ablation-replace.mjs in WRAP mode, wrapped by an outer script with trap 'git checkout HEAD -- ABS_PATH' EXIT INT TERM and an independent blob check. LEG 1, the scope-attribution refusal removed: the anchor
if (!scopes || scopes.size === 0) return { left: 'left_orphan' };was replaced by a guessed{ scope: 'settings' }. On disk: 'anchor x1 -> x0', 'replace x0 -> x1', 'blob 75d607ac5dc0 -> 3c509ef4edb1'. Result: exit 1, 'Tests 6 failed | 8 passed (14)'. The orphan pin failed at line 392 ('expected +0 to be 1' on left_orphan), with the count pins that include the orphan (full apply 'rewrap: 5' vs 4, resumable, live-safe, fail-closed, report). Restore: 'ok restored: blob == HEAD (75d607ac5dc0) andgit diff HEADis empty'; independently POST_BLOB == HEAD_BLOB and DIFF_HEAD_BYTES=0. LEG 2, verify-before-write removed: the 3-line resealHolds block was deleted. 'blob 75d607ac5dc0 -> 4fd879b7d1a1'. Result: exit 1, 'Tests 1 failed | 13 passed (14)', exactly the VERIFY BEFORE WRITE pin, at line 563 ('expected +0 to be 1' on refused_verify_failed). Restore proven the same way. NO BUILD LEG: the pins import the subject as './sys-secret-rewrap.js' (relative source), so no dist/ is on the resolution path. After both legs, the union is green at 6a7c27c (7 files / 87 tests).","command": {
"name": "os secret rewrap (packages/cli/src/commands/secret/rewrap.ts). The
secretfamily names its subcommand after its object or act, andrewrapis ADR-0128's own word. The write flag is --apply, the repo-wide spelling for 'write what the dry run showed' (migrate value-shapes, summary-nulls and the rest); orphans' --delete is specific to deletion.","flags": "--apply, --declared-datasources FILE, --no-declared-datasources, -y/--yes, --json, --database-url",
"dry_run_output_shape": "With --json, stdout is ONE document: {mode, report}. report = {mode, keySource (a source name or null, never a value), families: {settings|object-field|datasource: {status, referenceCount, reason on a gap}}, refusal (null, or {gaps, message}), counts: {total, rewrap, done, left, refused, notWritten}, byClass: {rewrap, done, left_orphan, left_conflicting_scope, left_union_incomplete, refused_unreadable, refused_unknown_derivation, refused_verify_failed, write_conflict, write_failed}, rewrapByScope: {settings, object_secret_field, datasource_credential}, notes}. Refusals are one document with
error: union_incomplete (with refused + report), driver_cannot_compare_and_set, crypto_key_unavailable, confirmation_required, declared_datasources_unreadable, boot_failed, no_engine, no_sys_secret_driver or scan_failed. The human output prints the same classes and counts. Pinned: the report holds no plaintext, ciphertext, row id or holder coordinate. Exit code: --apply exits 1 when any refused_* or write_* class is non-zero, or when it refuses. The dry run exits 0 after reporting."},
"semver": "@objectstack/cli is minor (a new published operator command) and @objectstack/service-settings is minor (a new root export, ciphertextDerivationStatus, plus its type). Both are additive widenings, and the declaration is now
Clause-②: yes (widening)in the changeset and on line 2 of the PR body. There is no BREAKING banner and no ADR-0087 marker. The lines that judged it at 219457c: '✓ LEVEL AXIS: this PR declares clause-②yes (widening), and no package whosepackages/**/src/**it moves is gradedpatch.' with direction armwidening; '✓ This diff introduces nomajorbump.'; '✓ check-adr-0087-registration: this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen).' No gate refused.","deviations": [
"A conflict to rule on, left as it is: the PR body's 'Gates and tests' section still quotes the earlier round's offline level-axis line, "✓ LEVEL AXIS: this PR declares clause-②
no, so no package here is declared to have grown a published surface." The fix-round order said to change only line 2 and the refusal list, and to change nothing else, so I left that quote. It is now stale. The seat can replace it with this round's line: "✓ LEVEL AXIS: this PR declares clause-②yes (widening), and no package whosepackages/**/src/**it moves is gradedpatch." The same applies to the same quote in report 5961923912 (gates and semver).","The worktree had been removed after the first round, so it was re-created on the existing branch at its pushed tip 6a7c27c (equal to origin), and pnpm install was run. No main merge was needed: the push was accepted.",
"From the first round, still standing: the key file ~/.objectstack/dev-crypto-key was minted in this container at 2026-10-02T20:35:20Z by an early probe and left in place. The review accepted this."
],
"mcp_calls": "0",
"api_writes": "This round: 2 relay strokes, each one POST /repos/objectstack-ai/objectstack/dispatches (HTTP 204) executed by the fleet-write workflow as objectstack-fleet[bot]. (1) issue_patch, PATCH /repos//issues/21469 (the PR body), run 37070422025: 14030 bytes sent and stored identically, read back over REST as equal to the corrected body, footer last, draft true. (2) This os-dev-report comment, POST /repos//issues/21326/comments, via post-stamped.mjs. Not REST: 1 git push (219457c). The first round's 3 relay strokes and 9 pushes are in report 5961923912. Reads only: review 5962153191, claim amendment 5962171746, the PR read-back, and check-runs.",
"open_questions": [],
"out_of_scope_findings": [
"carrier: the next stage of #21326 (retiring version-1 opening) · rows the rewrap leaves (left_orphan, left_conflicting_scope, left_union_incomplete, refused_unreadable, refused_unknown_derivation) stay outside the current derivation. The review adds the caveat that left_orphan on the one-shot boot is a lower bound on attribution. The dry run's byClass is the census that stage needs · noted, not filed",
"carrier: the dispatching seat (escalated to file by review 5962153191 ③) · pre-existing: os secret orphans composes SettingsServicePlugin with its default provider, which in a development posture with no key mints a key file during a report that writes nothing to the database · noted here, filing is the seat's"
]
}
objectstack-fleet commentedon Oct 2, 2026
ACCEPT — PR #21469 @
219457c0(stage 2 of #21326: the at-rest rewrap)domain:specseat 2 (session_01YDt3PzwfrkuFzUBF89WPmM), holder of claim5960437209(amendment5962171746) · 2026-10-02T22:35Zmain. The first line isPart of #21326, andClause-②: yes (widening)sits at a line start, with no closing keyword. 13 files: a newos secretsubcommand, its planner and pins, one provider export with pins, the CLI docs page, one changeset and one ADR anchor.5962153191at6a7c27c2, onClause-②only, which was this seat's error in the claim;5962356675on this exact head. The re-review carried the first record's other judgments by reference.219457c0: 43 success and 6 skipped by design:Console Pin Gate, the opt-in packed-tarball smoke, and the body-edit reruns ofAuto Label/Check PR Size.check-governed-merges --pr 21469reads NOT governed. 2,299 changed lines.os secret rewrapre-seals version-1 provider ciphertext throughrotateKey.@objectstack/cliand@objectstack/service-settingsminor, withClause-②: yes (widening)and no ADR-0087 marker.5962153191:left_orphanis a lower bound on a one-shot boot;Out-of-scope findings:
os secret orphanscan create a dev key file during a report that writes nothing. → filed as finding(cli):os secret orphans, a report that "writes nothing", composes the settings service with its default crypto provider, which mints a key file in the key home in a development posture with no key #21471.Next: ready, auto-merge, the queue.
⛔ Security family: comments on this card name classes and positions only.
Generated by Claude Code
objectstack-fleet commentedon Oct 2, 2026
Landed: PR #21469 →
0557c2f674(stage 2 of #21326: the at-rest rewrap)domain:specseat 2 (session_01YDt3PzwfrkuFzUBF89WPmM), holder of claim5960437209(amendment5962171746) · 2026-10-02T23:11Z0557c2f674, with one parent (0b8239111f).219457c0(ACCEPT on this card, re-review PASS5962356675): 13 of 13 files are blob-equal.os secret rewrapre-seals version-1 provider ciphertext under the current derivation, throughrotateKey.@objectstack/service-settingspublishesciphertextDerivationStatus.5945843248accepted "D1, D2, D3, with a versioned handle and the at-rest rewrap migration". Stage 1 (57cc695062) built D1–D3 and the versioned handle, and stage 2 builds the rewrap.needs-user-decision.⛔ Security family: comments on this card name classes and positions only.
Generated by Claude Code
objectstack-fleet commentedon Oct 2, 2026
决策:版本 1 密文的开启路径,是否在改封工具落地后退役
domain:spec坐席 2(session_01YDt3PzwfrkuFzUBF89WPmM)· 2026-10-02T23:12Z · 锚定本卡(SKILL.md 〈升级与决策〉②:决策默认锚在所属 issue)维护者速读
本卡的范围已全部交付:新写入的密钥一律用新的、按来源区分的绑定方式加密;旧格式的密钥仍能读出,并新增了一个运维命令
os secret rewrap,可把旧格式逐条改封成新格式。剩下一个问题不在原范围内:要不要在之后的某个版本里,彻底停止读取旧格式?A=停止读取(升级前须先跑改封;没能改封的那部分密钥会永久读不出);B=继续读取但从不再用旧格式写入,本卡就此关闭(主流做法,如 AWS KMS、Vault 默认都保留旧版本可解密)。坐席建议 B。选 A 还是 B?Governing text
packages/spec/src/contracts/crypto-provider.ts中CryptoContext的「What it does not cover yet」一段:旧绑定「holds until the ciphertext is re-wrapped」,实现「MUST NOT treat such a ciphertext as attributed to a producer」。5945843248:接受的范围是「D1, D2, D3, with a versioned handle and the at-rest rewrap migration」,不含退役旧派生。前提(每条带 re-check,于
main实测)git grep -n -E "UnknownCiphertextVersionError|superseded" origin/main -- packages/services/service-settings/src/local-crypto-provider.ts→:253的错误类、:326的抛出、:342的CiphertextDerivationStatus = 'current' | 'superseded' | 'unknown'。main。git ls-tree --name-only origin/main packages/cli/src/commands/secret/→rewrap.ts及其两个测试。5962153191)。另外还有读不开的行。这些行一旦退役旧派生,就再也读不出。具体问题
改封工具落地之后,provider 是否要在某个版本起拒绝开启版本 1 密文?
选项
os secret rewrap --apply,且「留下」与「读不开」的行要先人工处理。处理不了的那部分密钥(设置、记录上的 secret 字段、数据源凭据)永久读不出。不可回退。completed关闭。新写入恒用新派生;旧密文继续可读,运维可随时自行改封。业务含义直译:
四维分析
os-decision-facets
min_decryption_version才拒绝旧版本。平台强制退役在主流里是例外。推荐:B。 只看①选 B;②③④ 是否翻转:否(②零拉动、④零新增义务同向,③持平)。回退: A,但须先拿到每个部署的
os secret rewrap干跑分类计数(byClass中superseded归零,留下与读不开两类均有人工处置结论),再加升级前置门。置信缺口: 各部署(含 cloud)现存版本 1 行的数量,以及留下与读不开两类的规模,本会话测不到;也不知道是否有合规或客户要求平台声明更强的静态绑定。这正是 ADR-0128 §4 自列的触发条件之一。若出现这样的要求,应改走 A。裁后执行
completed关闭本卡,引用本裁决。合同段落「What it does not cover yet」的事实不变(旧绑定在改封前一直有效),不需要改。ADR-0128 的落地注记(D1–D3 与改封工具已建成)作为 Tier H 文本另行处理。os secret rewrap;合同段落改写;BREAKING changeset 与 ADR-0087 处置;外加 ADR-0128 落地注记另卡(Tier H)。dispatch 前先要求实测各部署的干跑计数。⛔ Security family: comments on this card name classes and positions only.
Generated by Claude Code
objectstack-fleet commentedon Oct 3, 2026
Ruling: batch #270 item 3 · letter B · maintainer 「其他issue同意」 2026-10-03T01:52Z
Director seat, summon #32,
session_016tKoy8NJa35Yih1FdzrVmn. Written asobjectstack-fleet[bot]through the relay. ⛔ Classes and positions only.The ruling
B: version-1 ciphertexts stay readable, and are never written again.
os secret rewrap. The platform does not force retirement of the old derivation.Not taken: A, a forced retirement with an upgrade gate. It would add a permanent upgrade precondition and an irreversible disposition of the rows the re-wrap deliberately leaves, for a hazard ADR-0128 §1.4 measured at zero instances.
四棱(本裁决新记录)
Execution
completedin this act. Stages 1 and 2 (PR feat(spec,service-settings): CryptoContext gains a required scope discriminant, bound into a delimiter-safe, versioned AAD (ADR-0128 D1-D3, stage 1) #21453, PR feat(cli,service-settings): os secret rewrap, the at-rest re-wrap of version-1 sys_secret ciphertext under each holder's producer scope (ADR-0128 §4.2, stage 2) #21469) delivered its scope.Generated by Claude Code
docs(adr): ADR-0128 §4 dated note — the keyed-digest break did not ca…
feat(spec,service-settings): CryptoContext gains a required scope dis…
feat(cli,service-settings): os secret rewrap, the at-rest re-wrap of …