Skip to content

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

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-21326-at-rest-rewrap
Oct 2, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-21326-at-rest-rewrap

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Part of #21326
Clause-②: yes (widening)

Stage 2 of the staged card: the at-rest re-wrap of version-1 sys_secret ciphertext, ADR-0128 §4.2, through rotateKey. Retiring version-1 opening is a later stage and is not here; #21326 stays open for it.

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

What lands

  • os secret rewrap, a new subcommand beside os secret orphans, with the same boot, connection, guards and output conventions. It is a dry run by default (a read-only boot that writes nothing), and --apply writes. The other flags are --declared-datasources / --no-declared-datasources, -y, --yes, --json and --database-url. Nothing on any boot or upgrade path invokes it, and it has no HTTP surface, so no refusal answers a request and no ADR-0112 ledger row is owed (A8).
  • ciphertextDerivationStatus on @objectstack/service-settings: LocalCryptoProvider's own reading of which AAD derivation sealed a stored ciphertext (current, superseded or unknown). It is read off the marker without opening anything, and it is the same readCiphertext that decrypt dispatches on. The re-wrap classifies rows with it, so the CLI never restates the marker grammar.
  • packages/cli/src/utils/sys-secret-rewrap.ts holds the planner and executor (pure planning, injected ports), with its pins, the command's guards test and its concrete-driver contract test.
  • The CLI reference page content/docs/deployment/cli.mdx gains os secret rewrap next to os secret orphans. The page is hand-written, not generated.
  • The new command is registered in both bootSchemaStack family ledgers: schema-migrate.one-shot-family.integration.test.ts and json-stdout-purity.e2e.test.ts.
  • One changeset (cli and service-settings at minor) and one ADR-0128 anchor for the planner.

A1: where version-1 ciphertext is stored (census at 1fd56645af)

  • sys_secret.ciphertext is the only store of ciphertext the provider seals. All three producers write it: SettingsService.set(), the engine's secret-field path and the datasource binder's bind(). There are no other non-test encrypt call sites. Version 1 means no : marker.
  • sys_setting.value_enc inline values are not provider-sealed. They are sealed by the separate CryptoAdapter interface: no CryptoContext, no ICryptoProvider AAD, and its only in-tree implementation is the base64 NoopCryptoAdapter. They are not version-1 ciphertext of this provider, so rotateKey cannot reach them and they are out of this class. ⛔ No second sealing path was built.
  • sys_two_factor.backup_codes is not provider-sealed either. It is sealed by better-auth's own symmetric encryption under the auth secret.

A2: per-row scope attribution

rotateKey(handle, ctx) seals under the caller's scope, and a version-1 ciphertext binds no scope, so opening one proves nothing about its producer. The scope therefore comes from the holder, through the classification the orphan sweep already uses: the cross-producer reference union. Each union reference already carries its holder family, so the classifier needed no extension. The planner only groups the union's references by handle. ⛔ No second holder walk. SCOPE_OF_HOLDER_FAMILY is a Record over the closed family set (settings → settings, object-field → object_secret_field, datasource → datasource_credential), so a fourth family stops compiling until it has a scope.

The order of the decision:

  1. A row whose holders belong to different producers is left (left_conflicting_scope).
  2. While the union is incomplete, every version-1 row is left (left_union_incomplete): a family that was not read may hold it too. --apply then refuses and names the family.
  3. A row with no holder is left (left_orphan).
  4. Otherwise the row is attempted under its one scope. Several holders of one scope are one attribution, and the row is re-wrapped once.

A3: the properties, and the pin for each

Property How it is met Pin
Resumable and idempotent All progress lives in the rows. A row reading current is skipped as done, and there is no run log. A run is killed at its second write. A re-run finishes the rest, and a third run writes nothing. The real SQLite boot shows a second --apply is all done with the table unchanged.
Safe against a live deployment Each row is ONE updateMany keyed on id AND the exact ciphertext the run read. All five drivers serve that as a single filtered statement and answer the changed count (measured in source: SQL and Turso UPDATE … WHERE, Mongo updateMany, memory with no await between filter and write). A count of 0 is write_conflict, and the row is not overwritten. A driver without updateMany is refused before any row is opened. A producer write lands between the read and the write: write_conflict, the producer's value is kept, and the other rows land. The real SQL driver: a stale ciphertext changes nothing (0), and the read one changes the row (1).
Fails closed A row that does not open, or that has an unknown marker, or whose stored fields do not form a handle, is refused and not written. The run finishes the rest and exits 1. Every write is one statement. A row sealed under another key: refused_unreadable, its bytes unchanged, and the other rows re-wrapped. An unknown marker is never opened.
Verify before write The re-seal must keep the id, read current, carry a usable version, and open under the SAME context to the SAME plaintext, before the write. A re-seal that opens to a different value, and one that does not open: both refused_verify_failed, never written. Positive control: the honest provider re-wraps the same row.
Dry run It is the default and boots read-only. Every attributed row is opened, re-sealed and verified in memory, so its counts are the ones --apply produces. Nothing is written. The store is unchanged, and updateMany is never called. --apply over the same store lands exactly the dry run's counts. The one-shot family pin shows the dry run leaves the database byte-identical, boot included.

