Skip to content

Commit c52bfb4

Browse files
feat(service-settings,platform-objects)!: the settings cascade's global rung moves to the tenant-less sys_platform_setting (ADR-0131 D7) (#22166)
Part of #15207 Clause-②: yes (widening: a new platform object `sys_platform_setting`, and a new name if `packages/spec` exports one; the global rung's storage narrows on `sys_setting`. The dev measures the built declaration closure.) The line above is the claim's (`6049595369`), copied verbatim. The measured arm is in the changeset: `Clause-②: yes (narrowing)` — the diff widens (a new object, `SysPlatformSetting` and `CONFIG_CHANGE_GLOBAL_OBJECT_NAME` exported, a new name in `PLATFORM_OBJECTS_BY_PACKAGE`) AND narrows (the `global` option of `sys_setting.scope` retires, and the global rung's storage leaves `sys_setting`). `@objectstack/spec`'s exported TYPE surface does not move: the name list is data, and the new ADR-0087 entry and rationale fragment live in the generated registry. ## Scope: item (3) only This PR builds scope item (3) of #15207: the `scope: 'global'` rung of `sys_setting` leaves the tenant-scoped object (ADR-0131 D7, §6 Q3) for a tenant-less `sys_platform_setting`, and the settings cascade reads the new source. Item (1) landed as PR #22107. Items (2) and (4) are not here, so #15207 remains open. No stored row moves in this PR: existing global rows move in the v18 operator ceremony (C7, #15211), per ADR-0131 D14. ## What changes - **`sys_platform_setting`** (`packages/platform-objects/src/system/sys-platform-setting.object.ts`), registered by the settings service beside `sys_setting`: - one row per `(namespace, key)` for the deployment: `unique: 'global'` on `(namespace, key)`; - the value and encryption columns of a `sys_setting` row: `value`, `value_enc` (read-only), `encrypted`, `locked`, `locked_reason`, `updated_by` (read-only); - no `scope`, no `user_id`, and no organization column (`systemFields: { tenant: false }`); - governed by object permission (D7): `requiredPermissions: ['manage_platform_settings']`; generic API `get` / `list` only. - **`SettingsService`** (`settings-service.ts`): - a write at a key declared `scope: 'global'` lands in `sys_platform_setting`, keyed `(namespace, key)`, never in `sys_setting`; - the cascade's global rung is read from `sys_platform_setting` alone. Every `sys_setting` read now names its rungs (`$or` over `tenant` / `user`, or `user_id` / `tenant`), so a `scope = 'global'` row a pre-v18 database still holds there is not a second source. There is no fallback read and no boot-time move (D14); - the rank table, the lock check, `SpecifierScope` and `source: 'global'` are unchanged; - the global rung does not depend on the user, so `getMany` reads it once per call: `sys_setting` keeps its #10826 bound (at most two reads), plus one `sys_platform_setting` read. - **`config_change` audit row**: a global-scope change now names `sys_platform_setting` (`CONFIG_CHANGE_GLOBAL_OBJECT_NAME`, exported beside `CONFIG_CHANGE_OBJECT_NAME`). A tenant- or user-scope change still names `sys_setting`. Without this, the row would name a table the value is not in. - **`sys_setting.scope`** no longer declares `global` (H6). `sys_setting_audit.scope` keeps it, because the audit writer records the changed key's scope and a global change is still a change. The translation bundles drop the retired leaf, and the es-ES echo ledger drops its row (47 to 46 echoes). - **`os secret orphans` / `os secret rewrap`** (`packages/cli/src/utils/secret-reference-union.ts`): the settings family of the `sys_secret` reference union reads BOTH holders, `sys_setting.value_enc` and `sys_platform_setting.value_enc`. If either cannot be read, the whole family gaps (H3). - **ADR-0087**: one D3 semantic entry, `sys-setting-global-rung-moved`, and one step-18 rationale fragment. The registry is regenerated. `spec-changes.json` and the upgrade guide do not move, because step 18 is not projected yet. - **Regenerated census artefacts**: - platform-object tenancy census: 83 to 84 registered objects, out of reach 33 to 34, `systemFields.tenant: false` 8 to 9; - tenant-audit census: two sites moved from unreadable options to readable, because the `as any` spread of `bypass` is gone (171 to 173 threading a tenant context); - query-options erasure baseline: `settings-service.ts` 2 to 1, a ratchet down. ## Readings (measured at `51290bca2c`, re-checked after merging `main`) **H1 holds: nothing moves to configuration.** A census of every registered manifest (the built `builtinSettingsManifests` plus objectql's `lifecycleSettingsManifest`) found 109 keys at the global rung in seven namespaces: `auth` 29, `ai` 35, `storage` 11, `mail` 10, `sms` 10, `knowledge` 10 and `lifecycle` 4. `lifecycle.retention_overrides` is the one tenant key in a global manifest. Every one of the seven requires `manage_platform_settings` to read and to write through the door, so every one is edited live in Setup. Their in-tree consumers re-read on change: - `plugin-auth` and `organizations` use `getNamespace('auth')`, and plugin-auth re-applies on `subscribe('auth')`; - `plugin-email` (`mail`), `service-sms` (`sms`) and `service-storage` (`storage`) each re-apply on `subscribe`; - `lifecycle` is read on every sweep; - `ai` and `knowledge` have no in-tree value reader beyond their `test` actions, which `runAction` resolves live. No key is boot-read only, so there is no `open_questions` entry for a configuration move. **No writer attributes a global row to an organization (the stop condition did not fire).** - `SettingsService.setMany` is the only writer of a settings row. It writes under `{ isSystem: true }`, with no `tenantId` and a row that names no organization. - `sys_setting` is `unclassified` in the platform-object tenancy inventory, so `resolveSystemInsertOrganization` derives nothing. - The new pin writes a global key with a writer context of `tenantId: 'org_1'`, and the stored row carries no organization. - Two organization signals do sit on the global WRITE PATH, but neither attributes the settings ROW: - the `CryptoContext` passed to `encrypt` carries `tenantId: ctx.tenantId` for every rung. The only in-tree provider (`LocalCryptoProvider`) binds no tenant into the AAD, and `materialiseRow` decrypts with no `tenantId` at all; - the `config_change` audit row stamps the writer's organization (item (2)'s object). **H2: global-rung readers outside the service.** Census of every non-test source naming `sys_setting`. The procedure fires: it flags the union and the orphan command. | reader | reads the global rung? | disposition | |---|---|---| | `cli/src/utils/secret-reference-union.ts` (settings collector) | yes: every row, no scope filter | **fixed here** (reads both holders) | | `cli/src/commands/secret/orphans.ts`, lines 300 to 309 (legacy-inline guard rows) | yes: every row | **outside the claim's surface, not edited** (see Acceptance notes) | | `cli/src/utils/sys-secret-orphan-sweep.ts` | no read: pure, consumes the rows `orphans.ts` hands it | none | | `core/src/security/resolve-authz-context.ts`, line 1696 | no: the direct read pins `scope: 'tenant'`; the service leg goes through `getMany` | none | | `metadata-protocol/src/migrations/sys-setting-identity-index.ts` | index maintenance over all rows, pre-v18 global rows included; it reads no value | none (see Acceptance notes) | | `mcp/src/plugin.ts`, `plugin-hono-server/src/current-user-endpoints.ts` | no: comments only; they read through `resolveLocalizationContext` | none | | `service-settings/src/sys-secret-orphan-report.ts` | no read: a pure classifier over caller-supplied rows, with no in-tree caller | none | **H3 holds, and is closed here.** Before the union fix, a credential held only in `sys_platform_setting.value_enc` is attributable (its `(namespace, key)` is a declared encrypted specifier) and unreferenced by `sys_setting`. That is exactly the deletable shape, so the sweep would delete the credential in force. Ablation B below turns the three pins red, including the sweep's `referenced` → deletable. **H4: the AAD binds no holder object and no organization.** `LocalCryptoProvider`'s version-2 AAD is the 0xFF lead byte, a version label, then `lp(scope) || lp(namespace) || lp(key)`. `scope` is the producer vocabulary (`settings`). It binds no object name, no organization and no tenant (`aadForVersion2`, `local-crypto-provider.ts`). So **C7's row move needs no re-encryption**: copy `value_enc` (the `sys_secret` handle) unchanged into the new row with the same `(namespace, key)`. The `sys_secret` row does not move. A pin seals a handle with an organization in the context, places it in a `sys_platform_setting` row, and resolves it. **H5: object permission holds, and the settings door is unaffected.** This was measured with a scratch harness, not committed: the real `SecurityPlugin` middleware, the registry-processed `sys_platform_setting` and the shipped permission sets, running a `find`. | posture | organization admin | platform admin | member | system context (the service) | |---|---|---|---|---| | none registered | 403 `PERMISSION_DENIED` | admitted, no filter | 403 | admitted | | `single` | 403 `PERMISSION_DENIED` | admitted, no filter | 403 | admitted | | `isolated` | 403 `PERMISSION_DENIED` | admitted, no filter | 403 | admitted | | control: same object without `requiredPermissions`, any posture | **admitted, no filter** | admitted | 403 | admitted | The control row is why the gate ships with the object: with no column there is no wall. The settings door is unchanged. `settings-admission-tenancy-posture.test.ts` and `config-change-audit.test.ts` drive the plugin's own routes over a real `ObjectQL` with a `scope: 'global'` manifest, and the row lands in `sys_platform_setting` (200). **H6: half holds.** After this PR no writer writes `scope: 'global'` into `sys_setting`, so its `global` option is retired, with the ADR-0087 entry. The `sys_setting_audit.scope` mirror is still written (the audit writer records `entry.scope`), so it stays. The parity pin now reads: `sys_setting.scope` = `SpecifierScopeSchema` minus `global`, and `sys_setting_audit.scope` = `SpecifierScopeSchema`. **Zone 3's suggested pin "a tenant and a user value still override it" is falsified by the unchanged rank table.** `scopeRank` gives `global` rank 1 and `resolveKeyFromRows` takes the first non-null rung, so a global value OUTRANKS the tenant and user rungs. It did before this PR and does after it. The pin asserts what the rulings keep: global from the new store outranks both, and the tenant and user rungs answer once it is empty. ## What C7 (#15211) must do with the rows (recorded, not built) - For every `sys_setting` row at `scope = 'global'`, write one `sys_platform_setting` row with the same `namespace` and `key`, and copy `value`, `value_enc`, `encrypted`, `locked`, `locked_reason` and `updated_by`. Then remove the source row. - `value_enc` is copied **verbatim**: re-encryption is neither needed nor wanted (H4). The `sys_secret` row stays where it is, and its handle id is unchanged. - A `(namespace, key)` with more than one global row is possible on a pre-#8629 database, because NULL-distinct unique let duplicates in. The ceremony has to pick one. `sys_platform_setting`'s `(namespace, key)` unique refuses the second. - Until the ceremony runs, a moved key answers from its next rung or the manifest default. The v18 boot refusal (D10) is what stops a deployment from running in that state. ## Tests All suite counts below were read at the merged head `d0477879af`, unless a line says otherwise. - `pnpm --filter @objectstack/service-settings exec vitest run`: 37 files, 638 tests passed. This includes the new `settings-global-rung.test.ts` (8 cases, a real `ObjectQL`) and the fixtures re-premised so global rows sit in `sys_platform_setting`. - `pnpm --filter @objectstack/platform-objects exec vitest run`: 65 files, 1036 tests passed. Before the echo-ledger fix, the run had 4 red, all in `objects-es-es-echo-decisions.test.ts`, for the retired leaf. - `pnpm --filter @objectstack/spec exec vitest run src/system src/migrations src/data/api-methods-batch-conformance.test.ts`: 55 files, 1980 tests passed. - `pnpm --filter @objectstack/cli exec vitest run --project integration` over the touched files (union, sweep, rewrap, `src/commands/secret`): 7 files, 91 tests passed. The `unit` layer: 263 files, 3874 tests passed, at the pre-merge head `1137107352`. - Typecheck green for `service-settings`, `platform-objects`, `spec` and `cli`, at `1137107352`. - **Ablations** were one-off, through `scripts/ablation-replace.mjs` in WRAP mode. Each anchor hit 1 → 0 and the blob changed. Each restore read blob == HEAD with `git diff HEAD` empty, and `git status --porcelain` read 0 lines after all four. The subjects are imported by relative source path, so no `dist/` is in the resolution path. - A, the null-user `sys_setting` read readmits `scope: 'global'`: red, `a global-scope key never consults sys_setting at all`. - A2, the user-keyed read readmits `scope: 'global'`: red, `a scope=global row still in sys_setting is NOT read`. - D, the global rung read from `sys_setting`: 7 of 8 red. - B, the union reads `sys_setting` alone: red, `names a handle held ONLY by sys_platform_setting.value_enc`, `an unreadable sys_platform_setting gaps the WHOLE settings family`, and the sweep's `REFERENCED, never deletable`. ## Gates - **Derivation.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `d0477879af` derived 125 families. All 125 were run, each exit code captured before any pipe, and all 125 exited 0. `--ran` reconciles 125 derived, 125 run, 0 NOT-MEASURED, 0 UNRUN. - **What the battery covers.** It includes the claim-time list. It adds `check:engine-double-contract`, `check:objectql-double-limit`, `check:where-matcher`, `check:i18n-coverage`, `check:i18n-walk-parity`, `check:type-check-coverage`, `check:type-check-debt` and `check:empty-changeset`, which the diff touches. - **The generated-artifact gates are green:** `check:api-surface` ("unchanged"), `check:migration-registry`, `check:spec-changes`, `check:upgrade-guide`, `check:platform-object-tenancy-census`, `check-tenant-audit-census`, `check:query-options-erasure`, `check:i18n` and `check:nul-bytes`. - **The merge.** `main` was merged twice, the second time through `scripts/pm/os-regen-merge.sh`, because PR #22103 also writes `packages/spec/src/migrations/registry.ts`. After `gen:migration-registry`, the regenerated registry is byte-identical to the merge, and it holds both this PR's entry and #22103's `declared-index-bare-unique-true-retired`. - **Lint, a proven narrowing.** `eslint --no-inline-config --format json` at `d0477879af` read 37 results, the 37 changed `.ts` files: 0 errors and 0 warnings, so none was ignored by the config. `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, per its own comment), so this diff cannot move the verdict on any untouched file. The full `pnpm lint` is CI's. ## Acceptance notes (observed, not changed here) - `cli/src/commands/secret/orphans.ts`, lines 300 to 309, builds the sweep's legacy-inline guard from `sys_setting` rows only. Today nothing writes legacy inline ciphertext into `sys_platform_setting`: the plugin always wires `LocalCryptoProvider` plus the `sys_secret` store. After C7 moves pre-Phase-3 global rows, though, an inline value could sit there. The guard is withhold-only, and the union, which decides deletion, already reads both holders. Natural carrier: C7 (#15211). - `metadata-protocol/src/migrations/sys-setting-identity-index.ts`: two operator texts on its degraded arms still say global-scope settings rows "can still be created" in `sys_setting`. No writer creates one after this PR. The text goes fully stale when C7 empties the layer. Carrier: C7 (#15211). - `packages/spec/src/system/settings-manifest.zod.ts`: the module TSDoc says values persist in `sys_setting`, and its resolution list was already missing the global rung. No `.describe()` names the store, so it is left untouched per the claim. - `SettingsService.setMany` passes `tenantId: ctx.tenantId` into the `CryptoContext` on every rung, while `materialiseRow` decrypts with none. No in-tree provider reads `tenantId`, so this pulls nothing today. A per-tenant-key KMS provider would seal and open under different keys on every rung. --- _Generated by [Claude Code](https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7b926f7 commit c52bfb4

42 files changed

Lines changed: 1338 additions & 246 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'@objectstack/platform-objects': minor
3+
'@objectstack/service-settings': minor
4+
'@objectstack/spec': minor
5+
'@objectstack/cli': minor
6+
---
7+
8+
feat(service-settings,platform-objects)!: the settings cascade's global rung moves to the tenant-less `sys_platform_setting`, and `sys_setting.scope` no longer declares `global` (ADR-0131 D7)
9+
10+
Clause-②: yes (narrowing)
11+
12+
<!-- adr-0087: registered sys-setting-global-rung-moved -->
13+
14+
**BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes (Changesets pre mode is not on yet).
15+
16+
A settings value for a key declared at `global` scope is a deployment-wide value: the mail transport, the SMS, storage, AI and knowledge providers, auth policy, the lifecycle retention defaults. It used to be a `scope: 'global'` row of `sys_setting`, a tenant-scoped table, where the injected `organization_id` column only ever held NULL and a walled posture hid the row from every reader. ADR-0131 D7 moves the rung out: it is now stored in a new platform object, `sys_platform_setting`.
17+
18+
- **`sys_platform_setting`** (registered by the settings service, beside `sys_setting`): one row per `(namespace, key)` for the deployment, with the `value`, `value_enc`, `encrypted`, `locked`, `locked_reason` and `updated_by` columns of a settings row, and no `scope`, `user_id` or `organization_id` (`systemFields: { tenant: false }`). It is governed by object permission: a generic data read needs `manage_platform_settings` (`requiredPermissions`), which platform administrators hold. Writes go only through the settings door.
19+
- **`SettingsService`** writes a global-scope key to `sys_platform_setting` and reads the cascade's global rung from there alone. Its `sys_setting` reads exclude `scope = 'global'`, so there is one source per rung. The cascade order, the lock semantics, `SpecifierScope` and the `source: 'global'` resolution value are unchanged. A global-scope change's `config_change` audit row names `sys_platform_setting` (`CONFIG_CHANGE_GLOBAL_OBJECT_NAME`); tenant- and user-scope changes still name `sys_setting`.
20+
- **`sys_setting.scope`** no longer declares the `global` option: no write produces such a row. `sys_setting_audit.scope` keeps it, because a global-scope change is still audited there.
21+
- **`os secret orphans` / `os secret rewrap`**: the settings family of the `sys_secret` reference union now reads both `sys_setting.value_enc` and `sys_platform_setting.value_enc`. Without that, every credential held at the global rung would read as unreferenced and be swept. An unreadable `sys_platform_setting` gaps the family, which refuses deletion.
22+
23+
**What moves for consumers.**
24+
25+
- **Existing databases — nothing moves automatically** (ADR-0131 D14). A `sys_setting` row at `scope = 'global'` is no longer read; until the v18 upgrade ceremony moves it, that key answers from its next rung or the manifest default. The ceremony moves each such row to `sys_platform_setting` by namespace and key, its `value_enc` handle included. An encrypted value needs no re-encryption: the ciphertext's associated data binds the settings scope, namespace and key, never the holding object or an organization.
26+
- **Authored references.** A filter, list-view column or seed that names `scope = 'global'` on `sys_setting` matches nothing: point it at `sys_platform_setting`, which has no `scope` column.
27+
- **Generic data reads of the global values.** Read `sys_platform_setting`; it needs `manage_platform_settings`. The settings door (`/api/settings/:namespace`) is unchanged and keeps applying each manifest's own read and write capability.
28+
- **Kernels that register the settings objects by hand.** Register `SysPlatformSetting` (`@objectstack/platform-objects/system`) beside `SysSetting`; `SettingsServicePlugin` already does. The service reads both on every resolution, so a kernel missing one fails the read loudly rather than answering a cascade with a rung missing.

‎content/docs/permissions/tenant-audit-census.mdx‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way.
122122

123123
The same holds twice over for the context. An options argument spelled as a
124124
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
125-
forwarding shim cannot, and **54 of the 234 sites are spelled that way**. A
125+
forwarding shim cannot, and **52 of the 234 sites are spelled that way**. A
126126
context resolved from an inline literal or a local `const` can be tested for
127127
`isSystem`; one arriving from a helper call cannot.
128128

@@ -150,8 +150,8 @@ now **0**: nothing on this surface threads a context that provably lacks the fla
150150

151151
**"No tenant context" counted sites it had not read.** An options argument the
152152
walker could not parse was folded into the same bucket as one it had read and
153-
found empty. That published **62 sites "carrying no tenant context at all"**
154-
when 8 said so and 54 were simply unread — an over-claim in the *alarming*
153+
found empty. That published **60 sites "carrying no tenant context at all"**
154+
when 8 said so and 52 were simply unread — an over-claim in the *alarming*
155155
direction, on the very figure this page tells other cards to cite. `carries` is
156156
now three-valued, and an unreadable argument can never contribute to the
157157
provable count.
@@ -228,10 +228,10 @@ cannot read, and they are neither in nor out.
228228
| …whose object name is chosen at run time | 80 |
229229
| …against an object with tenancy ENABLED | 153 |
230230
| …against an object that declares tenancy off | 1 |
231-
| threading a tenant context | 172 |
231+
| threading a tenant context | 174 |
232232
| PROVABLY carrying none (options read, no context key) | **8** |
233233
| …of those, against a decidably tenancy-enabled object | **2** |
234-
| options argument UNREADABLE — may or may not carry one | 54 |
234+
| options argument UNREADABLE — may or may not carry one | 52 |
235235
| …of those, against a decidably tenancy-enabled object | 31 |
236236
| threading a decidably ELEVATED (`isSystem`) context | 123 |
237237
| threading a context that is decidably NOT elevated | 0 |
@@ -297,13 +297,13 @@ holds still. They are required to be HERE and to say WHEN they were true;
297297
their values are not compared. The reasoning, and the measurement behind it,
298298
are in `scripts/check-tenant-audit-census.mjs`.
299299

300-
Measured on 2026-10-08 at `7f9500afd`.
300+
Measured on 2026-10-08 at `0328884e5`.
301301

302302
| corpus scale (not enforced) | count |
303303
| :--- | ---: |
304-
| tracked non-test sources scanned | 615 |
304+
| tracked non-test sources scanned | 617 |
305305
| engine-shaped types recognised | 70 |
306-
| declared objects in the registry | 116 |
306+
| declared objects in the registry | 117 |
307307
| same-named calls subtracted as non-engine | 160 |
308308

309309
{/* END GENERATED: tenant-audit-census */}

‎docs/audits/2026-08-tenant-audit-write-call-sites.counts.md‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,10 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.
3838
| Object name chosen at run time | 80 |
3939
| Against a tenancy-enabled object | 153 |
4040
| Against an object declaring tenancy off | 1 |
41-
| Threading a tenant context | 172 |
41+
| Threading a tenant context | 174 |
4242
| Provably carrying none | 8 |
4343
| …and decidably tenancy-enabled | 2 |
44-
| Options argument unreadable | 54 |
44+
| Options argument unreadable | 52 |
4545
| …and decidably tenancy-enabled | 31 |
4646
| Threading a decidably elevated context | 123 |
4747
| Threading a decidably non-elevated context | 0 |
@@ -90,13 +90,13 @@ holds still. They are required to be HERE and to say WHEN they were true;
9090
their values are not compared. The reasoning, and the measurement behind it,
9191
are in `scripts/check-tenant-audit-census.mjs`.
9292

93-
Measured on 2026-10-08 at `7f9500afd`.
93+
Measured on 2026-10-08 at `0328884e5`.
9494

9595
| corpus scale (not enforced) | count |
9696
| :--- | ---: |
97-
| tracked non-test sources scanned | 615 |
97+
| tracked non-test sources scanned | 617 |
9898
| engine-shaped types recognised | 70 |
99-
| declared objects in the registry | 116 |
99+
| declared objects in the registry | 117 |
100100
| same-named calls subtracted as non-engine | 160 |
101101

102102
## Every site
@@ -239,8 +239,8 @@ Measured on 2026-10-08 at `7f9500afd`.
239239
| `packages/services/service-settings/src/settings-service-plugin.ts` | `insert` | `sys_secret` | enabled | elevated | 1 |
240240
| `packages/services/service-settings/src/settings-service-plugin.ts` | `update` | `sys_secret` | enabled | elevated | 1 |
241241
| `packages/services/service-settings/src/settings-service-plugin.ts` | `insert` | `sys_setting_audit` | enabled | elevated | 1 |
242-
| `packages/services/service-settings/src/settings-service.ts` | `insert` | `this.objectName` | undecidable | options unreadable | 1 |
243-
| `packages/services/service-settings/src/settings-service.ts` | `update` | `this.objectName` | undecidable | options unreadable | 1 |
242+
| `packages/services/service-settings/src/settings-service.ts` | `insert` | `object` | undecidable | context, elevation undecidable | 1 |
243+
| `packages/services/service-settings/src/settings-service.ts` | `update` | `object` | undecidable | context, elevation undecidable | 1 |
244244
| `packages/services/service-storage/src/attachment-lifecycle.ts` | `update` | `sys_file` | enabled | elevated | 3 |
245245
| `packages/services/service-storage/src/backfill-file-references.ts` | `update` | `object` | undecidable | options unreadable | 1 |
246246
| `packages/services/service-storage/src/backfill-file-references.ts` | `update` | `object` | undecidable | elevated | 1 |

