Repository navigation
fix(service-settings): repoint sys_setting.value_enc on a secret rotation, and reap the retired ciphertext (#8030) - #8063
Conversation
…tion, and reap the retired ciphertext (#8030) A second PUT of a new value for an encrypted setting key answered 200 with a correctly redacted body, advanced `updated_at`, wrote an audit row and inserted a genuinely new `sys_secret` row holding the new plaintext — and left `sys_setting.value_enc` pointing at the FIRST handle. The effective secret never changed, so a leaked credential survived the rotation meant to retire it, with every visible signal saying the rotation had landed. The first write of any secret was correct, so it was invisible until the second. Cause: `sys_setting.value_enc` (and `updated_by`) are declared `readonly: true`, and the engine strips author-declared read-only columns from a NON-system caller's UPDATE payload (`stripReadonlyFields`, gated on `!opCtx.context?.isSystem`). `SettingsService.upsertRow` persisted through a plain, un-elevated `engine.update`, so the handle could never be repointed. The INSERT path is deliberately exempt from that strip (#3413) — which is exactly why the first write landed and every later one did not. Fix, on the service side only: - `upsertRow` performs its UPDATE as a system write. `SettingsService` is a privileged writer — the manifest capability gate, the env/upper-scope lock pre-flight and `validatePatch` have all run by then, and these are columns it owns rather than ones a caller forged. Same posture, same reason, as `ObjectQL.recomputeSummaries`' elevation (#7673). - `SettingsEngine.update` gains a `context` member and the `IDataEngine` adapter forwards it on BOTH branches. The settings row write takes the `multi` one (its `where` is the composite key, never an `id`), so the by-id branch is the half that would rot unnoticed. - `value_enc` STAYS `readonly: true`. The elevation is scoped to this one write, so an external caller reaching `sys_setting` through the data layer still cannot repoint a secret handle. A test fails if that flag is removed. Orphans are reaped rather than accepted: the `sys_secret` row a rotated-away handle named is deleted once the repoint has committed. An orphan is a decryptable copy of the credential the admin just retired, one more per rotation. The store's `delete` is optional and the call is best-effort — the new secret is already in force by then, and a failed cleanup is reported, never raised. Tests boot a real ObjectQL over the real `SysSetting` / `SysSecret` schemas and drive the real service through the real adapter, so the verdict is about the engine's actual strip rule rather than about a fake written to match the fix. `vitest.config.ts` aliases `@objectstack/objectql` and `@objectstack/core` to source, per `check-test-source-alias`. Fixes #8030 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AFizXytCQYaGCNvewL7TaE
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also reference the affected code. These are read-only:
|
…ource, emptying its check-test-source-alias entry (objectstack-ai#8104) (objectstack-ai#8190) objectstack-ai#8063 aliased two workspace deps to source in this package's first `vitest.config.ts`. The other three — `@objectstack/spec`, `@objectstack/platform-objects`, `@objectstack/types` — stayed on their `KNOWN_UNALIASED_TEST_IMPORTS` entry and kept resolving through `exports` to `dist/`. They are now aliased to source and the registry entry is deleted. 17 of this package's 20 test files were really reading the artifact: with the three `dist/` trees removed and the pre-objectstack-ai#8104 config in place, 17 files fail to load and only 58 of 413 cases run. With the aliases, the same tree with no `spec`/`platform-objects`/`types` build output passes 413/413. The subpath entry points were the actual work. This package imports no bare `@objectstack/spec` and no bare `@objectstack/platform-objects` at run time — every reachable specifier for those two is a subpath (`spec/api`, `spec/contracts`, `spec/data`, `spec/system`, `platform-objects/system`, plus `spec/security` reached transitively through `types`). The object alias form would swallow them into `…/index.ts/<sub>` (`ENOTDIR`, objectstack-ai#7778); the anchored bare form objectstack-ai#8063 established does not swallow them but does not cover them either, so copying that shape would have left every one of them on `dist` with the registry entry undeletable. So `spec` takes the one-rule-for-all-namespaces capture form, its export map being uniform, while `platform-objects` takes an explicit `/system` entry because its map is not (`./plugin` is `src/plugin.ts`, a file, so a `([a-z-]+)` rule would invent `src/plugin/index.ts`). `plugin-audit` writes its `platform-objects/audit` entry the same way. The registry deletion is half the change: the entry is audited for set equality in both directions, so aliases-complete-with-entry-present and entry-deleted-with-aliases-incomplete each fail the gate. It reads OK at 61 registered packages, down from 62. 413 passed / 20 files, unchanged from baseline. No change under `src/`. Claude-Session: https://claude.ai/code/session_01SMN7p2W62YzA3bY7BaFv3r Co-authored-by: Claude <noreply@anthropic.com>
… the reachability fact it falsifies (objectstack-ai#8103) (objectstack-ai#8261) * feat(service-settings): report-only sys_secret orphan classifier (objectstack-ai#8103) Lands the non-destructive half of objectstack-ai#8103: an operator-facing answer to "which sys_secret rows are orphaned, and why", with no deletion and no migration. The sweep's vehicle stays an open maintainer decision. Re-measuring objectstack-ai#8063's three reachability facts falsified the middle one: sys_setting.value_enc is NOT the only column holding a sys_secret handle. The store has three producers (SettingsService, the engine's secret-field channel storing `secret:<id>` on arbitrary business rows, and the datasource credential binder storing `sys_secret:<id>` at external.credentialsRef), and the engine's holders are not statically enumerable. So the classifier reports a row it cannot attribute as `unattributable` rather than `orphaned` — the guard that stands between a future sweep and a live datasource credential. Also pins the two directional guards the card names: a rotateKey() re-wrap keeps the handle stable and reads as in-force (rotation metadata never decides a verdict), and a legacy inline value_enc contributes no handle while flagging its (namespace, key) siblings. Tests drive a real ObjectQL over the real SysSetting/SysSecret schemas: the orphan-generating mechanism is reproduced rather than cited, and the fixture carries all four classes so the pins are not vacuous. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARidKDYSCD56LaygrvDPnk * fix(service-settings): write the refKey NUL separator as an escape, not a raw byte check:nul-bytes caught a raw 0x00 in the composite-key separator -- the exact accident the gate documents (an editing tool materialising an escape while the author writes about it). The backslash-u-0000 spelling is byte-identical at run time. Also documents why NUL rather than a printable separator: no namespace or specifier key can contain one, so no two distinct pairs alias into one composite -- and an aliased pair here would silently change a row's verdict. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARidKDYSCD56LaygrvDPnk * test(service-settings): route the context-dropping engine double through assertEngineUpdateDispatch check:engine-double-contract (convention-scoped, so dispatch-gates.mjs cannot derive it) flagged the objectstack-ai#8103 harness's SettingsEngine double as declaring an update() that does not route through the shared dispatch predicate. Took the pin rather than a baseline entry: the predicate reads only where / multi / data.id and never looks at context, so it is orthogonal to the defect this double reproduces (dropping context so the engine strips the read-only value_enc). The double stays loose in exactly one dimension and is now conformant in the one that drifts silently -- by-id vs multi. multi:true is passed unconditionally instead of re-deciding the branch, because that IS the settings adapter's contract: a scalar where.id outranks multi in the shared predicate, so one call reproduces both of wrapEngineAsSettingsEngine's branches without mirroring the guard. Imported from @objectstack/objectql (already a devDependency, already aliased to source by vitest.config.ts) rather than @objectstack/metadata-core: the cycle rule prefers metadata-core only when objectql depends on the package being pinned, and objectql does not depend on service-settings. No verdict logic changed; settings-service.ts and crypto-adapter.ts untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARidKDYSCD56LaygrvDPnk --------- Co-authored-by: Claude <noreply@anthropic.com>
… over the COMPLETE reference union
The operator-run half of the pre-fix `sys_secret` residue. Report-only by
default; `--delete` removes ONLY rows that are attributable to a declared
encrypted settings specifier AND named by no family of the cross-producer
reference union, and it refuses whenever that union is incomplete, naming the
family that could not be enumerated.
Three guards are mechanical rather than advisory, because a delete over a
secrets table cannot delegate to an operator's attention:
- `unattributable` rows are never deletable;
- a row whose (namespace, key) currently resolves through a LEGACY INLINE
sys_setting value is withheld — the #8063 prefix guard in the opposite
direction, since an inline value names no handle and so explains the
absence;
- re-wrap evidence (version / rotated_at) is reported and is never a verdict
input: a re-wrap keeps the handle stable and is not a retirement.
The pre-delete export is mandatory and carries the cipher material, because the
audit trail records content digests rather than handles: without it an
erroneous delete can neither be named nor undone. It is written owner-only and
read back and checked against the plan before any row is removed.
The operator-facing text states the measured, inverted exposure framing: the
value still in force is the OLDEST one, the orphans hold values that never took
effect, so this retires nothing that is exposed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
…eting only over the COMPLETE reference union (objectstack-ai#12990) * feat(cli): os secret orphans — report sys_secret orphans, delete only over the COMPLETE reference union The operator-run half of the pre-fix `sys_secret` residue. Report-only by default; `--delete` removes ONLY rows that are attributable to a declared encrypted settings specifier AND named by no family of the cross-producer reference union, and it refuses whenever that union is incomplete, naming the family that could not be enumerated. Three guards are mechanical rather than advisory, because a delete over a secrets table cannot delegate to an operator's attention: - `unattributable` rows are never deletable; - a row whose (namespace, key) currently resolves through a LEGACY INLINE sys_setting value is withheld — the objectstack-ai#8063 prefix guard in the opposite direction, since an inline value names no handle and so explains the absence; - re-wrap evidence (version / rotated_at) is reported and is never a verdict input: a re-wrap keeps the handle stable and is not a retirement. The pre-delete export is mandatory and carries the cipher material, because the audit trail records content digests rather than handles: without it an erroneous delete can neither be named nor undone. It is written owner-only and read back and checked against the plan before any row is removed. The operator-facing text states the measured, inverted exposure framing: the value still in force is the OLDEST one, the orphans hold values that never took effect, so this retires nothing that is exposed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd * docs(cli): document `os secret orphans`, and add its changeset The docs entry states the same two things the command's own output does: the delete is refused unless every producer family enumerated, and the exposure framing is inverted — the value still in force is the oldest one, so the sweep retires nothing that is exposed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd * fix(cli): create the pre-delete export exclusively, never onto a file it lost the race for `os secret orphans --delete` refuses an export path that already exists, but that check runs before the boot, the scan and the confirmation — an arbitrarily long window before the write. The write itself used the default flag, which truncates an existing file and, because `mode` is applied only at creation, inherits that file's owner and permissions. Cipher material could land in a file the operator does not exclusively own. `flag: 'wx'` makes it a create-or-fail: EEXIST lands in the existing `export_failed` refusal, so nothing is deleted, and 0600 is guaranteed at the instant of creation. The pre-check stays — it still gives the better early message; the two are defence in depth, not alternatives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd * test(cli): pin that a squatted export path deletes nothing, with a positive control Drives the real `--delete --json` run against two fake seams only — the boot and the reference-union collector. Everything that decides which rows are deletable (`planSysSecretOrphanSweep`, `buildPreDeleteExport`, `collectEncryptedSpecifierRefs`, `isSecretHandle`) runs for real. The subject test creates the export file *inside the mocked boot*, which is exactly the window between the early `existsSync` refusal and the write: the run must remove no row and must leave the squatter's bytes intact. The positive control runs the same path over a free destination and asserts a row IS removed, so the empty list above cannot be satisfied by a command that can never delete anything. Also amends the existing changeset to state the atomic guarantee rather than just the pre-check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd * test(cli): drive `os secret orphans --json` in the stdout-purity family The family in json-stdout-purity.e2e.test.ts is DISCOVERED from the source tree — every command that calls bootSchemaStack and declares a `--json` flag — and reconciled against FAMILY. `os secret orphans` satisfies both halves, so it joined the discovered set the moment it landed and the reconciliation went red, exactly as that file's docblock promises it should. Listed and actually driven, not just named. Its report-only default is the form driven here: without `--delete` it boots, reports and writes nothing, so the family gains a member without the fixture gaining a destructive run. Measured on the uncompiled fixture before listing it, in case the red was hiding a real leak — it was not: stdout is a single 2836-byte line that a bare JSON.parse accepts (keys `mode,plan`), with zero kernel-logger records and zero `[StandaloneStack]` lines, while all three boot diagnostics stay on stderr. No change to the command was needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd --------- Co-authored-by: Claude <noreply@anthropic.com>
…ommits that decided them (objectstack-ai#20836) Part of objectstack-ai#20596 Clause-②: no ## What changed This is the fifteenth stage of the `domain:services` lane of the dead-citation sweep. It covers `packages/services/service-settings/src/**` and nothing else. By the seat's claim (`5908460751`), it is the largest package left in the lane. Later stages cover the other packages, so this PR says `Part of` and the card stays open. Every comment or docblock site in scope that cited a tracker number answering 404 has been rewritten in ruling C+D's form C (comment 5749154545 on objectstack-ai#19123), by the method of stages 1 to 14 (the latest is PR objectstack-ai#20816, landed as `73155fedc`). That is **11 sites on 11 lines in 9 files, covering 4 numbers**: - 4 census sites (every census site this package has at the base); - 7 sites in test comments, which the census defers. One of their numbers, `objectstack-ai#11318`, stands only in test files here; it was read on its own and answers 404. Each rewritten line now cites the commit in this repository that decided what the line describes, and says in its own words what was decided: **4 distinct commit shas**. No ADR records any of the four decisions (see the per-number table), so ruling C's commit rung applies. No number was dropped. Only comments changed. Every touched source file keeps its line count (11 lines out, 11 in, over 9 files), so no line citation into these files moves. All 11 changed lines carried a dead citation. No code token moves (see the guard below). **No citation number is added.** The only tracker number on an added line is the live `objectstack-ai#10251`, once, in `settings-prebind-read-warning.test.ts:17`. It already stood on that line, and it now sits beside the sha as the convenience link ruling C allows: 「(commit 1ec36b7, PR objectstack-ai#10251)」. `1ec36b730` is that pull request's squash commit. 2 dead sites are left on purpose: a test title and a test assertion message (see the list below). One more file: a `patch` changeset for `@objectstack/service-settings`, because the rewritten prose ships (see Changeset below). ## Census: `service-settings`, before and after **Instrument (A1).** The gate's own `node scripts/check-issue-citations.mjs --census --json`, read-only and unchanged. The count below is its `allocated-but-absent` findings under `packages/services/service-settings/`. Each run counts as a reading only because its board frontier equals the newest issue or pull-request number, read by a separate request just before and just after the run. | reading | tree | board | whole-repo `allocated-but-absent` | service-settings sites | lines | files | numbers | |---|---|---|---|---|---|---|---| | before | base `73155fedc`, run 2026-09-30T09:39:38Z to 09:43:17Z | enumerated, 187 pages, frontier objectstack-ai#20830 (newest objectstack-ai#20830 before and after) | 752 | **4** | 4 | 4 | 3 | | after | head `ac05607d6`, run 10:02:17Z to 10:06:00Z | enumerated, 187 pages, frontier objectstack-ai#20834 (newest objectstack-ai#20834 before and after) | 748 | **0** | 0 | 0 | 0 | The whole-repo drop is 4, exactly this diff's census sites. The `resolves` tally is 33,134 in both runs, and `resolves-as-pull-request` (1,985) and `cross-repo-unjudged` (1,018) did not move either. Neither run was truncated or discarded: both enumerations read 187 pages at the newest frontier. The seat's census counted 4 here at `6bff748b`, and the base agrees: `6bff748b` is an ancestor of the base, and no commit between them touches this package's `src`. **Supplementary instrument, the whole scope.** The census does not read test files or strings, and this stage's scope includes test comments. So a second reading runs the gate's own exported `extractCitations` (whole-file and comment-prose projections) and `namesThisRepository` over every `.ts` file under `service-settings/src` (64 files). It takes its verdicts from the before census's own board reading rather than from a second enumeration: a number is dead when that census reported it `allocated-but-absent`, and alive when the gate's own census-scope extraction judged it and the census did not report it. 10 numbers are covered by neither, because they stand only in test files, or as the second number of an `#A/#B` pair. Each was read on its own through the read-only tools. 1 answers 404 (`objectstack-ai#11318`, on the issue and the pull-request endpoint alike); 6 answer as issues; 3 answer as pull requests (`objectstack-ai#7554`, `objectstack-ai#10251`, `objectstack-ai#5133`). The probe's control: the known issue `objectstack-ai#11352` answers 404 on the pull-request endpoint. | reading | citations | dead | src comment | test comment | src string | test string | |---|---|---|---|---|---|---| | before, `73155fedc` | 534 | **13** | 4 | 7 | 0 | 2 | | after, `ac05607d6` | 523 | **2** | 0 | 0 | 0 | 2 | Its src-comment column equals the census's 4, which is the control on the second instrument. The 519 live citations and 2 cross-repo citations are the same in both readings, and the drop of 11 citations is exactly the rewritten sites. A third, raw reading (every `#` followed by 2 to 6 digits, whatever surrounds it) finds 547 occurrences before and 536 after, the same drop of 11. The 13 tokens beyond the gate's grammar are the same before and after, and none is dead (see Acceptance notes). ## Per-number table Sites and files count every dead occurrence in scope at the base (comments and strings, tests included). `rewritten / left` counts the sites rewritten and the sites left. Each anchor was read in its message and diff, not only its subject. | number | sites / files | rewritten / left | anchor: what it decided | |---|---|---|---| | `objectstack-ai#13279` | 4/4 | 4/0 | `6a180e42d` (PR objectstack-ai#13475): `resolveAuthzContext` raises `AuthzStoreUnavailableError` (`SERVICE_UNAVAILABLE`, 503) when a permission-store read throws, instead of answering an outage as a caller with zero capabilities, and each production transport's fail-closed `catch` re-raises that brand. The settings plugin's `verifiedContextFromRequest` is one of them. Its message names `objectstack-ai#13279` 4 times and its diff 54 times; `git blame` puts `settings-service-plugin.ts:307` in it, and the other three lines were written by `ac9376a74` (PR objectstack-ai#16580), a descendant, which describes that re-raise. Stage 6's anchor, reused by the storage, datasource and analytics stages | | `objectstack-ai#10159` | 3/2 | 3/0 | `1ec36b730` (PR objectstack-ai#10251): a settings write issued before the engine is bound is refused with `SETTINGS_ENGINE_NOT_BOUND` (503). Its message states that every read in any state is unchanged, which is the "left reads open" all three lines describe. The message does not name `objectstack-ai#10159`, but its own diff does, once, in its changeset ("refused loudly instead of resolving successfully while nothing reaches `sys_setting` (objectstack-ai#10159)"), and `settings-prebind-read-warning.test.ts:17` already paired the two numbers. `git blame` puts the three lines in `a24b7fa4d` (PR objectstack-ai#11044), the later read-half fix, a descendant. New to the sweep | | `objectstack-ai#17062` | 3/2 | 2/1 | `50b6f17d4` (PR objectstack-ai#17071): adds the package-local route-ledger conformance guard beside the dogfood live-mount parity gate, and updates the ledger header that had said such a guard was deliberately omitted. Its message does not name `objectstack-ai#17062`; its diff does, on 3 added lines, which are the three sites here (`git blame` puts all three in it). New to the sweep | | `objectstack-ai#11318` | 3/1 | 2/1 | `99ccbb9c8` (PR objectstack-ai#11467): the Settings, AI "Test connection" fallback keeps its mount instruction on all three real-provider branches and gains the cloud-only boundary read from `PLATFORM_CAPABILITY_PROVIDERS.ai`. Its own changeset states that "the embedder hint at the fourth site is deliberately left alone ... and pinned by a contrast test", which is the fence `:339` describes. Its message's trailer names `objectstack-ai#11318` as the issue it answers, its diff names the number 3 times, and `git blame` puts all three lines in it. New to the sweep | Every cited sha matches exactly one commit (`git rev-parse --disambiguate`, count 1 for each of the 4), and all 4 are ancestors of the base (`merge-base --is-ancestor`, exit 0 for each; reverse leg, base against each anchor, exit 1 for each; control legs exit 0: stage 1's landing `422db788a`, and the repository's root commit, which lies deeper than every anchor; the history is complete, `--is-shallow-repository` false, 15,193 commits). Each of the 4 numbers answers 404 on the issues endpoint, which serves pull requests too, read one by one. No ADR, `scripts/adr-anchors/` file or other `docs/` page records the decision of any of the 4: `docs/adr` names none of the numbers, and none of their mechanisms (`AuthzStoreUnavailableError`, `SETTINGS_ENGINE_NOT_BOUND`, `engineBindPending`, the settings route ledger, the AI hint's edition boundary). ## Wordings to check - **Tag swaps in place.** 「[objectstack-ai#13279]」 became 「[commit 6a180e4]」 (`settings-service-plugin.ts:307`); 「(objectstack-ai#13279)」 became 「(commit 6a180e4)」 on 2 lines; 「objectstack-ai#13279's permission-store re-raise」 became 「commit 6a180e4's permission-store re-raise」. These are the forms the storage and datasource stages used for the same sha. - **`objectstack-ai#10159`.** 「is why objectstack-ai#10159's fix deliberately left reads open」 became 「is why commit 1ec36b7's write refusal deliberately left reads open」; 「(objectstack-ai#10159's fix left reads open on purpose)」 became 「(commit 1ec36b7 left reads open on purpose)」; 「(objectstack-ai#10159 / PR objectstack-ai#10251)」 became 「(commit 1ec36b7, PR objectstack-ai#10251)」, with the pull-request number kept as the convenience link beside its own squash commit. - **`objectstack-ai#17062`.** 「Two layers, since objectstack-ai#17062.」 became 「Two layers, since commit 50b6f17.」 The docblock goes on to describe the conformance test that commit added as the second layer. - **Two headers keep the antecedent of the prose below them**, the form stage 14 used: - `settings-route-ledger.conformance.test.ts:4`: 「Settings route-ledger conformance (objectstack-ai#17062)」 became 「Settings route-ledger conformance (the issue behind commit 50b6f17)」, because `:25` of the same docblock says 「(per the issue)」. - `manifests/ai.manifest.test.ts:277`: 「objectstack-ai#11318 —」 became 「The card behind commit 99ccbb9:」, because `:288` 「the very claim this card is about」 and `:329` 「The un-followable form this card retired」 speak of that card. - **`ai.manifest.test.ts:339`.** 「deliberately not edited — objectstack-ai#11318 fences this site out by name」 became 「... — commit 99ccbb9 fences this site out by name」. The commit's own changeset names that site (quoted in the table). ## The 2 sites left - **Test strings, 2 sites on 2 lines**, left as stages 1 to 14 left theirs: - `manifests/ai.manifest.test.ts:292`, a `describe` title (`objectstack-ai#11318`); - `settings-route-ledger.conformance.test.ts:88`, the assertion message a failing run prints (`objectstack-ai#17062`). It is a string, not a comment, and form C does not touch strings. - No operator log string, runtime refusal, quoted maintainer ruling or generated file in this package carries a dead number. - **Outside `src`, listed and left, not edited in this stage:** - the shipping `README.md` names only the live `objectstack-ai#8026`; - `vitest.config.ts` names only live numbers (`objectstack-ai#8020`, `objectstack-ai#8030`, `objectstack-ai#8063`, `objectstack-ai#8104`, `objectstack-ai#10374`); - `tsconfig.json` and `package.json` name none; - the release-owned `CHANGELOG.md` names `objectstack-ai#13279` and `objectstack-ai#10159` on 2 lines, the entries of `6a180e4` and `1ec36b7`, which are this PR's anchors. ## Mechanical guard: no code token moves The guard compares, base `73155fedc` against head, over all 9 touched `.ts` files: - **Reading 1**, the TypeScript parser's leaf nodes (a `forEachChild` walk, so comments are trivia and JSDoc nodes are never visited). String and template literals are therefore read in full. - **Reading 2**, the full token stream in parser context (a `getChildren` walk, so punctuation and keywords are included; JSDoc nodes skipped). Results: - Real run at the final head `ac05607d6`: 9,999 base leaf tokens, **0 files with a token change** on either reading (exit 0). The first commit `ff7ef46f4` gave the same, and no `.ts` path changed after it. - Comment control in `settings-service.ts` (「deliberately left reads open.」 to 「deliberately kept reads open.」): 0 files changed, as expected (exit 0). - Positive control, a code token renamed in `settings-service-plugin.ts` (`isAuthzStoreUnavailableError(err)` to `isAuthzStoreUnavailableErrorX(err)`): DIFFER on the identifier (exit 1). - Positive control, one digit changed inside the kept test title `ai.manifest.test.ts:292` (`objectstack-ai#11318` to `objectstack-ai#11319`): DIFFER on the string literal (exit 1). Every mutation went through `scripts/ablation-replace.mjs` (wrap mode) under a shell trap that restores by absolute path, and each landed (anchor 1 to 0, blob changed). Each restore was proven byte-identical to the HEAD blob (`1248149a428d`, `a2fac9ad1f0b`, `79c2b40a48a6`), with `git diff HEAD` empty and a clean tree afterwards. ## Changeset This change ships bytes, so a `patch` changeset for `@objectstack/service-settings` (`.changeset/20596-service-settings-provenance-anchors.md`) is included. Its body is stages 12 and 13's commit-anchor text, word for word, with the package name changed. Measured on the built package (A3), after a full workspace build in which this package was a cache miss (71 of 71 tasks, 0 cached, at `ff7ef46f4`, which holds every source-line change): `files[]` is `dist`, `README.md` and `CHANGELOG.md`, and the package is not private. - The rewritten `settings-service.ts:685` docblock, on the pre-bind read reporter, is in all four entries: `dist/index.js`, `dist/index.cjs`, `dist/index.d.ts` and `dist/index.d.cts` (once each). - The other three rewrites (`settings-route-ledger.ts:17`, `settings-routes.ts:72`, `settings-service-plugin.ts:307`) are stripped by the bundle, and the other seven sit in test files. - Positive controls, the unchanged line beside each rewrite, land exactly where their neighbours do: the line before `settings-service.ts:685` once in each of the four entries, and the neighbours of the three stripped rewrites 0 everywhere. - A never-written negative phrase appears nowhere in `dist`, and none of the four old numbers is left there. The later commit adds only the changeset. ## Gates (final head `ac05607d6`) - **Citation judging, as CI runs it:** `pnpm check:issue-citations` exits 0 (self-test, 114 cases, 8 batteries). `node scripts/check-issue-citations.mjs` exits 0: 「no issue citations added against 73155fe (4 file(s) read)」. - **Doc authoring:** `pnpm check:doc-authoring` exits 0 (the sibling-package prose-id baseline holds, no growth). - **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `ac05607d6` derived 63 commands, the same 63 as at dispatch. - Each ran with its exit code captured before any pipe, and all 63 exit 0; none exited 3. - `--ran`, fed each command with its exit code, reports 63 run, 0 NOT MEASURED (a derived zero), 0 unrun, and exits 0. - The full `turbo run build` above ran first under the shared verify lock, so no gate hit an unbuilt workspace. - **Roster families the derivation lists outside its commands** (their rosters sit in directories this diff touches): `node scripts/check-changeset-fixed.mjs`, `pnpm check:authz-resolver`, `pnpm check:error-code-casing` and `pnpm check:filter-alias-parity`, each exit 0. - **Tests and typecheck, under the verify lock, at `ac05607d6`:** - `pnpm --filter @objectstack/service-settings test`: 33 files pass and 584 tests pass, which is every tracked test file under `src/`, the 5 touched ones included. - `pnpm --filter @objectstack/service-settings typecheck` (`tsc --noEmit`) exits 0, and `tsc --listFiles` puts all 9 touched files in the program. - **Lint, as a proven narrowing:** eslint with inline config disabled, over the 9 touched `.ts` files, gives 9 files, 0 errors and 0 warnings (its `--format json` output). All 9 are in eslint's own population (none reported ignored; `dist/index.js`, the control, reads ignored). `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, as its own lines 327-328 state), so a comment edit here cannot move the verdict on any untouched file. The repo-wide `pnpm lint` is CI's run. - **Control bytes:** `pnpm check:nul-bytes` exits 0, and a raw scan of the 10 changed files for control bytes finds none. ## Acceptance notes - **The gate-invisible spellings, grepped as the claim asked** (objectstack-ai#20636, including the `clause #N` position). At the base, `#N-word` is on 0 lines. `#A/#B` is on 11 lines (12 second numbers), and every second number is live: `objectstack-ai#6580`, `objectstack-ai#5094`, `objectstack-ai#11230`, `objectstack-ai#5480`, `objectstack-ai#5932`, `objectstack-ai#6199` and `objectstack-ai#5204` by the census's own judgement, and `objectstack-ai#5133` read on its own as a pull request. `option #N`, `clause #N` and URL-spelled links are on 0 lines. So the claim's 0 / 11 / 0 / 0 hold, and nothing dead hides behind them. The one other raw token beyond the grammar is the colour literal `'#6366f1'` in `manifests/branding.manifest.ts:32`. - **「The card」 phrases.** 38 lines in 18 files under this package's `src` speak of 「the card」 or 「this card」. They carry no number, and neither instrument sees them. The ones whose antecedent this diff would have removed are handled above; the rest are unchanged, as in stages 8 to 14. - **The census instrument did not truncate in this stage.** Both enumerations read 187 pages at the newest frontier. - **Anchors the next stages can reuse**, each checked here: `objectstack-ai#10159` → `1ec36b730`; `objectstack-ai#17062` → `50b6f17d4`; `objectstack-ai#11318` → `99ccbb9c8`; and the reused `objectstack-ai#13279` → `6a180e42d`. - **Base.** The branch is on `main` at `73155fedc`. `main` has since moved two commits (`4b45afaed`, `15b586dcf`). Neither touches `packages/services/service-settings`, `scripts/check-issue-citations.mjs`, `.changeset/config.json` or a path in this diff. `15b586dcf` moves `packages/spec/liveness/**`, a gate input this comment-only diff cannot interact with. No merge was taken; the merge queue rebuilds on the merged generation. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #8030
The defect
A second
PUTof a new value for an encrypted setting key answered 200 with a correctly redacted body, advancedupdated_at, wrote an audit row and inserted a genuinely newsys_secretrow holding the new plaintext — and leftsys_setting.value_encpointing at the first handle. The effective secret never changed.Every signal an administrator can see said the rotation landed. Rotating a leaked SMTP password or provider API key looked exactly like a rotation that worked, while the leaked credential stayed the one in force. The first write of any secret was correct, so it was invisible until the second.
Root cause — the filer's analysis held, verified end-to-end
sys_setting.value_enc(andupdated_by) are declaredreadonly: trueinpackages/platform-objects/src/system/sys-setting.object.ts, and the engine strips author-declared read-only columns from a non-system caller's UPDATE payload (stripReadonlyFields, gated onif (!opCtx.context?.isSystem)inpackages/objectql/src/engine.ts).SettingsService.upsertRowpersisted through a plain, un-elevatedengine.update, so the handle could never be repointed. The INSERT path is deliberately exempt from that strip (#3413) — exactly why the first write landed and every later one did not.The engine's own warning names the fix:
The fix — service side only, no cross-lane change
upsertRowperforms its UPDATE as a system write.SettingsServiceis a privileged writer: the manifest capability gate (assertPermitted), the env-lock / upper-scope-lock pre-flight andvalidatePatchhave all run by then, and these are columns it owns rather than ones a caller forged. Same posture and same measured reason asObjectQL.recomputeSummaries' elevation (crud-permission-matrix: an allowed member create of showcase_task returns 500 while the row is written (summary recompute runs under caller context) #7673).SettingsEngine.updategains acontextmember, and theIDataEngineadapter forwards it on both branches. The settings row write takes themultione (itswhereis the composite(namespace, key, scope, user_id), never anid), so the by-id branch is the half that would rot unnoticed — it is pinned.value_encstaysreadonly: true. The elevation is scoped to this one write, so an external caller reachingsys_settingthrough the data layer still cannot repoint a secret handle. That flag is a security control; removing it would have been the wrong direction on this defect. There is a test that fails if someone removes it.Nothing in
packages/platform-objectsorpackages/objectqlis touched — the STOP fork on the card did not fire.Orphan
sys_secretrows — reaped, not acceptedThe row a rotated-away handle named is deleted once the repoint has committed. An orphan is a decryptable copy of exactly the credential the admin just retired, accumulating one per rotation (the filer measured 7 → 8 → 9 across three writes) — so the exposure grows with the hygiene we ask operators to practise. Nothing else can reference the handle: ids are minted per
encrypt()call,value_encis the only column that holds one, and the audit trail records digests rather than handles, so it stays readable after the ciphertext is gone.The store's
deleteis optional (pre-existing fakes and the legacy inline-crypto path simply have none) and the call is best-effort: the new secret is already in force by then, so a failed cleanup is reported loudly, never raised.Verification
Tests boot a real
ObjectQLover the realSysSetting/SysSecretschemas and drive the realSettingsServicethrough the real adapter — the four pieces the running server bolts together — so the verdict is about the engine's actual strip rule rather than about a fake written to match the fix.Reverse-verification, with only the fix's two behavioural lines removed and everything else identical:
expected 'sec_d261eae…' not to be 'sec_d261eae…'at the second write,sys_secret1 → 2The 6 that stayed green with the fix removed are the ones that should: the first-write path, the readonly-for-external-callers pin, and the mask-echo no-op.
service-settingssuite: 413 passed / 20 files (was 401 / 19)tsc --noEmitinservice-settings: clean (0 errors — the package graduated out of the DEBT ledger inservice-settingshas notypecheckscript — turbo silently no-ops it, and ~5 test files carry pre-existing type errors behind the unwired gate #7925 and stays out)check-test-source-alias: OK, registry unchanged —@objectstack/objectqland@objectstack/coreare aliased to source in a newvitest.config.tsrather than added toKNOWN_UNALIASED_TEST_IMPORTScheck:engine-double-contract: OK — 166 pinned, 133 ledgered, 2 exempt (unchanged)Not regressed
PUTcarrying••••••••still leaves the stored ciphertext byte-identical; the key is dropped from the patch before the service sees it, so no write happens at all. Pinned.409 SETTINGS_LOCKEDfor env-locked secrets — untouched; the write path's pre-flight runs before any of this.Generated by Claude Code