After --apply, each re-wrapped row is opened through its producer's own read path: the engine's resolveSecret, the binder's resolve, and the settings context. No other producer's context opens it.

The key. The run resolves LocalCryptoProvider before the boot, from a key that already exists, in the strict posture with the auto-key opt-in withheld. It hands that provider to the settings service the boot composes, so no provider in the run mints a key. With no key, it hands that service one that refuses every call, and the run refuses before opening a row. Pinned with the real boot in a development posture with no key: crypto_key_unavailable, and no key file appears.

A5: output shape

Classes and counts only. ⛔ No plaintext, no ciphertext, no key material, and no row id. The --json document is { mode, report }, where report holds:

  • mode and keySource (a source name, never a value);
  • families: per holder family, status, referenceCount, and reason on a gap;
  • refusal;
  • counts: total, rewrap, done, left, refused, notWritten;
  • byClass: one count for each of rewrap, done, left_orphan, left_conflicting_scope, left_union_incomplete, refused_unreadable, refused_unknown_derivation, refused_verify_failed, write_conflict, write_failed;
  • rewrapByScope;
  • notes.

Refusals are one JSON document with an error key: union_incomplete, driver_cannot_compare_and_set, crypto_key_unavailable, confirmation_required, declared_datasources_unreadable, boot_failed, no_engine, no_sys_secret_driver or scan_failed. A pin asserts that the report holds none of the plaintexts, ciphertexts, row ids or holder coordinates of its fixture.

A7: out of this stage, and untouched

