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
Claim: PM loop round 1 (stage 2 of a staged card: the at-rest rewrap)
Session:
session_01YDt3PzwfrkuFzUBF89WPmMAccount:
os-tesla(the seat's linked user asGET /useranswers it; the card's assignee)Branch:
claude/issue-21326-at-rest-rewrapWorktree:
objectstack-issue-21326-s2Domain:
domain:specSeat:
domain:spec#2File surface (stage 2, at
1fd56645af): the rewrap of version-1 ciphertext to the current derivation, ADR-0128 §4.2, throughrotateKey.os secretfamily, besidepackages/cli/src/commands/secret/orphans.ts. It is resumable, safe to run against a live deployment, and fails closed on any row it cannot read. A dry run reports before anything is written.packages/cli/src/utils/sys-secret-orphan-sweep.tsandpackages/services/service-settings/src/sys-secret-orphan-report.tsalready make. ⛔ A row with no reachable holder, or with holders that disagree on scope, is not re-wrapped under a guessed scope (D3): it is reported and left as it is.packages/services/service-settings/src/whererotateKeyor the provider needs it, plus tests, pins, the CLI docs page for the new command, and changesets.packages/spec/src/contracts/crypto-provider.ts, unless the dev measures that the rewrap cannot be built without a contract change. Then it returns here before building.packages/objectql/src/engine.ts(PR fix(metadata-protocol)!: a metadata body's stored content hash is served and compared only in keyed form, never copied, never evaluated (#21207) #21436 is open on it).5959407278③).Container & model:
L,mode:subagent,model: opus(dispatch-gates --tierat1fd56645af: no path-derived mandate; floor sonnet · default opus · ceilingCONTRACT_REVIEW_TIER). This is security-family code that rewrites data at rest, so an at-tier review runs as a separate isolated subagent after the PR opens, whether or not the contract trigger fires.Clause-②: no (a new operator command; no contract or authorable surface moves)
Thread-read: 5960164329
Serial constraints cleared:
57cc695062(landing5960164329).commands/secret/, the orphan sweep or report, theservice-settingsprovider, the settings service or the datasource secret binder. The one hit near this surface, PR fix(metadata-protocol)!: a metadata body's stored content hash is served and compared only in keyed form, never copied, never evaluated (#21207) #21436, is on objectql'sengine.ts, which this stage does not write.Seat terms: p3, under the maintainer's 「p3 的卡也可以派了」 (
5952442849). This PR isPart of #21326. The card closes with the stage that settles version-1 opening.⛔ 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": "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 …