Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/15207-settings-global-rung-platform-setting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'@objectstack/platform-objects': minor
'@objectstack/service-settings': minor
'@objectstack/spec': minor
'@objectstack/cli': minor
---

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)

Clause-②: yes (narrowing)

<!-- adr-0087: registered sys-setting-global-rung-moved -->

**BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes (Changesets pre mode is not on yet).

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

- **`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.
- **`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`.
- **`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.
- **`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.

**What moves for consumers.**

- **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.
- **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.
- **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.
- **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.
16 changes: 8 additions & 8 deletions content/docs/permissions/tenant-audit-census.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way.

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

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

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

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

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

{/* END GENERATED: tenant-audit-census */}
14 changes: 7 additions & 7 deletions docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.
| Object name chosen at run time | 80 |
| Against a tenancy-enabled object | 153 |
| Against an object declaring tenancy off | 1 |
| Threading a tenant context | 172 |
| Threading a tenant context | 174 |
| Provably carrying none | 8 |
| …and decidably tenancy-enabled | 2 |
| Options argument unreadable | 54 |
| Options argument unreadable | 52 |
| …and decidably tenancy-enabled | 31 |
| Threading a decidably elevated context | 123 |
| Threading a decidably non-elevated context | 0 |
Expand Down Expand Up @@ -90,13 +90,13 @@ holds still. They are required to be HERE and to say WHEN they were true;
their values are not compared. The reasoning, and the measurement behind it,
are in `scripts/check-tenant-audit-census.mjs`.

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

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

## Every site
Expand Down Expand Up @@ -239,8 +239,8 @@ Measured on 2026-10-08 at `7f9500afd`.
| `packages/services/service-settings/src/settings-service-plugin.ts` | `insert` | `sys_secret` | enabled | elevated | 1 |
| `packages/services/service-settings/src/settings-service-plugin.ts` | `update` | `sys_secret` | enabled | elevated | 1 |
| `packages/services/service-settings/src/settings-service-plugin.ts` | `insert` | `sys_setting_audit` | enabled | elevated | 1 |
| `packages/services/service-settings/src/settings-service.ts` | `insert` | `this.objectName` | undecidable | options unreadable | 1 |
| `packages/services/service-settings/src/settings-service.ts` | `update` | `this.objectName` | undecidable | options unreadable | 1 |
| `packages/services/service-settings/src/settings-service.ts` | `insert` | `object` | undecidable | context, elevation undecidable | 1 |
| `packages/services/service-settings/src/settings-service.ts` | `update` | `object` | undecidable | context, elevation undecidable | 1 |
| `packages/services/service-storage/src/attachment-lifecycle.ts` | `update` | `sys_file` | enabled | elevated | 3 |
| `packages/services/service-storage/src/backfill-file-references.ts` | `update` | `object` | undecidable | options unreadable | 1 |
| `packages/services/service-storage/src/backfill-file-references.ts` | `update` | `object` | undecidable | elevated | 1 |
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/src/commands/secret/rewrap.guards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ function wireBoot(
if (object === 'sys_setting') {
return { async find() { harness.reads.push('sys_setting'); return rows.settings.map((r) => ({ ...r })); } };
}
// [ADR-0131 D7] The settings family's second holder: the global rung's store.
if (object === 'sys_platform_setting') {
return { async find() { harness.reads.push('sys_platform_setting'); return []; } };
}
if (object === 'sys_metadata') return { async find() { harness.reads.push('sys_metadata'); return []; } };
return undefined;
},
Expand Down Expand Up @@ -247,7 +251,7 @@ describe('os secret rewrap — guards that stop a run before any row is opened o
}, 60_000);

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

// "Not asked": a table that does not exist holds nothing, so no read is issued.
Expand Down
48 changes: 46 additions & 2 deletions packages/cli/src/utils/secret-reference-union.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,15 @@ const sysSettingObject = {
),
};

/** [ADR-0131 D7] The global rung's store: no `scope`, no `user_id`. */
const sysPlatformSettingObject = {
name: 'sys_platform_setting',
label: 'Platform Setting',
fields: Object.fromEntries(
['id', 'namespace', 'key', 'value', 'value_enc'].map((f) => [f, textField(f)]),
),
};

const sysMetadataObject = {
name: 'sys_metadata',
label: 'Metadata',
Expand Down Expand Up @@ -205,7 +214,7 @@ async function buildRuntime() {
// `packageId` is required by the built declaration this package resolves
// (`registerObject(schema, packageId, …)`); the engine's own in-package tests
// reach a source signature that defaults it.
for (const object of [sysSecretObject, sysSettingObject, sysMetadataObject, smtpObject]) {
for (const object of [sysSecretObject, sysSettingObject, sysPlatformSettingObject, sysMetadataObject, smtpObject]) {
engine.registry.registerObject(object as never, TEST_PACKAGE_ID);
}

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

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

Expand Down Expand Up @@ -363,6 +372,41 @@ describe('family 1 — settings (`sys_setting.value_enc`)', () => {
expect(union.complete).toBe(false);
expect(union.gaps.map((g) => g.family)).toContain('settings');
});

// [ADR-0131 D7] The global rung moved to `sys_platform_setting`, and the
// deployment-wide values it holds are exactly the provider credentials (mail,
// SMS, storage, AI). A union over `sys_setting` alone reads their handles as
// unreferenced, and the sweep deletes the credential in force.
it('names a handle held ONLY by sys_platform_setting.value_enc — the global rung', async () => {
const handle = await rt.crypto.encrypt('relay-api-key', { scope: 'settings', namespace: 'smtp', key: 'password' });
rt.store.seed('sys_secret', {
id: handle.id, namespace: 'smtp', key: 'password', kms_key_id: handle.kmsKeyId,
alg: handle.alg, version: handle.version, ciphertext: handle.ciphertext,
});
rt.store.seed('sys_platform_setting', { id: 'ps_1', namespace: 'smtp', key: 'password', value_enc: handle.id });
// Anti-vacuity: no `sys_setting` row names it, so only the new holder can.
expect(rt.store.rowsOf('sys_setting').some((r) => r.value_enc === handle.id)).toBe(false);

const union = await collect(rt);
assertSecretReferenceUnionComplete(union);
expect(union.handleIds.has(handle.id)).toBe(true);
const refs = union.references.filter((r) => r.handleId === handle.id);
expect(refs).toHaveLength(1);
expect(refs[0].family).toBe('settings');
expect(refs[0].holder).toBe('sys_platform_setting(namespace=smtp,key=password)');
});

it('an unreadable sys_platform_setting gaps the WHOLE settings family', async () => {
rt.store.failReadsOf('sys_platform_setting', new Error('relation does not exist'));
const result = await collectSettingsSecretReferences(rt.engine);
expect(result.status).toBe('gap');
expect(result.status === 'gap' && result.reason).toContain('sys_platform_setting');
expect(result.status === 'gap' && result.reason).toContain('relation does not exist');

const union = await collect(rt);
expect(union.complete).toBe(false);
expect(union.gaps.map((g) => g.family)).toContain('settings');
});
});

describe('family 2 — the engine secret-field channel (`secret:<id>` on a business row)', () => {
Expand Down
93 changes: 59 additions & 34 deletions packages/cli/src/utils/secret-reference-union.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
* `id` field's own description):
*
* 1. **settings** — `SettingsService` stores a bare `sec_…` handle in
* `sys_setting.value_enc`;
* `sys_setting.value_enc`, or `sys_platform_setting.value_enc` for a
* global-scope key (ADR-0131 D7);
* 2. **object-field** — the engine's `secret`-typed field channel stores
* `secret:<id>` on an arbitrary business row, on every `secret` field of
* every REGISTERED object, tenant-authored ones included;
Expand Down Expand Up @@ -333,55 +334,79 @@ const describeCause = (err: unknown): string =>
err instanceof Error ? `${err.name}: ${err.message}` : String(err);

/**
* Family 1 — handles held in `sys_setting.value_enc`.
* The settings producer's holder objects: every store `SettingsService` keeps a
* `value_enc` in. `sys_setting` holds the tenant and user rungs, and
* `sys_platform_setting` the global rung (ADR-0131 D7) — the deployment-wide
* values, which are exactly the ones that carry provider credentials (mail,
* SMS, storage, AI, knowledge).
*
* ⛔ Both are read, always. A union over `sys_setting` alone reads every handle
* a global-scope secret holds as unreferenced, and the sweep then deletes the
* credential in force — with no record afterwards of which one it was.
*/
const SETTINGS_HOLDER_OBJECTS = ['sys_setting', 'sys_platform_setting'] as const;

/**
* Family 1 — handles held in the settings stores' `value_enc`
* ({@link SETTINGS_HOLDER_OBJECTS}).
*
* `value_enc` also carries LEGACY INLINE ciphertext on rows written before the
* Phase-3 split, and such a row references no `sys_secret` row at all. The
* discriminator is service-settings' own `isSecretHandle`, imported rather than
* restated: treating inline ciphertext as a handle would inject a phantom id
* into the union, and restating the `sec_` prefix is how the two spellings
* would drift apart later.
*
* Either holder that cannot be read gaps the WHOLE family: a holder missing
* from the union is a set of live handles the sweep would read as orphans.
*/
export async function collectSettingsSecretReferences(
engine: SecretReferenceEngineLike,
): Promise<FamilyResult> {
const family: SecretReferenceFamily = 'settings';
const references: SecretReference[] = [];

const driver = engine.getDriverForObject('sys_setting');
if (!driver) {
return {
family,
status: 'gap',
reason:
'no driver resolves for `sys_setting`, so the settings producer\'s holder column could '
+ 'not be read (is the settings subsystem registered on this runtime?)',
references,
};
}
for (const holder of SETTINGS_HOLDER_OBJECTS) {
const driver = engine.getDriverForObject(holder);
if (!driver) {
return {
family,
status: 'gap',
reason:
`no driver resolves for \`${holder}\`, so the settings producer's holder column could `
+ 'not be read (is the settings subsystem registered on this runtime?)',
references,
};
}

let result: unknown;
try {
result = await driver.find('sys_setting', { fields: ['namespace', 'key', 'scope', 'user_id', 'value_enc'] });
} catch (err) {
return {
family,
status: 'gap',
reason: `reading \`sys_setting\` threw — ${describeCause(err)}`,
references,
};
}
// `sys_platform_setting` has no `scope` and no `user_id`: one row per
// `(namespace, key)` for the deployment.
const fields = holder === 'sys_setting'
? ['namespace', 'key', 'scope', 'user_id', 'value_enc']
: ['namespace', 'key', 'value_enc'];
let result: unknown;
try {
result = await driver.find(holder, { fields });
} catch (err) {
return {
family,
status: 'gap',
reason: `reading \`${holder}\` threw — ${describeCause(err)}`,
references,
};
}

for (const row of rowsOf(result)) {
const value = row.value_enc;
if (!isSecretHandle(value)) continue; // unset, or legacy inline ciphertext
references.push({
handleId: value,
family,
holder: `sys_setting(namespace=${String(row.namespace)},key=${String(row.key)}`
+ `${row.scope == null ? '' : `,scope=${String(row.scope)}`}`
+ `${row.user_id == null ? '' : `,user_id=${String(row.user_id)}`})`,
});
for (const row of rowsOf(result)) {
const value = row.value_enc;
if (!isSecretHandle(value)) continue; // unset, or legacy inline ciphertext
references.push({
handleId: value,
family,
holder: `${holder}(namespace=${String(row.namespace)},key=${String(row.key)}`
+ `${row.scope == null ? '' : `,scope=${String(row.scope)}`}`
+ `${row.user_id == null ? '' : `,user_id=${String(row.user_id)}`})`,
});
}
}

return { family, status: 'enumerated', references };
Expand Down
Loading
Loading