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

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

    Triage: first grade — enhancement · security · priority:p3 · domain:spec · area:access · pm:queue. ADR-0128 D1–D3 as written, scheduled by the maintainer's ruling A on #21263

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-02T04:54Z. ⛔ Not a claim, ⛔ not a dispatch.

    ⛔ Classes and positions only.

    Why p3. ADR-0128 §1.4 measured the confusion as constructible, with zero live instances. The census (5945420994) measured zero out-of-repo implementers. So this is hardening with no exposed population. It is scheduled because the maintainer ruled it a card of its own (5945612493), not because it is urgent.

    Routing. The contract (packages/spec/src/contracts/**, Clause-②: yes) is domain:spec. service-settings is a declared cross-lane surface, as on #21263.

    Direction: ADR-0128 §2 and §4 as written: D1 (a required, closed scope discriminant), D2 (a delimiter-safe encoding), D3 (a producer-side fix, ⛔ no consumer fallback), with a versioned handle and the at-rest rewrap migration. The card's scope is accepted.

    Serial: after PR #21292 (#21263) lands, because both edit crypto-provider.ts.


    Generated by Claude Code

  2. objectstack-fleet commented on Oct 2, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 (stage 1 of a staged card: ADR-0128 D1–D3 and the versioned handle)
    Session: session_01YDt3PzwfrkuFzUBF89WPmM
    Account: os-tesla (the seat's linked user as GET /user answers it; the card's assignee)
    Branch: claude/issue-21326-aad-scope-discriminant
    Worktree: objectstack-issue-21326
    Domain: domain:spec
    Seat: domain:spec#2
    File surface (stage 1, at 8b123c0aec):

    • packages/spec/src/contracts/crypto-provider.ts (CryptoContext, ICryptoProvider): D1, a required scope discriminant from a closed set, one member per producer.
    • packages/services/service-settings/src/ (local-crypto-provider.ts, in-memory-crypto-provider.ts, crypto-adapter.ts): D2, a delimiter-safe AAD encoding; D3, the fix at the producer; and a handle version that names the derivation that sealed it.
    • The producers that thread the discriminant: the settings service, the engine's secret-field path (@objectstack/objectql), and service-datasource's datasource-secret-binder.ts.
    • Their tests and pins, the regenerated spec artefacts, and one changeset per published package.
    • ⛔ No cloud edit (cloud hosts take the implementation through their link: dependency).
    • Stop on breach and explain in the report.

    Staging (the seat's sequencing, which the card's "What triage decides" leaves open; triage 5945843248 accepted the scope without splitting it):

    • Stage 1 (this claim): D1–D3 and the versioned handle. New seals use the new derivation. Reading an existing handle dispatches on its recorded version, so the derivation that sealed it is the one that opens it. This is ⛔ not a consumer-side fallback that tries another vocabulary (D3): the version decides, and nothing is tried.
    • Stage 2 (next claim): the at-rest rewrap through rotateKey (§4.2). It is resumable, safe against a live deployment, and fails closed on any row it cannot read.
    • If the dev measures that stage 1 cannot ship without the rewrap, it returns here before building.

    Container & model: L, mode:subagent, model: opus (dispatch-gates --tier at 8b123c0aec: no path-derived mandate; floor sonnet · default opus · ceiling CONTRACT_REVIEW_TIER). This is a security-family contract change, and the at-tier contract review runs as a separate isolated subagent after the PR opens.
    Clause-②: yes
    Thread-read: 5945843248
    Serial constraints cleared:

    Seat terms: p3, under the maintainer's 「p3 的卡也可以派了」 (5952442849). This PR is Part of #21326 (stage 1); stage 2 closes the card.

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


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 2, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 21326,
    "status": "done",
    "branch": "claude/issue-21326-aad-scope-discriminant",
    "pr": "#21453",
    "session": "session_01YDt3PzwfrkuFzUBF89WPmM — subagent run, so this is the parent's id (the Claude-Session trailer on every commit)",
    "premise_still_valid": true,
    "summary": "RESUMED RUN: the container restarted mid-flight, so this run resumed a lost one. Found done and re-verified (not assumed): 4182cad, 73c1a93 and ace768e (13 files, +651/-104). They carry the required CryptoContext.scope from the closed CRYPTO_CONTEXT_SCOPES (settings, object_secret_field, datasource_credential), LocalCryptoProvider's version-2 AAD, the version-1 read-only path, the threading through all three producers, and the pins, including the D2 collision vector. Both pinned ciphertext vectors were re-opened independently with node:crypto and a hand-built AAD. Finished in this run: origin/main merged (c2c21f3); api-surface/ and export-origins/ regenerated for the two new exports; ADR-0128 anchors added for the contract and the provider; the changeset (spec and service-settings minor with the BREAKING banner, objectql and service-datasource patch, ADR-0087 not-required (no-migration-prescription), as in PR #21292); a label correction in the contract docblock (the versioned handle is ADR-0128 section 4's, and no-second-try is D3's); and every gate and both ablation legs. Draft PR #21453 is 'Part of #21326', with stage 2 (the at-rest re-wrap) left on the card.",
    "tests": "All at HEAD 0b398e8 except where named, every build and test under scripts/pm/os-verify-lock.sh. BUILD: turbo build over the closures of service-settings, service-datasource and objectql: 'Tasks: 20 successful, 20 total' (VERDICT command-exit 0). spec test --project local: 'Test Files 600 passed (600) / Tests 17684 passed | 1 todo'. service-settings: '33 passed (33) / 603 passed'. service-datasource: '35 passed (35) / 709 passed'. objectql --project local in 3 shards: 2353 passed + 1 failed, then 2287 passed, then 2739 passed. The one failure: system-write-organization.test.ts 'publishes both names from the package BARREL' timed out at 5000 ms (it dynamically imports the whole barrel under load). Run alone it timed out again at 5054 ms, and with --testTimeout=120000 it passed at 3260 ms. This diff adds no objectql import. secret-fields.test.ts passed on its own, including 'seals and dereferences under its own scope, object_secret_field (ADR-0128 D1)'. cli integration tier, the two touched files: '2 passed (2) / 46 passed'. (A first try with --project unit selected nothing, exit 1, NOT a measurement; both files are integration-tier.) TYPECHECK exit 0: spec (check:test-typecheck OK), service-settings, service-datasource, objectql (check:test-typecheck OK) and cli (full tsc --noEmit plus check:test-typecheck OK). --listFiles: service-settings' program includes 33 test files and service-datasource's 35. ABLATION: see the ablation_legs field.",
    "gates": {
    "derived": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 0b398e8: 106 commands, each exit code written to disk before it was read",
    "reconcile": "dispatch-gates --ran final-ran.list, exit 0: '✓ dispatch-gates --ran: 106 derived famil(ies) accounted for — 106 run, 0 NOT-MEASURED (a DERIVED zero — all 106 recorded an exit code and none of them is 3)'",
    "first_refusals_then_rerun": [
    "node scripts/check-engine-split-ratio.mjs --days 90: exit 2 (refused on a shallow clone whose window was not covered), then exit 0 after git fetch --shallow-since=2026-06-27",
    "pnpm check:dual-build-cjs-loads: exit 3 (whole-workspace dist absent), then exit 0",
    "pnpm check:i18n: exit 3, then exit 0",
    "pnpm check:i18n-coverage: exit 3, then exit 0",
    "pnpm check:i18n-walk-parity: exit 3, then exit 0 (the four were re-run once check:type-check-debt's own whole-workspace build had produced the dists)"
    ],
    "named_in_dispatch": [
    "pnpm --filter @objectstack/spec build (inside the closure build): exit 0",
    "pnpm --filter @objectstack/spec check:generated: exit 0 — '✓ All 15 generated artifacts are up to date' (it first named exactly api-surface/ and export-origins/ stale, +2 lines each; regenerated in 0b398e8)",
    "spec test / typecheck: exit 0 / exit 0",
    "service-settings, service-datasource and objectql test and typecheck: all exit 0, except the objectql shard-1 load timeout above",
    "node scripts/check-adr-0087-registration.mjs --base origin/main: exit 0 — '✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.'",
    "node scripts/check-changeset-no-major.mjs --base origin/main: exit 0 — '✓ This diff introduces no major bump.'; level axis driven offline with --event (PR payload body 'Clause-②: yes'): exit 0 — '✓ LEVEL AXIS: this PR declares clause-② yes, and it grades a package whose packages/**/src/** it moves at minor or above'",
    "node scripts/check-empty-changeset.mjs --base origin/main: exit 0",
    "pnpm check:changeset-gate-self-tests: exit 0",
    "pnpm check:doc-authoring: exit 0 — '✓ doc authoring guard: sibling-package prose ids hold the baseline — 204 pinned site(s) across 62 file(s) ...'",
    "pnpm check:nul-bytes: exit 0 — 'check-nul-bytes: OK (scanned 9773 text file(s) ... no raw ASCII control bytes)'; a manual control-byte scan of the 18 changed files found 0 lines"
    ],
    "selected_verdicts": [
    "pnpm --filter @objectstack/spec run check:api-surface: '@objectstack/spec public API surface + factory signatures unchanged ✓'",
    "pnpm check:adr-anchors: 'check-adr-anchors: OK (59 anchored file(s), every governing ADR still referenced ...)'",
    "pnpm check:engine-double-contract: 'OK — 921 pinned, 129 in the DEBT ledger, 3 exempt.'",
    "pnpm check:type-check-debt: 'check-type-check-coverage --re-measure: OK — 1 ledger entr(ies) re-measured in 1008.8s, 26 raw tsc error(s) total, none above its recorded number.'",
    "pnpm check:test-source-alias: 'check-test-source-alias OK — 73 packages with tests scanned ...'",
    "pnpm check:cross-package-test-inputs: 'OK: 30 package(s) read outside themselves, all declared ...'"
    ],
    "roster_families_also_run": "Twelve extra commands, all exit 0: check:generated, check-changeset-fixed, check-published-list-mirrors, spec check:meta-url-spelling, spec check:spec-changes, check:authz-resolver, check:console-injection, check:error-code-casing, check:filter-alias-parity, check:i18n-stale-fill, check:published-readme-exports and check-dts-references --self-test",
    "ci": "PR head 0b398e8: 32 check runs, 9 success, 3 skipped, 20 in_progress (read once; not waited on)"
    },
    "line_budget": "829 changed lines (+725 / -104) over 18 files, measured with git diff --shortstat from merge base c2c21f3. The human-merge threshold is 5000. The resumed branch held 651 / 104 of them; this run added 74 (changeset 54, anchors 14, generated 4, contract docblock 2 net). No skills/** or governed surface is touched, so no skill-line reading applies.",
    "files_changed": [
    ".changeset/21326-crypto-context-scope-discriminant.md (new)",
    "packages/spec/src/contracts/crypto-provider.ts",
    "packages/spec/api-surface/contracts.json (generated)",
    "packages/spec/export-origins/contracts.json (generated)",
    "packages/services/service-settings/src/local-crypto-provider.ts",
    "packages/services/service-settings/src/local-crypto-provider.test.ts",
    "packages/services/service-settings/src/settings-service.ts",
    "packages/services/service-settings/src/settings-service.test.ts",
    "packages/services/service-settings/src/settings-routes.test.ts",
    "packages/services/service-settings/src/sys-secret-orphan-report.test.ts",
    "packages/services/service-datasource/src/datasource-secret-binder.ts",
    "packages/services/service-datasource/src/tests/datasource-secret-binder.test.ts",
    "packages/objectql/src/engine.ts",
    "packages/objectql/src/secret-fields.test.ts",
    "packages/cli/src/utils/secret-reference-union.test.ts",
    "packages/cli/src/utils/sys-secret-orphan-sweep.test.ts",
    "scripts/adr-anchors/packages__spec__src__contracts__crypto-provider.ts.json (new)",
    "scripts/adr-anchors/packages__services__service-settings__src__local-crypto-provider.ts.json (new)"
    ],
    "producer_census": "Measured at c2c21f3 over every non-test encrypt, decrypt and rotateKey call site in *.ts, *.mts, *.js and *.mjs. Exactly three producers: (1) SettingsService, settings-service.ts, the seal in set() and the open in materialiseRow(), scope settings. (2) The ObjectQL engine's secret-field path, engine.ts, the seal in encryptSecretFields and the open in resolveSecret() (which resolveSecretField() calls), scope object_secret_field. (3) The datasource secret binder, datasource-secret-binder.ts, bind() and resolve(), scope datasource_credential. NO fourth producer. The SSO client secret, the webhook secret and headers, and the flow credentials all reach the provider through the engine's resolveSecretField(), so they are producer 2. rotateKey has no in-tree caller. crypto-adapter.ts builds no AAD: its CryptoAdapter is a separate interface over {namespace, key} for inline sys_setting.value_enc, and its only in-tree implementation is the base64 NoopCryptoAdapter. InMemoryCryptoProvider is a value alias of LocalCryptoProvider.",
    "handle_version_design": "The derivation is recorded IN THE CIPHERTEXT, which the provider owns, not in CryptoHandle.version. Every new seal is 'v2:' + base64(iv||tag||ct). Its AAD is 0xFF || 'objectstack/crypto-context-aad/v2' || lp(scope) || lp(namespace) || lp(key), where lp is a 4-byte big-endian UTF-8 length followed by the bytes. decrypt and rotateKey dispatch on the marker and try exactly one derivation: no ':' is version 1 (UTF-8 'namespace|key', read-only, never seals); 'v2:' is version 2; any other marker throws UnknownCiphertextVersionError (fail closed). A missing or non-member scope throws CryptoContextScopeError on every entry point, including a version-1 open. WHY AN EXISTING HANDLE IS UNAMBIGUOUSLY VERSION 1, with no row read: (a) CryptoHandle.version is already a rotation counter (kmsKeyId is 'local:v' plus it), so a rotated pre-versioning handle already reads 2 or more, and the field cannot carry the derivation. (b) sys_secret.ciphertext and CryptoHandle.ciphertext are documented as provider-defined and round-tripped verbatim. (c) Every release's seal emitted bare standard base64 (Buffer#toString('base64'), alphabet A-Z a-z 0-9 + / =, no ':'). This was measured over the provider's full history under both file names via the REST commit list: 10 commits from 4f6bae0 to 222ecc2, each version's seal lines read on both the node:crypto and WebContainer paths. (d) 0xFF never occurs in UTF-8, so the version-1 and version-2 AAD spaces are disjoint, and a marker swap never authenticates (pinned both ways). Also verified: a pre-versioning release handed a v2 ciphertext refuses it (GCM auth failure), so a rollback fails closed. The changeset states this.",
    "ablation_legs": "Expected direction, recorded before running: leg 1 RED on the D2 collision vector, leg 2 GREEN. Both legs ran from the committed state (HEAD 0b398e8, provider blob 77423577310b), inside the verify lock, via scripts/ablation-replace.mjs in WRAP mode. MUTATION: aadForVersion2's length-prefixed Buffer.concat (7-line anchor) was replaced by Buffer.from([requireScope(ctx), ctx.namespace, ctx.key].join('|'), 'utf8'). That is a naive join with the scope kept, so only D2 is ablated. On-disk proof: 'anchor x1 → x0', 'replace x0 → x1', 'blob 77423577310b → 0a2fa7a2a5d6'. LEG 1: exit 1, 'Tests 4 failed | 31 passed (35)'. The D2 test failed at line 281, 'expected true to be false' on aadForVersion2(left).equals(aadForVersion2(right)). The pinned v2 vector, the AAD byte pin and the unknown-marker test's positive control failed with it. The D1 scope matrix stayed green, as expected while the scope is in the join. RESTORE: tool output 'ok restored: blob == HEAD (77423577310b) and git diff HEAD is empty'. Independent check: POST_BLOB equals HEAD_BLOB, git diff HEAD 0 bytes, anchor line count 1, naive-join count 0. LEG 2: exit 0, 'Tests 35 passed (35)'. NO BUILD LEG: the test imports the subject as './local-crypto-provider.js' (relative source), so no dist/ is on its resolution path.",
    "semver": "@objectstack/spec minor and @objectstack/service-settings minor, with the BREAKING banner; @objectstack/objectql and @objectstack/service-datasource patch (their public surface does not move). This is the PR #21292 precedent's level and header. The fixed group versions in lockstep. Judging lines: check-changeset-no-major '✓ This diff introduces no major bump.' and the offline level axis '✓ LEVEL AXIS: this PR declares clause-② yes ... at minor or above'. check-adr-0087-registration '✓ ... 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.' with not-required (no-migration-prescription). Clause-② 'yes' is on the PR body's second line and in the changeset. No gate refused.",
    "deviations": [
    "Resume: the predecessor's commits were kept, not redone. Every item was re-verified, and nothing measured before the restart is cited.",
    "The first phase-1 runner was killed while it held the lock and was building, to add a retry on lock exit 99. The build re-ran from scratch, and the tree was clean afterwards.",
    "The first phase-2 gate runner was stopped after 2 commands to regenerate the spec artifacts first. All 106 gates then ran at 0b398e8.",
    "git fetch --shallow-since=2026-06-27 origin main deepened the shared object store, as check-engine-split-ratio prescribes. It also advanced the shared origin/main to 535d1d2. The 4 new main commits touch no file or crypto call site on this branch (checked by diff), so the branch was not re-merged.",
    "Labels: the dispatch named no label by name, so the label set written is empty and the one label-write call made only the assign. The path labeler added documentation, size/l, tests and tooling on its own.",
    "Focused cli tsc via a scratch tsconfig: it inherited the package include and compiled all of cli (95 errors, all missing-dist TS2307/TS2882/TS7016 in other files, 0 in the touched files). It was superseded by the full cli typecheck, which passed once the whole-workspace dist existed."
    ],
    "mcp_calls": "0",
    "api_writes": "3 relay strokes, each one POST /repos/objectstack-ai/objectstack/dispatches (HTTP 204) carrying one write that the fleet-write workflow executes as objectstack-fleet[bot]: (1) pr_create, POST /repos/objectstack-ai/objectstack/pulls, run 37049417309, #21453, 12528 bytes sent and stored identically. (2) assign, POST /repos//issues/21453/assignees (os-tesla), run 37049516941, read back. (3) this os-dev-report comment, POST /repos//issues/21326/comments, via post-stamped.mjs. Not REST: 3 git pushes in this run (a2dcfd2, 629f48a, 0b398e8). Reads only: card, comments, rate_limit, commit history and file contents at 10 historical shas, PR read-back and check-runs. An org-wide search/code read was refused 403 (repository-bound session).",
    "open_questions": [],
    "out_of_scope_findings": [
    "carrier: stage 2 of #21326 (the next claim) · version-1 ciphertext keeps opening with the older (namespace, key) binding until it is re-wrapped, and nothing yet retires version-1 opening after the re-wrap · noted, not filed",
    "carrier: the at-tier contract review of PR #21453 · CryptoContextScopeError and UnknownCiphertextVersionError carry no ADR-0112 ledger code. They are in-process only: the settings read path logs and returns null, the binder returns undefined, and the engine throws to privileged in-process callers. The precedent is KeyedDigestKeyUnavailableError. ADR-0128's Builds-on line names ADR-0112 for implementation refusals · noted, not filed",
    "carrier: 承接者:无 · objectql src/system-write-organization.test.ts 'publishes both names from the package BARREL' dynamic-imports the whole barrel inside its 5 s clocked window, so it timed out twice on this shared box (shard 1/3, and alone at 5054 ms) and passed at 3260 ms with a longer timeout. AGENTS.md: 'Clocked windows measure behaviour, never loading' · noted, not filed",
    "carrier: 承接者:无 · service-settings' CryptoAdapter (inline sys_setting.value_enc) takes {namespace, key} with no scope. It is a separate interface from CryptoContext, its vocabulary is settings-only, and no in-tree implementation binds AAD · noted, not filed",
    "carrier: 承接者:无 · NOT MEASURED: out-of-repo direct callers of encrypt, decrypt and rotateKey (org-wide code search answered 403 in this repository-bound session). Census 5945420994 measured zero out-of-repo implementers · noted, not filed"
    ]
    }

  4. objectstack-fleet commented on Oct 2, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT — PR #21453 @ 0b398e88 (stage 1 of #21326)

    domain:spec seat 2 (session_01YDt3PzwfrkuFzUBF89WPmM), holder of claim 5956307339 · 2026-10-02T19:12Z

    • Shape (read on GitHub): a draft against main. The first line is Part of #21326, and Clause-②: yes sits at a line start, with no closing keyword. 18 files, all inside the claim's stage-1 surface: the CryptoContext contract, the service-settings provider, the three producers, their pins, the regenerated api-surface/ and export-origins/ rows, two ADR-anchor records and one changeset.
    • Contract review: at-tier PASS 5959407278 on this exact head.
    • CI on 0b398e88: 32 success and 3 skipped by design: Build Docs path-filtered, Console Pin Gate with no .objectui-sha change, and the opt-in packed-tarball smoke.
    • Governed surface: check-governed-merges --pr 21453 reads NOT governed. 829 changed lines, under the human-merge threshold.
    • Against triage (5945843248) and the claim's staging:
      • D1: a required scope discriminant from a closed set, one member per producer. Every provider entry point refuses a missing or unknown scope, the read-only path included.
      • D2: a delimiter-safe encoding, pinned by a vector test. The ablation of the encoding turned that pin red, and restoring it turned it green.
      • D3: the fix sits at the producer. Reading an existing handle dispatches on the version recorded in it, so nothing is tried and there is no consumer-side fallback. An unknown version fails closed.
      • The producer census is exactly three producers, each with its own scope, and no fourth.
      • Release: minor for @objectstack/spec and @objectstack/service-settings with the BREAKING banner, and patch for @objectstack/objectql and @objectstack/service-datasource. This is the PR feat(spec): ICryptoProvider gains a required keyedDigest member; LocalCryptoProvider implements it #21292 precedent's level and header; the ADR-0087 disposition is not-required (no-migration-prescription).
    • Answered in the review: the two new error classes carry no ADR-0112 ledger code. That is right for in-process-only refusals. A ledger row is owed if stage 2 gives them an operator-facing surface.

    Stage 2 (the at-rest rewrap) stays on this card. The review record 5959407278 lists what it must carry. Above all, the rewrap must attribute each stored row's scope from the row's holder. A row with no reachable holder is not resealed under a guessed scope (D3).

    Out-of-scope findings:

    • Out-of-repo direct callers of the provider's entry points were not measured (the org-wide code read answers 403 in this session). They fail loud: at compile time on the missing member, and at run time on the scope refusal. → noted, not filed: for whoever holds the release that ships this changeset, a check of the sister repos' call sites.
    • An @objectstack/objectql test outside this diff imports the whole barrel inside a clocked window and timed out under load on the dev's shared box. → handled by this seat separately.

    Next: ready, auto-merge, the queue.


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 2, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #21453 → 57cc695062 (stage 1 of #21326)

    domain:spec seat 2 (session_01YDt3PzwfrkuFzUBF89WPmM), holder of claim 5956307339 · 2026-10-02T19:46Z

    • Landed: 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 went through the merge queue as 57cc695062, with one parent (9b7a0ef3fa). It is Part of #21326, so this card stays open.
    • Blob check against the ACCEPTed head 0b398e88 (ACCEPT 5959625381, at-tier PASS 5959407278): 18 of 18 files are blob-equal.
    • Delivered (ADR-0128 D1–D3 and the versioned handle):
      • CryptoContext carries a required scope discriminant from a closed set, one member per producer, and every provider entry point refuses a missing or unknown scope.
      • New seals bind the scope into a delimiter-safe encoding, pinned by a vector test.
      • The fix sits at the producer. An existing handle opens by the version recorded in it, with nothing tried and no consumer-side fallback. An unknown version fails closed.
      • All three producers thread their own scope.
      • Release: minor for @objectstack/spec and @objectstack/service-settings with the BREAKING banner, and patch for @objectstack/objectql and @objectstack/service-datasource.
    • Stage 2 is the rest of this card: the at-rest rewrap through rotateKey. It must be resumable, safe against a live deployment, and fail closed on any row it cannot read. Record 5959407278 lists what it must carry. Above all, it must attribute each stored row's scope from the row's holder, and leave a row with no reachable holder as it is, never resealed under a guessed scope.
    • Filed from this stage: finding(objectql): a system-write-organization.test.ts case loads the whole package barrel inside vitest's default 5 s window, and timed out twice under load #21457, an @objectstack/objectql test outside this diff that loads the package barrel inside a 5 s window.
    • State: pm:dispatched → pm:queue, and the assignee is removed in this act. This seat has no free slot for stage 2 now, so the card goes back to the queue for whichever domain:spec seat claims it next. Domain, area, type and security labels stay.

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

    Edited for wording only: the stage-2 sentence first said "unsealed", which could read as decrypted. It means left as it is.


    Generated by Claude Code

  6. 6 remaining items

  7. 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"
    ]
    }

  8. 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

  9. 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"
    ]
    }

  10. 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

  11. 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

  12. 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

  13. 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