These are untouched: version-1 opening and the contract paragraph about it, packages/spec/src/contracts/crypto-provider.ts (the re-wrap needed no contract change), packages/objectql/src/engine.ts, docs/adr/**, and anything in cloud.

Gates and tests (local, at 6a7c27c2f2)

Derived gates. node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 107 commands. Each exit code was written to disk before it was read, and all 107 exited 0. --ran reconciles: "✓ 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)."

The gates named in the dispatch:

  • check-changeset-no-major.mjs --base origin/main: "✓ This diff introduces no major bump." The level axis was driven offline, with this body as the event payload: "✓ LEVEL AXIS: this PR declares clause-② yes (widening), and no package whose packages/**/src/** it moves is graded patch." (re-run at 219457c029, after the review's fix round)
  • check-adr-0087-registration.mjs --base origin/main: "✓ check-adr-0087-registration: this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen)."
  • check-empty-changeset.mjs --base origin/main: "✓ 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: "✓ doc authoring guard: sibling-package prose ids hold the baseline — 204 pinned site(s) across 62 file(s) …"
  • pnpm check:nul-bytes: "check-nul-bytes: OK (scanned 9800 text file(s) … no raw ASCII control bytes)."

Other gate readings:

  • check:cli-command-ids: 65 command modules, all of them oclif commands.
  • check:test-source-alias: OK, the unaliased set unchanged.
  • check:engine-double-contract: "OK — 921 pinned".
  • check:adr-anchors: OK, 60 anchored files.
  • check:type-check-debt: OK.
  • check:cross-package-test-inputs: OK.

Tests:

  • @objectstack/service-settings: test passed 33 files / 605 tests, and typecheck exited 0.
  • @objectstack/cli typecheck (tsc --noEmit plus check:test-typecheck): exit 0.
  • @objectstack/cli unit tier: 246 of 248 files passed on the first run. The other two, the published-subpath-*.pin files, refused on a missing cli dist/ (a prerequisite, not a verdict). Once the cli was built, both passed (29 / 29).
  • @objectstack/cli integration tier, run on the touched files and the secret family (sys-secret-rewrap, rewrap.guards, rewrap.driver-contract, orphans.guards, orphans.driver-contract, sys-secret-orphan-sweep, secret-reference-union): 7 files / 87 tests.
  • The one-shot family pin, filtered to the new member and the family table: the dry run leaves the database byte-identical, no missing SQLite file is created, --apply runs no seed write, and the family table holds.
  • json-stdout-purity.e2e (nightly tier) was not run here. Its three assertions for the new member were measured by a direct invocation at 6a7c27c2f2: stdout is one JSON document (the scan_failed refusal), no logger record is on stdout, and the three boot diagnostics are on stderr. In that run no database file was created, and the run's key home stayed empty.

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 this diff cannot move the verdict on any untouched file.

Ablations

Both legs ran from the committed state 6a7c27c2f2, through scripts/ablation-replace.mjs in WRAP mode, inside the verify lock. Each was restored, and the restore was proven by blob. The expected direction, recorded before running, was RED for both legs.

  1. The scope-attribution refusal removed. The no-holder branch of attributeRewrapScope returned a guessed settings scope instead of left_orphan.
    • On disk: anchor x1 → x0, replacement x0 → x1, blob 75d607ac5dc0 → 3c509ef4edb1.
    • Result: exit 1, "6 failed | 8 passed (14)". The orphan pin failed at its left_orphan count ("expected +0 to be 1"). The count pins that include the orphan failed with it: the full apply, resumable, live-safe, fail-closed and report pins.
    • Restore: blob == HEAD (75d607ac5dc0), and git diff HEAD is empty.
  2. Verify-before-write removed. The resealHolds check (a 3-line anchor) was deleted.
    • On disk: blob 75d607ac5dc0 → 4fd879b7d1a1.
    • Result: exit 1, "1 failed | 13 passed (14)". Exactly the verify pin failed, at refused_verify_failed ("expected +0 to be 1").
    • Restore: proven the same way.

There is 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 above is green at 6a7c27c2f2.

Acceptance notes


Generated by Claude Code

claude added 8 commits October 2, 2026 20:13
…der's own reading of a stored ciphertext's AAD derivation (ADR-0128 §4.2)

The at-rest re-wrap must tell a version-1 row from one already sealed under
the current derivation without opening it, and must not restate the marker
grammar that decrypt dispatches on. The reading is the provider's own
readCiphertext, published from the package root.

Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM
Co-authored-by: Claude <noreply@anthropic.com>
…ret ciphertext (ADR-0128 §4.2)

Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM
Co-authored-by: Claude <noreply@anthropic.com>
…fail-closed and verify-before-write (ADR-0128 §4.2)

Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM
Co-authored-by: Claude <noreply@anthropic.com>
…ined in the re-wrap pins

Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM
Co-authored-by: Claude <noreply@anthropic.com>
… its key before the boot

The settings service registers sys_setting, the settings family's holder, so
the re-wrap boots it as os secret orphans does. That service's own provider may
mint a key in a development posture, so the re-wrap resolves its provider first,
in the strict posture that never mints.

Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM
Co-authored-by: Claude <noreply@anthropic.com>
…wn provider, so no provider in the run mints a key; join the bootSchemaStack families

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

github-actions Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/service-settings, touching 75 documentable anchor(s).

28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json aa4632235ba571ef800b95e6bc18d00a30aa1d57.

⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

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

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

Which tree this was computed on

This run read content/docs from 78cceb50b14774215fa5a62bec35c4079566bd4b — the merge of head 219457c0298cbd7a8a88a42839df952da7554b67 into base aa4632235ba571ef800b95e6bc18d00a30aa1d57, which is what actions/checkout gives a pull_request run. Not the PR head.

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

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 78cceb50b14774215fa5a62bec35c4079566bd4b && git checkout 78cceb50b14774215fa5a62bec35c4079566bd4b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin aa4632235ba571ef800b95e6bc18d00a30aa1d57 219457c0298cbd7a8a88a42839df952da7554b67 && git checkout -B drift-repro aa4632235ba571ef800b95e6bc18d00a30aa1d57 && git merge --no-ff 219457c0298cbd7a8a88a42839df952da7554b67

node scripts/docs-audit/affected-docs.mjs --json aa4632235ba571ef800b95e6bc18d00a30aa1d57

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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6a7c27c2f21c45433ea5261d705acd3c2763b127
Local-runs: none

Scope read: card #21326 (body and every comment: triage 5945843248, the stage-1 claim 5956307339, dev report 5959101207, ACCEPT 5959625381, landing 5960164329, this stage's claim 5960437209 and dev report 5961923912), stage 1's review record 5959407278 on PR #21453 (its "what stage 2 must still carry" list), ADR-0128 §4 at the head, PR #21469 (body, the 13-file list, the net diff against main, its one thread comment, the advisory docs-drift check), the check-runs on the head, and the seams the diff builds on, read at the head through git show: the reference union, os secret orphans, the provider, the settings service and its plugin, bootSchemaStack, the sys_secret object, the five drivers' updateMany, and the repository's own definition of the Clause-② line (AGENTS.md, scripts/check-changeset-no-major.mjs, scripts/pm/clause2-line.mjs, the pm-dispatch references and the changesets at the head). Nothing built, run or re-run. ⛔ Security family: classes and positions only.

① Derived judgments

ADR-0128 §4.2, resumable: right. All state is in the rows. planSysSecretRewrap classifies each row off its own ciphertext marker through the injected derivationOf; a row reading current is done and skipped, and there is no run log, cursor or side file. A run stopped after any write leaves every written row current and every unwritten row superseded, so the re-run re-reads and finishes the rest; pinned with a writer that dies at its second write, then a second run that re-wraps exactly the stopped row, then a third that writes nothing, and with the real SQLite boot (a second --apply all done, table unchanged).

Safe against a live deployment: right, and real on every driver the command can reach. The only write is one updateMany('sys_secret', { where: { id, ciphertext: the ciphertext this run read } }, patch); changed === 0 is write_conflict and the row is not overwritten. Read at the head: SqlDriver.updateMany is one builder UPDATE … WHERE per target (one target: sys_secret is not rotation-managed) returning the count; TursoDriver.updateMany delegates to super locally and to the remote transport's one UPDATE … SET … WHERE returning rowsAffected; MongoDBDriver.updateMany is one collection.updateMany(filter, $set) returning modifiedCount; InMemoryDriver.updateMany filters and writes with no await between them and returns the count. SqliteWasmDriver extends SqlDriver. No driver selects then updates. asCompareAndSetWriter checks for the function rather than casting, and the driver_cannot_compare_and_set refusal sits before executeSysSecretRewrap, so under --apply a driver without it is refused before any row is opened; pinned (the guards test, and the real SQL driver answering 0 on a stale ciphertext and 1 on the stored one).

Fails closed: right; no partial write is possible. A row whose stored fields do not form a handle, or that does not open under its attributed context, is refused_unreadable; an unknown marker is refused_unknown_derivation and is never opened (pinned by the recording provider); a re-seal that does not hold is refused_verify_failed. None of those reaches the writer. A write is one statement per row, so a row is written whole or not at all; a thrown write is write_failed with the row as it was. rewrapUnfinished makes --apply exit 1 on any refused or not-written class while the run finishes the rest. The dry run exits 0 after reporting the same classes, which the body states.

Verify before write: right. resealHolds requires the same id, a current derivation on the new ciphertext, a usable version, and decrypt(next, the same ctx) === the plaintext opened from the stored row, before the write; pinned with a re-seal that opens to a different value and one that does not open, plus the honest positive control. The ablation of the three-line check turned exactly that pin red.

The dry run: right. It is the default; writer is null, and the boot takes deferSchemaDdl: true, readOnlyProbe: true, the os migrate plan boot os secret orphans also takes for its report. Pinned: updateMany never called and the store unchanged over the driver double; the one-shot-family ledger's noWrite member leaves the SQLite file byte-identical, boot included, and creates no missing file. --apply lands exactly the dry run's byClass over the same store (pinned), with write_conflict as the only way they diverge, and that only when a producer wrote in between, which is the property the conditional write exists for. --apply keeps the plain boot, as orphans --delete does; the family ledger's write member pins that it performs no seed write.

Per-row scope attribution (D3): right, in this order. attributeRewrapScope decides: (1) holders of more than one scope → left_conflicting_scope, whatever the union's completeness, because a missing family can add a holder and never remove one; (2) union incomplete → left_union_incomplete, because one visible scope does not prove there is no other and an orphan cannot be told from a row the missing family holds; (3) no holder → left_orphan; (4) the one scope. The derivation check runs before attribution, so a current row is done whoever holds it and an unknown marker is refused whoever holds it; right. Under an incomplete union no row gets a scope, attempts is 0, and --apply refuses outright naming the family (pinned). No row is ever re-sealed under a guessed scope: the only scope a row can carry is the one scope of its holders' family, and the ablation that replaced the orphan branch with a guessed scope turned six pins red. The row is opened with its own (namespace, key) and the holder's scope; that is the coordinate each producer opens with — verified for the settings producer at the head (set() stores the handle under the same namespace/key it sealed with, and materialiseRow opens with the setting row's namespace/key under scope: 'settings') and pinned for the other two by opening re-wrapped rows through engine.resolveSecret and the binder's resolve. SCOPE_OF_HOLDER_FAMILY is total over the closed family set: it is a Record keyed by SecretReferenceFamily (three members at the head), frozen, and pinned equal to both SECRET_REFERENCE_FAMILIES and CRYPTO_CONTEXT_SCOPES; a fourth family stops compiling here before it compiles anywhere. The union's handleId is the sys_secret.id (the reference type says so), so grouping by it and looking up row.id is the same key; the classifier was not extended, and that was right: SecretReference.family already carries the holder's kind.

The ciphertext census: right. Every non-test encrypt call site at the head is one of the three producers (the stage-1 census, re-read: settings-service.ts, engine.ts, datasource-secret-binder.ts), and all three persist the handle in sys_secret (the settings producer through secretStore.insert with the handle's ciphertext, read at the head). sys_setting.value_enc inline values go through this.crypto, the separate CryptoAdapter (NoopCryptoAdapter the only in-tree implementation, no CryptoContext, no provider AAD), and the orphan sweep already counts them as legacy inline; the auth backup codes are not a provider call site. Both are rightly out of this class, and the diff builds no second sealing path.

Key handling: right; the command never mints in any posture, and no key is a refusal. The provider is built with mode: 'production' and an env map that withholds OS_CRYPTO_AUTOKEY. In resolveDataKey at the head that reaches only the production branch, which reads an existing key file and never creates one, and the auto-key opt-in is the only minting path there; with the opt-in withheld it throws MISSING_PROD_KEY_MSG. The development and test branches are unreachable with the mode forced, whatever NODE_ENV says. The thrown message becomes keyUnavailable; the settings plugin is handed either this provider or a provider that refuses every call, so settings-service-plugin.ts's this.opts.cryptoProvider ?? new LocalCryptoProvider() default, which would mint in a development posture, is never constructed in this run. With no key and a row to open, the run refuses crypto_key_unavailable before opening anything; with nothing to open it reports without a key, which is right. Pinned twice: the guards test (an empty key home stays empty, with and without OS_CRYPTO_AUTOKEY=1) and the driver-contract test through the real boot in a development posture, with a positive control that a default-posture provider in the same kind of home does mint.

The new published export ciphertextDerivationStatus: right. It is a thin reading of readCiphertext, the function decrypt and rotateKey dispatch on, catching exactly UnknownCiphertextVersionError into 'unknown' and rethrowing anything else; a non-string is 'unknown'. The marker grammar lives in the provider only: the planner takes derivationOf as an injected port and the command passes the export; no v2, no separator and no superseded rule is spelled in packages/cli/src/utils/sys-secret-rewrap.ts or rewrap.ts (the test fixtures that construct an unknown marker are fixtures). Its pins tie it to decrypt's own behaviour on the same bytes: the pinned version-1 vector reads superseded and opens; the rotated result reads current; the pinned version-2 vector and a fresh seal under every scope read current; the unknown-marker bytes read unknown and decrypt refuses exactly them; and a version-1 body sealed under another key still reads superseded while refusing to open, which is the reason the re-wrap opens every row before it writes. The type and function are added to the named barrel src/index.ts, which package.json exports["."] serves.

Output: right, with one class named. The report is classes and counts: families (status, count, gap reason), refusal, counts, byClass, rewrapByScope, notes, keySource as a source name. Pinned against the fixture: no plaintext, no ciphertext (before or after), no sys_secret row id, no holder coordinate, and not vacuous. The human rendering prints the same. The refusal messages carry a file path (declared_datasources_unreadable), the provider's own no-key message naming the key-file path (crypto_key_unavailable), or a boot or scan error. One class the pin does not cover: on a gap, families[].reason and refusal.gaps[].reason are the union's own text, which the union types as safe to print and which can name an object and field (an unreadable holder object) or a sys_metadata row id and datasource name (an artefact that does not parse). That is the holder family's coordinate on a gap, never a sys_secret row id, ciphertext or plaintext, and it is exactly what os secret orphans prints today. Noted below; not owed.

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

  • @objectstack/cli: a new published operator command os secret rewrap with six flags, registered in both bootSchemaStack family ledgers; the planner module is not an export. Additive; right at minor.
  • @objectstack/service-settings: two names added to the root barrel, ciphertextDerivationStatus and type CiphertextDerivationStatus; SEALING_DERIVATION is module-private. No existing export moves, no accept set narrows, encrypt/decrypt/rotateKey unchanged. Additive; right at minor.
  • packages/spec/src/contracts/crypto-provider.ts, engine.ts, docs/adr/**, cloud: untouched, as the claim required.
  • content/docs/deployment/cli.mdx: a hand-written page gains the command beside os secret orphans; what it promises matches the code (dry run default, opens in memory, classes and counts, never minted key, the three left classes, the refusals).
  • One ADR-0128 anchor, one file named for the path it anchors, as the register requires; check:adr-anchors read 60 anchored files.
  • The security-family rule holds across the PR body, the changeset, the docs page and the code docblocks: classes and positions, no exploit construction, no worked cross-vocabulary example. The test files seal version-1 rows under the derivation the provider's own docblock and ADR-0128 §1.2 already state, which is a fixture, not a construction.

② Semver level

The levels are right; the Clause-② declaration is wrong, and that is this record's FAIL item.

The changeset .changeset/21326-secret-rewrap.md grades @objectstack/cli minor and @objectstack/service-settings minor. Both are purely additive widenings of a published package's surface, which the maintainer's ruling the gate carries ("a purely additive widening of a published package's public surface takes at least minor") requires; nothing is breaking, so no BREAKING banner is owed and no ADR-0087 marker applies, and check-adr-0087-registration read "adds no declared-breaking changeset". Right.

The PR body's second line and the changeset both read Clause-②: no. The repository's own criterion for the line is 「本卡放宽接受集或扩大公开面吗」 (.claude/skills/pm-dispatch/references/execution-duties.md), with the published surface judged by the package's exports map (references/contract-review.md: 「已发布面以包的 exports 映射为准」), and the gate itself glosses a no as "no package here is declared to have grown a published surface". This diff grows two published surfaces: @objectstack/service-settings adds two names reachable through exports["."], and @objectstack/cli adds an operator command, which the repository's own precedent .changeset/21018-cli-generate-picklist.md declares Clause-②: yes (widening). The changeset's prose says so itself ("@objectstack/service-settings publishes ciphertextDerivationStatus") two paragraphs under its no. The dev's rationale, "no contract or authorable surface moves", answers a narrower question than the line asks. references/landing-operations.md names the consequence: 「错误的 no 是可审计的假申报」.

The gates' verdicts do not rescue it: Check Changeset concluded success because the level axis cross-checks only a yes (a yes must grade a moved package minor or above); it asserts nothing on a no and cannot detect a widening declared away. So the success is not evidence for the declaration. Owed, in one patch round: Clause-②: yes (widening) (or bare yes) on the PR body's second line and in the changeset. The levels already satisfy the level axis, so nothing else moves, and the ADR-0087 reading stays "no declared-breaking changeset".

③ Boundary flags

The dev's deviations, each answered:

  1. The key file minted in the container's home by the early probe (at 5015d0db67, before the settings plugin was handed the run's provider): accepted as declared. The source is the plugin default read at the head (settings-service-plugin.ts, cryptoProvider ?? new LocalCryptoProvider(), development posture). The closure landed in 3200804adb and is pinned twice. Leaving the file in place rather than deleting a key some other development-posture process in the shared container may since have adopted is the smaller risk, and the file is outside the repository, so the post-task checklist's clean-tree rule is not touched. Nothing owed from this PR; the dispatching seat should know the container's key home now holds a minted development key.
  2. The dry run opens, re-seals and verifies rows in memory, against the orphan report's "without decrypting" ruling: accepted as right. The ruling of 2026-08-12 governs the orphan REPORT, a pure classifier over snapshots typed without ciphertext that needs no key to decide; this command's one purpose is to open and re-seal, and §4.2's "fails closed on any row it cannot read" can only be previewed by opening. The plaintext never leaves the per-row step (pinned absent from the report), the command docblock, the docs page and the dry-run note all say the dry run opens, and the consequence is the right one: the dry run needs the deployment's key and refuses without it when a row is attributable.
  3. The classifier not extended: measured and right (SecretReference.family).
  4. The boot composition including SettingsServicePlugin: right, sys_setting is the settings family's holder; the orphans command composes the same.
  5. pnpm test -- --maxWorkers=2 dropping its flag through the bare --: the AGENTS.md trap, declared, and the whole-package reading is the one cited. Accepted.
  6. Memory, Mongo and Turso measured by reading source only: re-read here at the head; holds (① above).
  7. The refused rm -rf ./* probe: nothing ran; accepted.
  8. Labels: the dispatch named none; accepted.

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

  1. Rows the rewrap leaves keep the version-1 binding: right carrier, the next stage; folded into the list below.
  2. Pre-existing: os secret orphans may mint a key file. Verified from source at the head: orphans.ts composes new SettingsServicePlugin({ registerRoutes: false }) with no cryptoProvider, the plugin defaults to new LocalCryptoProvider(), and in a development posture with no key resolveDataKey mints and persists dev-crypto-key in the key home, during a report whose own text says it writes nothing. In a production posture the same default refuses to construct, so the boot fails loud; the trap is development posture only. This is a defect to file, and it is escalated to the dispatching seat to file (Prime Directive chore: version packages #10: reproducible from source, with the repro being the composition itself): class, a report-only operator command with an undeclared filesystem side effect in key custody, security family, pre-existing on main, outside this diff; remedy, the composition this PR already uses (a provider resolved in the strict posture, or a refusing one, handed to the plugin). Not a verdict item here, and the dev was right not to widen the claim's surface to fix it.

Further flags from this review:

  • What the later stage (retiring version-1 opening) must now carry, given the classes this rewrap leaves. (a) Five classes keep rows outside the current derivation after a clean --apply: left_orphan, left_conflicting_scope, left_union_incomplete, refused_unreadable (sealed under a key this deployment does not hold) and refused_unknown_derivation; every one would stop opening or stays unopenable once version 1 is retired, so the retirement needs a measured zero of superseded rows, deployment by deployment, as its precondition, and the dry run's byClass is that census. (b) left_orphan on this one-shot boot is a lower bound on attribution, not "no holder anywhere": the object-field family enumerates the objects REGISTERED on bootSchemaStack's boot (the compiled artifact's objects plus the platform objects), and a secret: holder on an object that boot does not register reads as no holder. The rewrap leaves such a row, which is the safe direction and D3's; the later stage must not read the count as deletable or retire-able without that caveat, and os secret orphans shares the enumeration. (c) The operating sequence belongs in the docs: os secret orphans --delete for the settings-attributable orphans, then os secret rewrap; a left_conflicting_scope row has no mechanism and needs an operator decision. (d) The contract paragraph on what version-1 ciphertext still carries, and the rollback statement, move with the retirement; ADR-0128's dated implementation note stays Tier H on its separate card. (e) ADR-0112: still no HTTP surface and no refusal answers a request, so no ledger row is owed from this PR (the stage-1 review's ③ condition did not trigger); it triggers if the later stage makes a version-1 ciphertext a refusal on a read path a request reaches.
  • Two refusal codes the body's list omits: no_engine and no_sys_secret_driver, both reachable and inherited from orphans.ts. A body edit in the same patch round; not a code change.
  • The gap-reason class in the output (① Output): the union's own text on a gap can name a holder object/field or a sys_metadata row and datasource name. It is the union's typed "safe to print" text and the same text os secret orphans prints; the PR body's "no row id" sentence is true of the report's own fields and should not be read as covering it. Noted, not owed.
  • Governed surfaces: none in the 13 paths (.changeset/, content/docs/, packages/**, scripts/adr-anchors/**); Governed Surface Queue Guard concluded success.
  • The claim's serial constraints and exclusions hold: stage 1 (57cc695062) is an ancestor of the base; 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) and the contract file are untouched; no cloud edit.
  • Check-runs on the head, read 2026-10-02T21:47:44Z: 33 runs; 26 success; 2 skipped by design (Console Pin Gate, no .objectui-sha change; Packed-tarball smoke (opt-in)); 5 in_progress (Lint & Repo Gates, Test Core (1/6), (4/6), (5/6), Type Check · workspace); none failed. Build Docs ran and succeeded, since the diff touches content/docs. in_progress is an honest reading, not a pass. The PR is a draft with no auto-merge; Part of #21326 with no closing keyword, and the part-of check concluded success.
  • The advisory docs-drift comment lists 28 hand-written pages by symbol; both surfaces this diff adds are additive, so no existing statement is falsified by them, and the one page the command belongs on was edited. Not re-read page by page.

Implemented-by: claude/issue-21326-at-rest-rewrap
Reviewed-by: session_01YDt3PzwfrkuFzUBF89WPmM

VERDICT: FAIL

Failed item: ② the Clause-②: no declaration on the PR body and in .changeset/21326-secret-rewrap.md. The diff grows two published surfaces (a root export on @objectstack/service-settings, an operator command on @objectstack/cli), so the line owes yes (widening). The levels, the code, the pins and every ① and ③ judgment above stand; the patch round is that one line in two places, plus the two refusal codes in the body's list, on a new head for re-review.


Generated by Claude Code

The diff grows two published surfaces: a root export on
@objectstack/service-settings (ciphertextDerivationStatus and its type) and
the os secret rewrap command on @objectstack/cli. The levels (minor, minor)
already satisfy a widening declaration.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 219457c0298cbd7a8a88a42839df952da7554b67
Local-runs: none

Scope read: a re-review of PR #21469 after record 5962153191 FAILED one item (②) at 6a7c27c2f21c45433ea5261d705acd3c2763b127. Read at this head: the PR (body, the 13-file list, the net diff against main; review threads and reviews: none), the card #21326's comments since that record (the seat's claim amendment 5962171746, the dev's fix-round report 5962212613), the delta 6a7c27c2f2..219457c029 through git fetch origin and git diff --stat on the local checkout, .changeset/21326-secret-rewrap.md at both heads, packages/cli/src/commands/secret/rewrap.ts at the head for the refusal codes it emits, the repository's reader of the declaration line (scripts/pm/clause2-line.mjs, scripts/check-changeset-no-major.mjs) and the ADR-0087 marker form (scripts/check-adr-0087-registration.mjs) at the head, and the check-runs on the head (47, read 2026-10-02T22:10:47Z). Nothing built, run or re-run. ⛔ Security family: classes and positions only.

① Derived judgments

The delta touches nothing ① judged, so the previous record's ① holds at this head by reference. git diff 6a7c27c2f2 219457c029 --stat reads .changeset/21326-secret-rewrap.md | 2 +- — one file, one insertion, one deletion — and exactly one commit lies between the two heads (219457c029 fix(changeset): os secret rewrap declares Clause-② yes (widening), parent 6a7c27c2f2). The PR file list is the same 13 paths (+2298 / −1) that 5962153191 read. No code, test, docs page, anchor or generated artifact moved, so every ① judgment there — resumable, safe against a live deployment, fails closed, verify before write, the dry run, the scope-attribution order and the totality of SCOPE_OF_HOLDER_FAMILY, the ciphertext census, key handling, ciphertextDerivationStatus, the output shape and the per-surface accept-set reading — stands as delivered and is not re-derived here.

② Semver level

Right at this head; the one failed item of 5962153191 is closed.

  • The changeset. At 219457c029, .changeset/21326-secret-rewrap.md line 8 reads Clause-②: yes (widening), at the start of a line of its own. The frontmatter is unchanged: @objectstack/cli minor, @objectstack/service-settings minor. The prose is byte-unchanged from 6a7c27c2f2 apart from that one line (the full diff is the -Clause-②: no / +Clause-②: yes (widening) pair and nothing else). No ADR-0087 marker is present: the marker form is an adr-0087: disposition comment and the file carries none, which is right because both levels are additive widenings and nothing is breaking; the Check Changeset step "Require an ADR-0087 disposition on a declared-breaking changeset" concluded success on this head.
  • The declaration grammar. scripts/pm/clause2-line.mjs at the head accepts yes (widening) as the arm-carrying spelling of yes (CLAUSE2_VALUES holds yes, CLAUSE2_ARMS holds widening), reads only a key-initial line (CLAUSE2_KEY_LINE is anchored at the line start after at most one list, quote or bold decoration), and returns the first declaring line. Spelling and position are both the ones the gate reads.
  • The PR body. Line 1 Part of #21326, line 2 Clause-②: yes (widening), at the line start with no decoration; it is the body's only key-initial Clause-② line, so it is the one the reader returns. The body's one other occurrence (line 72) is a lowercase quotation inside the gates prose, not key-initial, and the reader passes over it.
  • The refusal list is complete against the code. rewrap.ts at the head emits nine error codes: boot_failed, confirmation_required, crypto_key_unavailable, declared_datasources_unreadable, driver_cannot_compare_and_set, no_engine, no_sys_secret_driver, scan_failed, union_incomplete. The body's A5 list names the same nine; the two the previous record found missing (no_engine, no_sys_secret_driver) are now in it.
  • No stale quotation remains. The body's gates section quotes this round's level-axis line (declares clause-② yes (widening), and no package whose packages/**/src/** it moves is graded patch), marked as re-run at 219457c029; no no reading of the axis survives anywhere in the body. The gates header's "local, at 6a7c27c2f2" and the ablation section's 6a7c27c2f2 are honest statements of where those runs happened, on code this delta did not move; they are not stale claims.
  • The three carriers agree. Changeset line 8, PR body line 2 and the seat's amended claim 5962171746 all read yes (widening); the stage-2 claim's original no (5960437209) is superseded on the card by that amendment.

③ Boundary flags

  • Check-runs on this head, read 2026-10-02T22:10:47Z: 47 runs; 30 success; 6 skipped (Console Pin Gate, no .objectui-sha change; Packed-tarball smoke (opt-in); and Auto Label ×2 and Check PR Size ×2 on the two re-fired pull_request runs, each of which also has a success leg on this head); 11 in_progress (Lint & Repo Gates, Temporal Conformance (live PG + MySQL), Test Core (1/6) through (6/6), Type Check · consumer gates, Type Check · debt ledger, Type Check · workspace); none failed. in_progress is an honest reading, not a pass. Check Changeset ran three times on this head (pull_request events created 22:03:25Z, 22:04:13Z and 22:08:17Z: the push, then each body edit re-firing it) and concluded success each time; in the latest run the steps "Guard against accidental major bumps (launch window)" (the level axis, which now cross-checks the declared yes against the levels of the packages the diff moves), "Require an ADR-0087 disposition on a declared-breaking changeset" and "Reject an empty-frontmatter changeset added by this PR" each concluded success. The job's log text was not readable from this container (the Actions log store refused the read), so the per-step conclusions above are from the job record, not the printed lines. Also success: Governed Surface Queue Guard, Build Core, Build Docs, Dogfood Regression Gate and its three shards, Dogfood Verify CLI, Type Check · source gates, Spec property liveness, and the three card-claim checks.
  • Governed surfaces: none in the 13 paths; unchanged from the previous record.
  • PR state: draft, no auto-merge, no reviews, no review threads; Part of #21326 with no closing keyword, and the part-of check concluded success on this head.
  • Body-edit provenance: the dev's body patch left through the fleet relay (fleet-write run 37070422025, repository_dispatch, success), as the dev's report states; the body ends on its session-URL footer, with no HTML comment and no angle-bracket fragment.
  • The finding 5962153191 escalated to the dispatching seat (os secret orphans composing the settings service with its default provider, which can mint a key file in a development posture during a report that writes nothing to the database) is filed as finding(cli): os secret orphans, a report that "writes nothing", composes the settings service with its default crypto provider, which mints a key file in the key home in a development posture with no key #21471 (open). Not a verdict item here.
  • Carried forward unchanged from 5962153191 ③: the accepted deviations, the five classes the rewrap leaves and what the later version-1-retirement stage must carry (the measured-zero precondition, the lower-bound caveat on left_orphan, the operating sequence for the docs, the contract paragraph and rollback statement moving with the retirement), the gap-reason text class in the output, and ADR-0112 owing no ledger row from this PR.

Implemented-by: claude/issue-21326-at-rest-rewrap
Reviewed-by: session_01YDt3PzwfrkuFzUBF89WPmM

VERDICT: PASS

The one failed item of 5962153191 (②) is closed at this head: the declaration reads Clause-②: yes (widening) in the changeset and on the PR body's second line, the levels and prose are unchanged, no ADR-0087 marker is owed or present, and the body's refusal list is complete against the command. The delta is that one line, so every other judgment of the previous record holds by reference. The required contexts still in_progress are read as such; landing waits on their success.


Generated by Claude Code

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

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants