Skip to content

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

Closed

Description

@objectstack-fleet

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.

⚠️ Security family: this card names classes and positions only.

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 (ICryptoProvider and LocalCryptoProvider in packages/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. rotateKey is 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 constructs LocalCryptoProvider through a link: dependency (census 5945420994), 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) and domain: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

  1. objectstack-fleet commented on Oct 2, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 (stage 2 of a staged card: the at-rest rewrap)
    Session: session_01YDt3PzwfrkuFzUBF89WPmM
    Account: os-tesla (the seat's linked user as GET /user answers it; the card's assignee)
    Branch: claude/issue-21326-at-rest-rewrap
    Worktree: objectstack-issue-21326-s2
    Domain: domain:spec
    Seat: domain:spec#2
    File surface (stage 2, at 1fd56645af): the rewrap of version-1 ciphertext to the current derivation, ADR-0128 §4.2, through rotateKey.

    • An operator command in the existing os secret family, beside packages/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.
    • Per-row scope attribution from each row's holder, through the reachability classification that packages/cli/src/utils/sys-secret-orphan-sweep.ts and packages/services/service-settings/src/sys-secret-orphan-report.ts already 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/ where rotateKey or the provider needs it, plus tests, pins, the CLI docs page for the new command, and changesets.
    • ⛔ Not this stage:
      • retiring version-1 opening, and the contract paragraph that names it. That is a later stage, after this one lands, and it carries its own question: it breaks any deployment that has not run the rewrap.
      • 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).
      • ADR-0128's dated implementation note (Tier H, a separate card).
      • an HTTP surface: the rewrap stays an operator command, so no new refusal answers a request (ADR-0112, review 5959407278 ③).
    • Stop on breach and explain in the report.

    Container & model: L, mode:subagent, model: opus (dispatch-gates --tier at 1fd56645af: no path-derived mandate; floor sonnet · default opus · ceiling CONTRACT_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:

    Seat terms: p3, under the maintainer's 「p3 的卡也可以派了」 (5952442849). This PR is Part 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

  2. objectstack-fleet commented on Oct 2, 2026

    @objectstack-fleet
    ContributorAuthor

    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 rewrap sits beside os secret orphans and 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 reads current is 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 #21326 with Clause-②: no and 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 major bump.' 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 current for 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 still superseded. 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 all done and 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, current derivation, 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) and git diff HEAD is 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 secret family names its subcommand after its object or act, and rewrap is 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 major bump.' 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 rewrap at 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 a cd in 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"
    ]
    }

  3. objectstack-fleet commented on Oct 2, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim amendment (stage 2): Clause-② corrected to yes (widening)

    domain:spec seat 2 (session_01YDt3PzwfrkuFzUBF89WPmM), holder of claim 5960437209 · 2026-10-02T22:01Z

    • The 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:

      • a new root export from @objectstack/service-settings (ciphertextDerivationStatus and its type);
      • a new published operator command in @objectstack/cli (os secret rewrap).

      So Clause-②: yes (widening). The at-tier review 5962153191 failed 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 minor for 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

  4. objectstack-fleet commented on Oct 2, 2026

    @objectstack-fleet
    ContributorAuthor

    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-②: no became Clause-②: 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 reads Clause-②: yes (widening), and the list of refusals gains no_engine and no_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 major bump.' / '✓ LEVEL AXIS: this PR declares clause-② yes (widening), and no package whose packages/**/src/** it moves is graded patch.' / '· 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 current for 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 still superseded. 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 all done and 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, current derivation, 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) and git diff HEAD is 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 secret family names its subcommand after its object or act, and rewrap is 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 whose packages/**/src/** it moves is graded patch.' with direction arm widening; '✓ This diff introduces no major bump.'; '✓ 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 whose packages/**/src/** it moves is graded patch." 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"
    ]
    }

  5. objectstack-fleet commented on Oct 2, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT — PR #21469 @ 219457c0 (stage 2 of #21326: the at-rest rewrap)

    domain:spec seat 2 (session_01YDt3PzwfrkuFzUBF89WPmM), holder of claim 5960437209 (amendment 5962171746) · 2026-10-02T22:35Z

    • Shape (read on GitHub): a draft against main. The first line is Part of #21326, and Clause-②: yes (widening) sits at a line start, with no closing keyword. 13 files: a new os secret subcommand, its planner and pins, one provider export with pins, the CLI docs page, one changeset and one ADR anchor.
    • Contract review:
      • at-tier FAIL 5962153191 at 6a7c27c2, on Clause-② only, which was this seat's error in the claim;
      • the claim amendment, and a one-line fix round;
      • re-review PASS 5962356675 on this exact head. The re-review carried the first record's other judgments by reference.
    • CI on 219457c0: 43 success and 6 skipped by design: Console Pin Gate, the opt-in packed-tarball smoke, and the body-edit reruns of Auto Label / Check PR Size.
    • Governed surface: check-governed-merges --pr 21469 reads NOT governed. 2,299 changed lines.
    • What lands, against ADR-0128 §4.2 and the claim:
      • The command: os secret rewrap re-seals version-1 provider ciphertext through rotateKey.
      • Attribution: each row's scope comes from its holder, through the existing reference union, with no second holder walk. A row with no holder, with holders of different scopes, or seen while any family is a gap, is left as it is and reported by class. Nothing is resealed under a guessed scope (D3).
      • Resumable: the state is in the rows.
      • Safe against a live deployment: one conditional write keyed on the ciphertext read. A driver that cannot do it is refused before anything opens.
      • Fails closed: no partial write is possible.
      • Verified before write, and a dry run that writes nothing is the default.
      • No key is ever minted.
      • Output: classes and counts only.
      • Release: @objectstack/cli and @objectstack/service-settings minor, with Clause-②: yes (widening) and no ADR-0087 marker.
    • What the later stage carries (retiring version-1 opening), per review 5962153191:
      • the rewrap's left and refused classes as a measured precondition (zero rows still on version 1, per deployment);
      • left_orphan is a lower bound on a one-shot boot;
      • the operating sequence (orphans first, then the rewrap; conflicting-scope rows need an operator decision);
      • the contract paragraph and a rollback statement;
      • ADR-0128's dated note, Tier H, as a separate card.

    Out-of-scope findings:

    Next: ready, auto-merge, the queue.

    ⛔ Security family: comments on this card name classes and positions only.


    Generated by Claude Code

  6. objectstack-fleet commented on Oct 2, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #21469 → 0557c2f674 (stage 2 of #21326: the at-rest rewrap)

    domain:spec seat 2 (session_01YDt3PzwfrkuFzUBF89WPmM), holder of claim 5960437209 (amendment 5962171746) · 2026-10-02T23:11Z

    • Landed: 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 went through the merge queue as 0557c2f674, with one parent (0b8239111f).
    • Blob check against the ACCEPTed head 219457c0 (ACCEPT on this card, re-review PASS 5962356675): 13 of 13 files are blob-equal.
    • Delivered:
      • os secret rewrap re-seals version-1 provider ciphertext under the current derivation, through rotateKey.
      • Each row's scope comes from its holder, and a row with no holder, conflicting holders or an incomplete census is left as it is.
      • It is resumable, safe against a live deployment, fails closed and verifies before it writes. A dry run is the default.
      • @objectstack/service-settings publishes ciphertextDerivationStatus.
    • This card's accepted scope is now complete. Triage 5945843248 accepted "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.
    • One question is left, and it is not in that scope: whether version-1 opening is ever retired. Retiring it would make every un-rewrapped secret unreadable, an irreversible act on data at rest, so it is the maintainer's. The analysis follows in the next comment, and the card leaves the active queue for needs-user-decision.

    ⛔ Security family: comments on this card name classes and positions only.


    Generated by Claude Code

  7. objectstack-fleet commented on Oct 2, 2026

    @objectstack-fleet
    ContributorAuthor

    决策:版本 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

    • ADR-0128 §4(按名引):迁移期间「两种派生必须并存,每个密文自带封它的那种派生」。该 ADR 没有裁定之后是否退役旧派生。
    • 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」,不含退役旧派生。
    • Prior rulings read: version-1,derivation,rewrap,re-wrap,aad,ciphertext → 11 hits; ADR-0035 Decision §3, ADR-0035 Decision §6, ADR-0085 Decision §3, ADR-0085 Decision §5, ADR-0108 D2, ADR-0128 D1, ADR-0128 D2; thread: none; repo: objectstack-ai/objectstack。逐条读过:ADR-0128 D1/D2 是本卡已交付的方向;其余各条只是「derivation」「ciphertext」等词的泛匹配,都没有对旧派生是否退役作出裁定。
    • 协议声明: 选 A 要改协议(provider 拒开旧派生,合同段落随之改写);选 B 不改协议。

    前提(每条带 re-check,于 main 实测)

    1. 旧派生只读、从不再写,未知派生失败关闭。 git grep -n -E "UnknownCiphertextVersionError|superseded" origin/main -- packages/services/service-settings/src/local-crypto-provider.ts → :253 的错误类、:326 的抛出、:342 的 CiphertextDerivationStatus = 'current' | 'superseded' | 'unknown'。
    2. 改封工具已在 main。 git ls-tree --name-only origin/main packages/cli/src/commands/secret/ → rewrap.ts 及其两个测试。
    3. 改封会刻意留下三类行: 无持有者、持有者范围冲突、来源普查不完整时的行(复核 5962153191)。另外还有读不开的行。这些行一旦退役旧派生,就再也读不出。
    4. ADR-0128 §1.4 测得的危害实例为零。 是否仍为零,无法在本会话里对部署实测(组织级读取返回 403)。

    具体问题

    改封工具落地之后,provider 是否要在某个版本起拒绝开启版本 1 密文?

    选项

    做什么 客户可感知的后果
    A. 退役旧派生 后续一个阶段:provider 拒开版本 1;升级流程在仍有版本 1 行时拒绝启动,要求先跑改封;合同段落改写;BREAKING changeset;ADR-0128 的落地注记(Tier H,另卡)。 每个部署升级前必须先跑 os secret rewrap --apply,且「留下」与「读不开」的行要先人工处理。处理不了的那部分密钥(设置、记录上的 secret 字段、数据源凭据)永久读不出。不可回退。
    B. 保留旧派生只读 不做任何代码改动,本卡以 completed 关闭。新写入恒用新派生;旧密文继续可读,运维可随时自行改封。 无可感知变化。未改封的旧密文继续保持较弱的旧绑定;该绑定的危害按 ADR-0128 §1.4 实测为零实例。

    业务含义直译:

    • A: 像银行在某天起不再认旧版存折,储户必须先去柜台换新折,没换的那部分账户就此冻结。
    • B: 新开户一律用新版存折,旧存折照常可用,储户想换随时可换。

    四维分析

    os-decision-facets

    • ① 项目长远合理性: B 不新增任何特例,也不改契约;A 要新增一道升级前置门和一次协议改写。终态句:两年后,平台对所有新写入只用按来源区分的派生;旧版本密文仍可解密,是否禁用旧版本交由运维显式决定。这正是主流 KMS 的建模:AWS KMS 自动轮换后保留旧密钥材料用于解密;HashiCorp Vault transit 默认可解密旧版本,只有运维显式调高 min_decryption_version 才拒绝旧版本。平台强制退役在主流里是例外。
    • ② 实际业务拉动: 今天没有人撞上。ADR-0128 §1.4 实测危害零实例;也没有任何部署或客户提出要退役旧派生。零拉动,默认 defer。
    • ③ 防 AI 犯错: 这里不涉及编写面,AI 写元数据不会经过这条路径;两个选项在这条轴上持平。未知派生恒失败关闭,这一点两者都保持。
    • ④ 创业阶段不扩散: B 不新增任何义务;A 新增一道升级门、一套运维流程和一次不可逆的数据处置,都是永久义务。

    推荐:B。 只看①选 B;②③④ 是否翻转:否(②零拉动、④零新增义务同向,③持平)。回退: A,但须先拿到每个部署的 os secret rewrap 干跑分类计数(byClass 中 superseded 归零,留下与读不开两类均有人工处置结论),再加升级前置门。置信缺口: 各部署(含 cloud)现存版本 1 行的数量,以及留下与读不开两类的规模,本会话测不到;也不知道是否有合规或客户要求平台声明更强的静态绑定。这正是 ADR-0128 §4 自列的触发条件之一。若出现这样的要求,应改走 A。

    裁后执行

    • B: 坐席以 completed 关闭本卡,引用本裁决。合同段落「What it does not cover yet」的事实不变(旧绑定在改封前一直有效),不需要改。ADR-0128 的落地注记(D1–D3 与改封工具已建成)作为 Tier H 文本另行处理。
    • A: 坐席另开第 3 阶段认领。内容:provider 拒开版本 1,并给出带处方的错误;升级前置门在仍有版本 1 行时拒绝启动,点名 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

  8. objectstack-fleet commented on Oct 3, 2026

    @objectstack-fleet
    ContributorAuthor

    Ruling: batch #270 item 3 · letter B · maintainer 「其他issue同意」 2026-10-03T01:52Z

    Director seat, summon #32, session_016tKoy8NJa35Yih1FdzrVmn. Written as objectstack-fleet[bot] through the relay. ⛔ Classes and positions only.

    • How it was ruled. Batch 🔗 Broken links detected in documentation #270 was presented in the live director chat with options A and B from 5962926559. The director recommended B, with fallback A. The maintainer answered 「其他issue同意」.
    • The freshness gate: no comment after 5962926559.

    The ruling

    B: version-1 ciphertexts stay readable, and are never written again.

    • New writes always use the producer-discriminated derivation. An unknown derivation fails closed.
    • Operators re-wrap at will with os secret rewrap. The platform does not force retirement of the old derivation.
    • The contract paragraph "What it does not cover yet" stays true as written: the old binding holds until a ciphertext is re-wrapped.

    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.

    四棱(本裁决新记录)

    • ① 长远:不新增特例、不改契约;与主流 KMS 同形(旧版本可解密,是否禁用由运维显式决定——凭知识,未在本会话实测)。
    • ② 拉动:零,危害实测零实例。
    • ③ 防 AI:不涉及编写面,持平。
    • ④ 不扩散:零新增义务。
    • 只看①选 B;②③④ 是否翻转:否。
    • The fallback A is re-opened only by a per-deployment re-wrap dry-run count together with a compliance or customer requirement. That is the trigger ADR-0128 §4 itself lists.

    Execution


    Generated by Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions