Repository navigation
Commit c52bfb4
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
File tree
- .changeset
- content/docs/permissions
- docs/audits
- packages
- cli/src
- commands/secret
- utils
- platform-objects/src
- apps/translations
- system
- services/service-settings/src
- spec/src
- migrations
- entries/semantic
- system/constants
- scripts
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
122 | 122 | | |
123 | 123 | | |
124 | 124 | | |
125 | | - | |
| 125 | + | |
126 | 126 | | |
127 | 127 | | |
128 | 128 | | |
| |||
150 | 150 | | |
151 | 151 | | |
152 | 152 | | |
153 | | - | |
154 | | - | |
| 153 | + | |
| 154 | + | |
155 | 155 | | |
156 | 156 | | |
157 | 157 | | |
| |||
228 | 228 | | |
229 | 229 | | |
230 | 230 | | |
231 | | - | |
| 231 | + | |
232 | 232 | | |
233 | 233 | | |
234 | | - | |
| 234 | + | |
235 | 235 | | |
236 | 236 | | |
237 | 237 | | |
| |||
297 | 297 | | |
298 | 298 | | |
299 | 299 | | |
300 | | - | |
| 300 | + | |
301 | 301 | | |
302 | 302 | | |
303 | 303 | | |
304 | | - | |
| 304 | + | |
305 | 305 | | |
306 | | - | |
| 306 | + | |
307 | 307 | | |
308 | 308 | | |
309 | 309 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
38 | 38 | | |
39 | 39 | | |
40 | 40 | | |
41 | | - | |
| 41 | + | |
42 | 42 | | |
43 | 43 | | |
44 | | - | |
| 44 | + | |
45 | 45 | | |
46 | 46 | | |
47 | 47 | | |
| |||
90 | 90 | | |
91 | 91 | | |
92 | 92 | | |
93 | | - | |
| 93 | + | |
94 | 94 | | |
95 | 95 | | |
96 | 96 | | |
97 | | - | |
| 97 | + | |
98 | 98 | | |
99 | | - | |
| 99 | + | |
100 | 100 | | |
101 | 101 | | |
102 | 102 | | |
| |||
239 | 239 | | |
240 | 240 | | |
241 | 241 | | |
242 | | - | |
243 | | - | |
| 242 | + | |
| 243 | + | |
244 | 244 | | |
245 | 245 | | |
246 | 246 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
108 | 108 | | |
109 | 109 | | |
110 | 110 | | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
111 | 115 | | |
112 | 116 | | |
113 | 117 | | |
| |||
247 | 251 | | |
248 | 252 | | |
249 | 253 | | |
250 | | - | |
| 254 | + | |
251 | 255 | | |
252 | 256 | | |
253 | 257 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
161 | 161 | | |
162 | 162 | | |
163 | 163 | | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
164 | 173 | | |
165 | 174 | | |
166 | 175 | | |
| |||
205 | 214 | | |
206 | 215 | | |
207 | 216 | | |
208 | | - | |
| 217 | + | |
209 | 218 | | |
210 | 219 | | |
211 | 220 | | |
| |||
329 | 338 | | |
330 | 339 | | |
331 | 340 | | |
332 | | - | |
| 341 | + | |
333 | 342 | | |
334 | 343 | | |
335 | 344 | | |
| |||
363 | 372 | | |
364 | 373 | | |
365 | 374 | | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
366 | 410 | | |
367 | 411 | | |
368 | 412 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
8 | 8 | | |
9 | 9 | | |
10 | 10 | | |
11 | | - | |
| 11 | + | |
| 12 | + | |
12 | 13 | | |
13 | 14 | | |
14 | 15 | | |
| |||
333 | 334 | | |
334 | 335 | | |
335 | 336 | | |
336 | | - | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
337 | 352 | | |
338 | 353 | | |
339 | 354 | | |
340 | 355 | | |
341 | 356 | | |
342 | 357 | | |
343 | 358 | | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
344 | 362 | | |
345 | 363 | | |
346 | 364 | | |
347 | 365 | | |
348 | 366 | | |
349 | 367 | | |
350 | 368 | | |
351 | | - | |
352 | | - | |
353 | | - | |
354 | | - | |
355 | | - | |
356 | | - | |
357 | | - | |
358 | | - | |
359 | | - | |
360 | | - | |
361 | | - | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
362 | 381 | | |
363 | | - | |
364 | | - | |
365 | | - | |
366 | | - | |
367 | | - | |
368 | | - | |
369 | | - | |
370 | | - | |
371 | | - | |
372 | | - | |
373 | | - | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
374 | 398 | | |
375 | | - | |
376 | | - | |
377 | | - | |
378 | | - | |
379 | | - | |
380 | | - | |
381 | | - | |
382 | | - | |
383 | | - | |
384 | | - | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
385 | 410 | | |
386 | 411 | | |
387 | 412 | | |
| |||
0 commit comments