‎packages/cli/src/commands/secret/rewrap.guards.test.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,10 @@ function wireBoot(
108108
if (object === 'sys_setting') {
109109
return { async find() { harness.reads.push('sys_setting'); return rows.settings.map((r) => ({ ...r })); } };
110110
}
111+
// [ADR-0131 D7] The settings family's second holder: the global rung's store.
112+
if (object === 'sys_platform_setting') {
113+
return { async find() { harness.reads.push('sys_platform_setting'); return []; } };
114+
}
111115
if (object === 'sys_metadata') return { async find() { harness.reads.push('sys_metadata'); return []; } };
112116
return undefined;
113117
},
@@ -247,7 +251,7 @@ describe('os secret rewrap — guards that stop a run before any row is opened o
247251
}, 60_000);
248252

249253
it('a dry run over tables the boot measured absent reads none of them, and reports empty work', async () => {
250-
const h = wireBoot(freshRows(), { absent: ['sys_secret', 'sys_setting', 'sys_metadata'] });
254+
const h = wireBoot(freshRows(), { absent: ['sys_secret', 'sys_setting', 'sys_platform_setting', 'sys_metadata'] });
251255
const { payload, exitCode } = await run(['--no-declared-datasources']);
252256

253257
// "Not asked": a table that does not exist holds nothing, so no read is issued.

‎packages/cli/src/utils/secret-reference-union.test.ts‎

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,15 @@ const sysSettingObject = {
161161
),
162162
};
163163

164+
/** [ADR-0131 D7] The global rung's store: no `scope`, no `user_id`. */
165+
const sysPlatformSettingObject = {
166+
name: 'sys_platform_setting',
167+
label: 'Platform Setting',
168+
fields: Object.fromEntries(
169+
['id', 'namespace', 'key', 'value', 'value_enc'].map((f) => [f, textField(f)]),
170+
),
171+
};
172+
164173
const sysMetadataObject = {
165174
name: 'sys_metadata',
166175
label: 'Metadata',
@@ -205,7 +214,7 @@ async function buildRuntime() {
205214
// `packageId` is required by the built declaration this package resolves
206215
// (`registerObject(schema, packageId, …)`); the engine's own in-package tests
207216
// reach a source signature that defaults it.
208-
for (const object of [sysSecretObject, sysSettingObject, sysMetadataObject, smtpObject]) {
217+
for (const object of [sysSecretObject, sysSettingObject, sysPlatformSettingObject, sysMetadataObject, smtpObject]) {
209218
engine.registry.registerObject(object as never, TEST_PACKAGE_ID);
210219
}
211220

@@ -329,7 +338,7 @@ describe('the premise: the shipped settings-scoped classifier calls a LIVE crede
329338
});
330339
});
331340

332-
describe('family 1 — settings (`sys_setting.value_enc`)', () => {
341+
describe('family 1 — settings (`sys_setting.value_enc` and `sys_platform_setting.value_enc`)', () => {
333342
let rt: Runtime;
334343
beforeEach(async () => { rt = await buildRuntime(); });
335344

@@ -363,6 +372,41 @@ describe('family 1 — settings (`sys_setting.value_enc`)', () => {
363372
expect(union.complete).toBe(false);
364373
expect(union.gaps.map((g) => g.family)).toContain('settings');
365374
});
375+
376+
// [ADR-0131 D7] The global rung moved to `sys_platform_setting`, and the
377+
// deployment-wide values it holds are exactly the provider credentials (mail,
378+
// SMS, storage, AI). A union over `sys_setting` alone reads their handles as
379+
// unreferenced, and the sweep deletes the credential in force.
380+
it('names a handle held ONLY by sys_platform_setting.value_enc — the global rung', async () => {
381+
const handle = await rt.crypto.encrypt('relay-api-key', { scope: 'settings', namespace: 'smtp', key: 'password' });
382+
rt.store.seed('sys_secret', {
383+
id: handle.id, namespace: 'smtp', key: 'password', kms_key_id: handle.kmsKeyId,
384+
alg: handle.alg, version: handle.version, ciphertext: handle.ciphertext,
385+
});
386+
rt.store.seed('sys_platform_setting', { id: 'ps_1', namespace: 'smtp', key: 'password', value_enc: handle.id });
387+
// Anti-vacuity: no `sys_setting` row names it, so only the new holder can.
388+
expect(rt.store.rowsOf('sys_setting').some((r) => r.value_enc === handle.id)).toBe(false);
389+
390+
const union = await collect(rt);
391+
assertSecretReferenceUnionComplete(union);
392+
expect(union.handleIds.has(handle.id)).toBe(true);
393+
const refs = union.references.filter((r) => r.handleId === handle.id);
394+
expect(refs).toHaveLength(1);
395+
expect(refs[0].family).toBe('settings');
396+
expect(refs[0].holder).toBe('sys_platform_setting(namespace=smtp,key=password)');
397+
});
398+
399+
it('an unreadable sys_platform_setting gaps the WHOLE settings family', async () => {
400+
rt.store.failReadsOf('sys_platform_setting', new Error('relation does not exist'));
401+
const result = await collectSettingsSecretReferences(rt.engine);
402+
expect(result.status).toBe('gap');
403+
expect(result.status === 'gap' && result.reason).toContain('sys_platform_setting');
404+
expect(result.status === 'gap' && result.reason).toContain('relation does not exist');
405+
406+
const union = await collect(rt);
407+
expect(union.complete).toBe(false);
408+
expect(union.gaps.map((g) => g.family)).toContain('settings');
409+
});
366410
});
367411

368412
describe('family 2 — the engine secret-field channel (`secret:<id>` on a business row)', () => {

‎packages/cli/src/utils/secret-reference-union.ts‎

Lines changed: 59 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
* `id` field's own description):
99
*
1010
* 1. **settings** — `SettingsService` stores a bare `sec_…` handle in
11-
* `sys_setting.value_enc`;
11+
* `sys_setting.value_enc`, or `sys_platform_setting.value_enc` for a
12+
* global-scope key (ADR-0131 D7);
1213
* 2. **object-field** — the engine's `secret`-typed field channel stores
1314
* `secret:<id>` on an arbitrary business row, on every `secret` field of
1415
* every REGISTERED object, tenant-authored ones included;
@@ -333,55 +334,79 @@ const describeCause = (err: unknown): string =>
333334
err instanceof Error ? `${err.name}: ${err.message}` : String(err);
334335

335336
/**
336-
* Family 1 — handles held in `sys_setting.value_enc`.
337+
* The settings producer's holder objects: every store `SettingsService` keeps a
338+
* `value_enc` in. `sys_setting` holds the tenant and user rungs, and
339+
* `sys_platform_setting` the global rung (ADR-0131 D7) — the deployment-wide
340+
* values, which are exactly the ones that carry provider credentials (mail,
341+
* SMS, storage, AI, knowledge).
342+
*
343+
* ⛔ Both are read, always. A union over `sys_setting` alone reads every handle
344+
* a global-scope secret holds as unreferenced, and the sweep then deletes the
345+
* credential in force — with no record afterwards of which one it was.
346+
*/
347+
const SETTINGS_HOLDER_OBJECTS = ['sys_setting', 'sys_platform_setting'] as const;
348+
349+
/**
350+
* Family 1 — handles held in the settings stores' `value_enc`
351+
* ({@link SETTINGS_HOLDER_OBJECTS}).
337352
*
338353
* `value_enc` also carries LEGACY INLINE ciphertext on rows written before the
339354
* Phase-3 split, and such a row references no `sys_secret` row at all. The
340355
* discriminator is service-settings' own `isSecretHandle`, imported rather than
341356
* restated: treating inline ciphertext as a handle would inject a phantom id
342357
* into the union, and restating the `sec_` prefix is how the two spellings
343358
* would drift apart later.
359+
*
360+
* Either holder that cannot be read gaps the WHOLE family: a holder missing
361+
* from the union is a set of live handles the sweep would read as orphans.
344362
*/
345363
export async function collectSettingsSecretReferences(
346364
engine: SecretReferenceEngineLike,
347365
): Promise<FamilyResult> {
348366
const family: SecretReferenceFamily = 'settings';
349367
const references: SecretReference[] = [];
350368

351-
const driver = engine.getDriverForObject('sys_setting');
352-
if (!driver) {
353-
return {
354-
family,
355-
status: 'gap',
356-
reason:
357-
'no driver resolves for `sys_setting`, so the settings producer\'s holder column could '
358-
+ 'not be read (is the settings subsystem registered on this runtime?)',
359-
references,
360-
};
361-
}
369+
for (const holder of SETTINGS_HOLDER_OBJECTS) {
370+
const driver = engine.getDriverForObject(holder);
371+
if (!driver) {
372+
return {
373+
family,
374+
status: 'gap',
375+
reason:
376+
`no driver resolves for \`${holder}\`, so the settings producer's holder column could `
377+
+ 'not be read (is the settings subsystem registered on this runtime?)',
378+
references,
379+
};
380+
}
362381

363-
let result: unknown;
364-
try {
365-
result = await driver.find('sys_setting', { fields: ['namespace', 'key', 'scope', 'user_id', 'value_enc'] });
366-
} catch (err) {
367-
return {
368-
family,
369-
status: 'gap',
370-
reason: `reading \`sys_setting\` threw — ${describeCause(err)}`,
371-
references,
372-
};
373-
}
382+
// `sys_platform_setting` has no `scope` and no `user_id`: one row per
383+
// `(namespace, key)` for the deployment.
384+
const fields = holder === 'sys_setting'
385+
? ['namespace', 'key', 'scope', 'user_id', 'value_enc']
386+
: ['namespace', 'key', 'value_enc'];
387+
let result: unknown;
388+
try {
389+
result = await driver.find(holder, { fields });
390+
} catch (err) {
391+
return {
392+
family,
393+
status: 'gap',
394+
reason: `reading \`${holder}\` threw — ${describeCause(err)}`,
395+
references,
396+
};
397+
}
374398

375-
for (const row of rowsOf(result)) {
376-
const value = row.value_enc;
377-
if (!isSecretHandle(value)) continue; // unset, or legacy inline ciphertext
378-
references.push({
379-
handleId: value,
380-
family,
381-
holder: `sys_setting(namespace=${String(row.namespace)},key=${String(row.key)}`
382-
+ `${row.scope == null ? '' : `,scope=${String(row.scope)}`}`
383-
+ `${row.user_id == null ? '' : `,user_id=${String(row.user_id)}`})`,
384-
});
399+
for (const row of rowsOf(result)) {
400+
const value = row.value_enc;
401+
if (!isSecretHandle(value)) continue; // unset, or legacy inline ciphertext
402+
references.push({
403+
handleId: value,
404+
family,
405+
holder: `${holder}(namespace=${String(row.namespace)},key=${String(row.key)}`
406+
+ `${row.scope == null ? '' : `,scope=${String(row.scope)}`}`
407+
+ `${row.user_id == null ? '' : `,user_id=${String(row.user_id)}`})`,
408+
});
409+
}
385410
}
386411

387412
return { family, status: 'enumerated', references };

0 commit comments

Comments
 (0)