From ea10257694d422f2c47ef774791654c563bd8102 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 12:55:29 +0000 Subject: [PATCH 01/11] feat(services): sys_secret, sys_flow_credential deployment-level; global-rung settings audit to a sibling (WIP) Claude-Session: https://claude.ai/code/session_01HZT9daB4YX41N1SVVxYcek Co-authored-by: Claude --- .../utils/organization-ownership-inventory.ts | 20 +- packages/platform-objects/src/system/index.ts | 2 + .../sys-platform-setting-audit.object.ts | 173 ++++++++++++++++++ .../src/system/sys-secret.object.ts | 36 ++++ .../src/sys-flow-credential.object.ts | 26 +++ .../services/service-settings/src/manifest.ts | 8 +- .../src/settings-read-door.ts | 10 +- .../src/settings-service-plugin.ts | 50 +++-- .../service-settings/src/settings-service.ts | 8 +- .../src/settings-service.types.ts | 17 +- .../system/constants/platform-object-names.ts | 1 + 11 files changed, 317 insertions(+), 34 deletions(-) create mode 100644 packages/platform-objects/src/system/sys-platform-setting-audit.object.ts diff --git a/packages/cli/src/utils/organization-ownership-inventory.ts b/packages/cli/src/utils/organization-ownership-inventory.ts index a3abc052ee7..785597ecd06 100644 --- a/packages/cli/src/utils/organization-ownership-inventory.ts +++ b/packages/cli/src/utils/organization-ownership-inventory.ts @@ -619,9 +619,25 @@ export const ORGANIZATION_OWNERSHIP_INVENTORY: readonly InventoryEntry[] = [ ], }, { object: 'sys_setting_audit', census: P1, fate: 'attribution', citation: `${D3_ORG_ROWS}; ${D10_SINGLE}`, anchors: [] }, - { object: 'sys_secret', census: P1, fate: 'attribution', citation: `re-verification 6018712820: sys_secret is tenant-attributed by one producer (moved here from C6); ${D10_SINGLE}`, anchors: [] }, + { + object: 'sys_platform_setting_audit', + census: PMAIN, + fate: 'column-drop', + citation: `${CENSUS_MAIN}: systemFields.tenant: false; ${D7}: the settings audit trail's global rung (ruling record 6257510973, Q4 A)`, + }, + { + object: 'sys_secret', + census: P1, + fate: 'column-drop', + citation: `${D7}: a secret is owned by the row that references it (ruling record 6257510973, Q4 A); the 18.x expand step keeps the column declared and unread until this drop`, + }, { object: 'sys_scim_connection_credential', census: P1, fate: 'attribution', citation: `${D3_ORG_ROWS}; ${D10_SINGLE}`, anchors: [] }, - { object: 'sys_flow_credential', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach, added since the first census); ${D10_SINGLE}`, anchors: [] }, + { + object: 'sys_flow_credential', + census: PMAIN, + fate: 'column-drop', + citation: `${D7}: a credential's ownership follows its flow, which is environment metadata (ruling record 6259141885, Q1 A); the 18.x expand step keeps the column declared and unread until this drop`, + }, // Inbound mail (`@objectstack/service-mailbox`): every row is written by the // system with the mailbox's organization stamped (ADR-0131 D9), so a NULL // row has no producer today; the parents below are where one would take it. diff --git a/packages/platform-objects/src/system/index.ts b/packages/platform-objects/src/system/index.ts index 81886729fe2..95839163f25 100644 --- a/packages/platform-objects/src/system/index.ts +++ b/packages/platform-objects/src/system/index.ts @@ -16,6 +16,8 @@ export { SysPlatformSetting } from './sys-platform-setting.object.js'; export { SysSecret } from './sys-secret.object.js'; export { SysMetadataActivation } from './sys-metadata-activation.object.js'; export { SysSettingAudit } from './sys-setting-audit.object.js'; +// [ADR-0131 D7] The settings audit trail's global rung, tenant-less. +export { SysPlatformSettingAudit } from './sys-platform-setting-audit.object.js'; export { SysMigration } from './sys-migration.object.js'; export { SysMigrationJournal } from './sys-migration-journal.object.js'; export { diff --git a/packages/platform-objects/src/system/sys-platform-setting-audit.object.ts b/packages/platform-objects/src/system/sys-platform-setting-audit.object.ts new file mode 100644 index 00000000000..65cbd7e9c48 --- /dev/null +++ b/packages/platform-objects/src/system/sys-platform-setting-audit.object.ts @@ -0,0 +1,173 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * sys_platform_setting_audit — the settings audit trail's GLOBAL rung + * (ADR-0131 D7, ruling record 6257510973 · Q4 A). + * + * The same departure `sys_setting`'s global rung made for `sys_platform_setting`: + * a write of a global-scope settings key is deployment-level, so its audit row + * names no organization and lives here, in a table with no organization column. + * Tenant and user rows stay in `sys_setting_audit`, each stamped with the + * organization whose value changed. + * + * Same row shape as `sys_setting_audit`, so one reader reads both tables the + * same way; `scope` is always `global` here. + * + * ## No organization column, governed by object permission + * + * `systemFields: { tenant: false }` — the one writer (`SettingsService`, through + * the settings audit writer) attributes no row to an organization. With no + * column there is no tenant wall, so who may read is object permission (D7): + * the same platform-only capability `sys_platform_setting` requires. A manifest + * whose keys sit at the global rung already requires it to read or write them + * through the settings door, so the door, the setting and its trail agree. + * + * ## The 18.0 rows + * + * An 18.0 install wrote its global-rung audit rows into `sys_setting_audit`, + * stamped with the Default Organization. They stay there until the upgrade + * ceremony moves them here; a reader of the global rung's history reads both. + * + * Append-only: the settings audit writer is the only writer, and the generic + * data API offers reads only. + * + * @namespace sys + */ +export const SysPlatformSettingAudit = ObjectSchema.create({ + name: 'sys_platform_setting_audit', + label: 'Platform Setting Audit Entry', + pluralLabel: 'Platform Setting Audit', + icon: 'history', + isSystem: true, + managedBy: 'engine-owned', + // [ADR-0131 D7] Deployment-level: NO tenant column, and reads are platform-only. + systemFields: { tenant: false }, + requiredPermissions: ['manage_platform_settings'], + description: 'Append-only audit trail for SettingsService mutations of global-scope keys.', + highlightFields: ['namespace', 'key', 'action', 'actor_id', 'created_at'], + listViews: { + recent: { + type: 'grid', + name: 'recent', + label: 'Recent', + columns: ['created_at', 'namespace', 'key', 'action', 'actor_id', 'source'], + sort: [{ field: 'created_at', order: 'desc' }], + }, + }, + + fields: { + id: Field.text({ + label: 'ID', + readonly: true, + }), + + created_at: Field.datetime({ + label: 'Created At', + readonly: true, + description: 'When the mutation was recorded.', + }), + + namespace: Field.text({ + label: 'Namespace', + required: true, + maxLength: 128, + }), + + key: Field.text({ + label: 'Key', + required: true, + maxLength: 128, + }), + + scope: Field.select( + [{ label: 'Global', value: 'global' }], + { + label: 'Scope', + required: true, + defaultValue: 'global', + description: 'Cascade layer the row was written to — always the global rung here.', + }, + ), + + action: Field.select( + [ + { label: 'Set', value: 'set' }, + { label: 'Reset', value: 'reset' }, + { label: 'Lock', value: 'lock' }, + { label: 'Unlock', value: 'unlock' }, + { label: 'Rotate', value: 'rotate' }, + ], + { + label: 'Action', + required: true, + description: 'Mutation kind.', + }, + ), + + actor_id: Field.lookup('sys_user', { + label: 'Actor', + description: 'User who performed the mutation; null for system jobs.', + }), + + source: Field.select( + [ + { label: 'UI', value: 'ui' }, + { label: 'API', value: 'api' }, + { label: 'Migration', value: 'migration' }, + { label: 'Import', value: 'import' }, + { label: 'System', value: 'system' }, + ], + { + label: 'Source', + required: true, + defaultValue: 'api', + description: 'Mutation entry-point.', + }, + ), + + reason: Field.text({ + label: 'Reason', + description: 'Free-text justification provided by the actor (optional).', + }), + + old_hash: Field.text({ + label: 'Old Hash', + readonly: true, + maxLength: 128, + description: 'Digest of the previous value. Never the plaintext. Null when previously unset.', + }), + + new_hash: Field.text({ + label: 'New Hash', + readonly: true, + maxLength: 128, + description: 'Digest of the new value. Never the plaintext. Null on reset.', + }), + + encrypted: Field.boolean({ + label: 'Encrypted', + defaultValue: false, + description: 'True when the field carries secret material (rotation is interesting).', + }), + + request_id: Field.text({ + label: 'Request ID', + maxLength: 128, + description: 'Correlates with sys_audit_log / tracing.', + }), + }, + + indexes: [ + { fields: ['namespace', 'created_at'], unique: false }, + { fields: ['actor_id', 'created_at'], unique: false }, + ], + + enable: { + trackHistory: false, + // [ADR-0103] Engine-owned: appended by the settings service (SYSTEM_CTX), + // never via the generic data API. Reads stay open for compliance review. + apiMethods: ['get', 'list'], + }, +}); diff --git a/packages/platform-objects/src/system/sys-secret.object.ts b/packages/platform-objects/src/system/sys-secret.object.ts index 6c099986490..cf6441fccf9 100644 --- a/packages/platform-objects/src/system/sys-secret.object.ts +++ b/packages/platform-objects/src/system/sys-secret.object.ts @@ -33,6 +33,22 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * engine fails CLOSED without this store, it is registered by * `PlatformObjectsPlugin` (platform infrastructure), not by service-settings. * + * ## Deployment-level (ADR-0131 D7, ruling record 6257510973 · Q4 A) + * + * A secret is owned by the row that references it — `sys_setting` / + * `sys_platform_setting`, the webhook, the flow credential, the field's record, + * the datasource — never by an organization of its own. Every read is by id, + * under privilege. So nothing stamps or scopes this table by organization. + * + * In the 18.x expand step the 18.0 `organization_id` column stays DECLARED + * (below), nullable and unread, and `tenancy: { enabled: false }` takes the + * object out of the tenancy machinery. Declaring the column is what keeps an + * 18.0 database booting: with `systemFields: { tenant: false }` and no + * declared column, the v18 boot guard reads the physical column an 18.0 + * install left here as the v17 shape and refuses the boot, and the drift report + * offers to drop it. The upgrade ceremony drops the column; then this object + * declares `systemFields: { tenant: false }` like the rest of D7's family. + * * @namespace sys */ export const SysSecret = ObjectSchema.create({ @@ -51,6 +67,9 @@ export const SysSecret = ObjectSchema.create({ // opt-in (`isSystem: true`), which the middleware short-circuits. A // principal-less read is refused (ADR-0096 D5). access: { default: 'private' }, + // [ADR-0131 D7] Deployment-level: no tenant scope, no stamp. See the header + // for why the 18.0 column is still declared below. + tenancy: { enabled: false }, description: 'Cipher store written by three privileged producers (see managedBy); each holds its handle in its own column. Never holds plaintext.', highlightFields: ['namespace', 'key', 'kms_key_id', 'version', 'rotated_at'], @@ -154,6 +173,23 @@ export const SysSecret = ObjectSchema.create({ description: 'Provider-encoded ciphertext blob (base64 / JSON). Implementation-defined; only the matching ICryptoProvider can read it.', }), + + /** + * The 18.0 organization column, kept until the upgrade ceremony drops it. + * No writer sets it and no reader reads it; a row an 18.0 install wrote + * still holds its organization here, and it opens by id like any other. + * Same physical shape as the column 18.0 injected (a 255-wide string), so + * the schema sync sees no drift on an 18.0 table. + */ + organization_id: Field.text({ + label: 'Organization (18.0, retired)', + required: false, + readonly: true, + hidden: true, + maxLength: 255, + description: + 'Retired: the organization an 18.0 install stamped on this row. Unread and never written; the upgrade ceremony drops the column.', + }), }, indexes: [ diff --git a/packages/services/service-automation/src/sys-flow-credential.object.ts b/packages/services/service-automation/src/sys-flow-credential.object.ts index daa0672b0b4..df0b13171f7 100644 --- a/packages/services/service-automation/src/sys-flow-credential.object.ts +++ b/packages/services/service-automation/src/sys-flow-credential.object.ts @@ -33,6 +33,16 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * Env-wide, like the engine's flow map, which keys flows by bare name: every * metadata row is env-wide (ADR-0131 D6), a flow's stored rows included. * + * Deployment-level (ADR-0131 D7, ruling record 6259141885 · Q1 A): a + * credential's ownership follows its flow, which is environment metadata, so + * nothing stamps or scopes this table by organization. In the 18.x expand step + * the 18.0 `organization_id` column stays DECLARED, nullable and unread, and + * `tenancy: { enabled: false }` takes the object out of the tenancy machinery. + * With `systemFields: { tenant: false }` and no declared column, the v18 boot + * guard would read the physical column an 18.0 install left here as the v17 + * shape and refuse the boot. The upgrade ceremony drops the column; then this + * object declares `systemFields: { tenant: false }`. + * * Writers: the automation plugin's credential channel (the metadata save door, * the publish promotion, a delete, the one-time migration), under a system * context. Readers: the same channel and the engine's verification and `http` @@ -51,6 +61,9 @@ export const SysFlowCredential = ObjectSchema.create({ // wildcard grant. Everything reads and writes it through the engine under a // system context. access: { default: 'private' }, + // [ADR-0131 D7] Deployment-level: no tenant scope, no stamp. See the header + // for why the 18.0 column is still declared below. + tenancy: { enabled: false }, description: "Write-only store of flow credentials (an inbound hook's secret, an http node's signing secret), one encrypted row per credential position of a flow in one lifecycle state. The flow definition keeps no copy.", displayNameField: 'flow_name', @@ -110,6 +123,19 @@ export const SysFlowCredential = ObjectSchema.create({ defaultValue: 'NOW()', readonly: true, }), + + // The 18.0 organization column, kept until the upgrade ceremony drops it: + // never written, never read. Same physical shape as the column 18.0 + // injected (a 255-wide string), so the schema sync sees no drift. + organization_id: Field.text({ + label: 'Organization (18.0, retired)', + required: false, + readonly: true, + hidden: true, + maxLength: 255, + description: + 'Retired: the organization an 18.0 install stamped on this row. Unread and never written; the upgrade ceremony drops the column.', + }), }, indexes: [ diff --git a/packages/services/service-settings/src/manifest.ts b/packages/services/service-settings/src/manifest.ts index 3842426f5cc..896d616c411 100644 --- a/packages/services/service-settings/src/manifest.ts +++ b/packages/services/service-settings/src/manifest.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { SysPlatformSetting, SysSetting, SysSettingAudit } from '@objectstack/platform-objects/system'; +import { SysPlatformSetting, SysPlatformSettingAudit, SysSetting, SysSettingAudit } from '@objectstack/platform-objects/system'; export const SETTINGS_PLUGIN_ID = 'com.objectstack.service.settings'; export const SETTINGS_PLUGIN_VERSION = '0.1.0'; @@ -15,6 +15,10 @@ export const SETTINGS_PLUGIN_VERSION = '0.1.0'; * the resolver reads both on every resolution: a kernel with one and not the * other would answer a cascade with a rung missing. * + * The audit trail splits the same way (ruling record 6257510973, Q4 A): + * `sys_platform_setting_audit` takes the global rung's rows, with no + * organization column, and `sys_setting_audit` keeps the tenant and user rows. + * * `sys_secret` is deliberately NOT here (#4270): its producers span domains * (encrypted settings here, the engine's `secret`-field encryption, the * datasource credential binder) and the engine fails closed when the store @@ -22,7 +26,7 @@ export const SETTINGS_PLUGIN_VERSION = '0.1.0'; * infrastructure, present with or without this service (cf. `sys_migration`, * #4243). This service remains a producer/consumer via its secret store. */ -export const settingsObjects: any[] = [SysSetting, SysPlatformSetting, SysSettingAudit]; +export const settingsObjects: any[] = [SysSetting, SysPlatformSetting, SysSettingAudit, SysPlatformSettingAudit]; /** Manifest header shared by compile-time config and runtime registration. */ export const settingsPluginManifestHeader = { diff --git a/packages/services/service-settings/src/settings-read-door.ts b/packages/services/service-settings/src/settings-read-door.ts index 224e9a95fe5..70ecaba7cfe 100644 --- a/packages/services/service-settings/src/settings-read-door.ts +++ b/packages/services/service-settings/src/settings-read-door.ts @@ -9,14 +9,15 @@ * The settings door (`GET /api/settings/:namespace`) refuses a caller who lacks * the manifest's `readPermission` (`SettingsService.assertPermitted`). The same * values, and their audit trail, are rows of `sys_setting`, - * `sys_setting_audit` and `sys_platform_setting`, and each of those objects - * exposes `get` / `list` on the generic data API as a diagnostic grid. That + * `sys_setting_audit`, `sys_platform_setting` and `sys_platform_setting_audit`, + * and each of those objects exposes `get` / `list` on the generic data API as a + * diagnostic grid. That * door applied the OBJECT's grants only — never the namespace's — so a * principal the settings door refused read the namespace's rows there instead. * * ## The seam * - * An engine middleware the settings plugin registers for exactly those three + * An engine middleware the settings plugin registers for exactly those four * objects. It ANDs a `namespace` predicate into the operation's `where` for * every READ (`find`, `findOne`, `count`, `aggregate`) — a filter, never a * pass over the result, so a count, an aggregate and a page see exactly the @@ -29,7 +30,7 @@ * * - a SYSTEM context — the settings service's own plumbing and every other * platform reader that elevates on purpose; - * - writes — the three objects expose no write on the data API, and the + * - writes — the four objects expose no write on the data API, and the * settings door owns every write. * * A context that is not system and carries no capability holds none, so it @@ -44,6 +45,7 @@ export const SETTINGS_READ_DOOR_OBJECTS: readonly string[] = Object.freeze([ 'sys_setting', 'sys_setting_audit', 'sys_platform_setting', + 'sys_platform_setting_audit', ]); /** The engine verbs that read rows — the four that carry a `where` to scope. */ diff --git a/packages/services/service-settings/src/settings-service-plugin.ts b/packages/services/service-settings/src/settings-service-plugin.ts index 54cfab51a24..5a10bb2f673 100644 --- a/packages/services/service-settings/src/settings-service-plugin.ts +++ b/packages/services/service-settings/src/settings-service-plugin.ts @@ -254,7 +254,8 @@ export class SettingsServicePlugin implements Plugin { if (!registerSettingsReadDoor(engine, this.service!)) { ctx.logger?.warn?.( 'SettingsServicePlugin: the objectql engine offers no middleware seam, so the data API ' + - 'read of sys_setting / sys_setting_audit / sys_platform_setting is NOT gated by each ' + + 'read of sys_setting / sys_setting_audit / sys_platform_setting / sys_platform_setting_audit ' + + 'is NOT gated by each ' + "settings namespace's readPermission — only by the objects' own grants.", ); } @@ -548,10 +549,35 @@ export class SettingsServicePlugin implements Plugin { } } +/** The settings audit trail's tenant and user rungs (organization NOT NULL). */ +export const SETTING_AUDIT_OBJECT = 'sys_setting_audit'; + +/** + * The settings audit trail's global rung, with no organization column + * (ADR-0131 D7, ruling record 6257510973 · Q4 A). + */ +export const PLATFORM_SETTING_AUDIT_OBJECT = 'sys_platform_setting_audit'; + +/** + * Which table an audit row of this cascade rung goes to: the global rung's to + * the deployment-level sibling, the tenant and user rungs' to + * `sys_setting_audit` — the split `sys_setting` / `sys_platform_setting` makes + * for the values themselves. + * + * An 18.0 install wrote global-rung rows into `sys_setting_audit`; they stay + * there until the upgrade ceremony moves them, so a reader of the global rung's + * history reads both tables. + */ +export function settingAuditObjectFor(scope: string): string { + return scope === 'global' ? PLATFORM_SETTING_AUDIT_OBJECT : SETTING_AUDIT_OBJECT; +} + /** - * Phase 3: append-only writer for `sys_setting_audit`. Failures here - * MUST NOT abort the settings write, so all calls are wrapped in a - * try/catch and reported through the plugin logger. + * Phase 3: append-only writer for the settings audit trail — `sys_setting_audit` + * for the tenant and user rungs, `sys_platform_setting_audit` for the global + * rung ({@link settingAuditObjectFor}). Failures here MUST NOT abort the + * settings write, so all calls are wrapped in a try/catch and reported through + * the plugin logger. * * [#8145] The settings-SHAPED half of the dual write. Its sibling — * {@link buildConfigChangeAuditSink} — records the same event on the @@ -588,16 +614,17 @@ export function buildSettingAuditWriter( // Under the opt-in below the engine no longer checks that `actor_id` // names a user, so the writer keeps that refusal; this `catch` reports // it exactly as it reported the engine's (see assertUserReferenceResolves). + const object = settingAuditObjectFor(entry.scope); await assertUserReferenceResolves( (id) => eng.findOne(USER_OBJECT, { where: { id }, fields: ['id'] }, { context: { isSystem: true } }), { - object: 'sys_setting_audit', + object, field: 'actor_id', - label: registeredLabel(eng, { object: 'sys_setting_audit', field: 'actor_id' }, 'Actor'), + label: registeredLabel(eng, { object, field: 'actor_id' }, 'Actor'), }, entry.actorId ?? null, ); - await eng.insert('sys_setting_audit', { + await eng.insert(object, { namespace: entry.namespace, key: entry.key, scope: entry.scope, @@ -611,10 +638,11 @@ export function buildSettingAuditWriter( reason: entry.reason ?? null, created_at: new Date().toISOString(), // [ADR-0131 D3/D9] The organization whose value changed — the - // settings row's own owner. A global value names none. - ...(entry.organizationId ? { organization_id: entry.organizationId } : {}), + // settings row's own owner. A global value names none, and its row + // goes to the sibling that has no organization column (D7). + ...(object === SETTING_AUDIT_OBJECT && entry.organizationId ? { organization_id: entry.organizationId } : {}), // The explicit system opt-in, as the settings row write carries: - // `sys_setting_audit` is the platform's own ledger, written after + // the settings audit is the platform's own ledger, written after // the settings door already authorized the change, and not a // write that may rely on a missing principal to pass the security // middleware's principal-less hand-off (ADR-0096 D5). @@ -630,7 +658,7 @@ export function buildSettingAuditWriter( reportedDurabilityLoss = true; report( cause + - ' — settings writes keep succeeding, but their sys_setting_audit history rows are NOT persisted ' + + ' — settings writes keep succeeding, but their settings audit history rows are NOT persisted ' + 'and nothing retries them. Fix the cause named here (the actor must be a sys_user; the row must ' + 'carry the organization its setting belongs to).', ); diff --git a/packages/services/service-settings/src/settings-service.ts b/packages/services/service-settings/src/settings-service.ts index e3a1e5290c8..4ab597bbc4e 100644 --- a/packages/services/service-settings/src/settings-service.ts +++ b/packages/services/service-settings/src/settings-service.ts @@ -2117,11 +2117,9 @@ export class SettingsService { alg: handle.alg, version: handle.version, ciphertext: handle.ciphertext, - // [ADR-0131 D3/D9] The ciphertext of a tenant or user value is - // that organization's, as its `sys_setting` row is. A global - // value's is deployment-level and names none (see - // `SettingsSecretStore.insert`). - ...(organizationId ? { organization_id: organizationId } : {}), + // [ADR-0131 D7, ruling record 6257510973 · Q4 A] No + // organization: the secret is deployment-level, owned by the + // settings row that holds its handle in `value_enc`. }); storedEnc = handle.id; digest = await this.secretAuditDigest(namespace, key, plain); diff --git a/packages/services/service-settings/src/settings-service.types.ts b/packages/services/service-settings/src/settings-service.types.ts index 92ed48d1c68..c094bc8015c 100644 --- a/packages/services/service-settings/src/settings-service.types.ts +++ b/packages/services/service-settings/src/settings-service.types.ts @@ -242,13 +242,8 @@ export interface SettingsSecretStore { alg: string; version: number; ciphertext: string; - /** - * [ADR-0131 D3/D9] The organization that owns the secret — the owning - * `sys_setting` row's, for a tenant or user value. Absent for a global - * value: a deployment-level secret, whose fate in this tenant-column - * store is not settled yet. - */ - organization_id?: string; + // [ADR-0131 D7, ruling record 6257510973 · Q4 A] No organization: a + // secret is deployment-level, owned by the row that holds its handle. }): Promise<{ id: string }>; /** Look up the latest ciphertext for a handle id; null when missing. */ get(id: string): Promise<{ @@ -285,8 +280,10 @@ export interface SettingsSecretStore { } /** - * Append-only writer for the `sys_setting_audit` object — Phase 3 - * audit trail. Distinct from {@link SettingsAuditSink} (which writes the + * Append-only writer for the settings audit trail — Phase 3. The tenant and + * user rungs' rows go to `sys_setting_audit`; the global rung's go to the + * deployment-level `sys_platform_setting_audit` (ADR-0131 D7), the way the + * values split between `sys_setting` and `sys_platform_setting`. Distinct from {@link SettingsAuditSink} (which writes the * generic `sys_audit_log` `config_change` row) so audit consumers can subscribe * to settings activity without scanning the firehose. * @@ -387,7 +384,7 @@ export interface SettingsServiceOptions { secretStore?: SettingsSecretStore; /** Audit sink. When undefined, writes still succeed but are not logged. */ audit?: SettingsAuditSink; - /** Phase 3 dedicated writer for `sys_setting_audit`. */ + /** Phase 3 dedicated writer for the settings audit trail. */ auditWriter?: SettingsAuditWriter; /** * `process.env`-like map. Defaults to `process.env`. Injected so diff --git a/packages/spec/src/system/constants/platform-object-names.ts b/packages/spec/src/system/constants/platform-object-names.ts index e22d4abc04c..51c3600dda5 100644 --- a/packages/spec/src/system/constants/platform-object-names.ts +++ b/packages/spec/src/system/constants/platform-object-names.ts @@ -72,6 +72,7 @@ export const PLATFORM_OBJECTS_BY_PACKAGE: Readonly Date: Mon, 19 Oct 2026 13:00:26 +0000 Subject: [PATCH 02/11] =?UTF-8?q?test(services):=20pin=20the=20expand=20sh?= =?UTF-8?q?ape=20=E2=80=94=20new=20writes=20unstamped,=2018.0-shaped=20row?= =?UTF-8?q?s=20still=20read=20(WIP)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01HZT9daB4YX41N1SVVxYcek Co-authored-by: Claude --- ...eployment-level-secret-audit-credential.md | 27 ++ .../deployment-level-no-tenant-column.test.ts | 60 +++- ...credential-deployment-level-expand.test.ts | 223 +++++++++++++++ .../service-settings/src/manifest.test.ts | 6 +- .../settings-deployment-level-expand.test.ts | 260 ++++++++++++++++++ .../src/settings-read-door.pin.test.ts | 10 +- .../src/settings-secret-organization.test.ts | 61 ++-- scripts/platform-object-tenancy-census.json | 35 ++- 8 files changed, 644 insertions(+), 38 deletions(-) create mode 100644 .changeset/24530-deployment-level-secret-audit-credential.md create mode 100644 packages/services/service-automation/src/flow-credential-deployment-level-expand.test.ts create mode 100644 packages/services/service-settings/src/settings-deployment-level-expand.test.ts diff --git a/.changeset/24530-deployment-level-secret-audit-credential.md b/.changeset/24530-deployment-level-secret-audit-credential.md new file mode 100644 index 00000000000..ab0520022a1 --- /dev/null +++ b/.changeset/24530-deployment-level-secret-audit-credential.md @@ -0,0 +1,27 @@ +--- +'@objectstack/platform-objects': minor +'@objectstack/service-settings': minor +'@objectstack/service-automation': minor +'@objectstack/spec': minor +'@objectstack/cli': minor +--- + +feat(services)!: `sys_secret` and `sys_flow_credential` are deployment-level, and the settings audit trail's global rung moves to the new `sys_platform_setting_audit` + +Clause-②: yes (narrowing) + + + +**BREAKING** for a reader of the settings audit trail, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D7; ruling records 6257510973, Q4 A, and 6259141885, Q1 A). This is the expand half of the change: it adds the new write shape and reads both shapes. It moves, deletes and re-stamps no row an 18.0 install wrote, and it drops no column. + +- **`sys_secret` is deployment-level.** + - **Before.** Under `single`, every `sys_secret` row was stamped with the Default Organization, and an encrypted tenant or user setting wrote its organization on the secret. + - **Now.** The object declares `tenancy: { enabled: false }`: nothing stamps, walls or scopes it by organization. A secret is owned by the row that holds its handle, and every read stays by id under privilege. The settings service no longer passes `organization_id` to its secret store, and `SettingsSecretStore.insert` no longer declares the property. + - **The 18.0 column stays.** `organization_id` is still declared, nullable, read-only and unread, with the shape 18.0 gave it (a 255-wide string, no longer a lookup), so an 18.0 database boots and its schema sync proposes no change. A row an 18.0 install stamped keeps its organization and opens by id as before. The upgrade ceremony drops the column. +- **`sys_flow_credential` is deployment-level**, in the same shape: `tenancy: { enabled: false }`, the 18.0 column kept declared and unread. A credential's ownership follows its flow, which is environment metadata. New credential rows, and the `sys_secret` rows their values mint, carry no organization. 18.0 rows keep theirs and resolve as before. +- **The settings audit trail's global rung moves to `sys_platform_setting_audit`.** + - **Before.** A write of a global-scope settings key appended its history row to `sys_setting_audit`, stamped with the Default Organization under `single`. Any principal the settings read door let read the namespace could list it there. + - **Now.** That row goes to the new `sys_platform_setting_audit`: the same row shape, `scope` always `global`, no organization column (`systemFields: { tenant: false }`). Reading it through the generic data API requires the `manage_platform_settings` capability, as `sys_platform_setting` does, and each namespace's `readPermission` applies there too. Tenant and user rows stay in `sys_setting_audit`, stamped as before. + - **18.0 rows.** Global-rung rows an 18.0 install wrote stay in `sys_setting_audit` until the upgrade ceremony moves them. The global rung's history is both tables. + +**The remedy.** If you list or filter the global rung's settings history (`sys_setting_audit` with `scope = global`), read `sys_platform_setting_audit` as well, with a principal that holds `manage_platform_settings`. Nothing to change for a reader of tenant or user rows, or for a reader of a secret or a flow credential by id. diff --git a/packages/platform-objects/src/deployment-level-no-tenant-column.test.ts b/packages/platform-objects/src/deployment-level-no-tenant-column.test.ts index 49ca728424e..9b944df68f4 100644 --- a/packages/platform-objects/src/deployment-level-no-tenant-column.test.ts +++ b/packages/platform-objects/src/deployment-level-no-tenant-column.test.ts @@ -4,7 +4,8 @@ import { describe, it, expect } from 'vitest'; import { resolveInjectedSystemColumns } from '@objectstack/spec/data'; import { PLATFORM_CAPABILITIES } from '@objectstack/spec/security'; import { SysJob, SysJobRun, SysJobQueue } from './audit/index.js'; -import { SysMigration, SysMigrationJournal, SysSecret } from './system/index.js'; +import { isTenancyDisabled } from '@objectstack/spec/data'; +import { SysMigration, SysMigrationJournal, SysPlatformSettingAudit, SysSecret, SysSettingAudit } from './system/index.js'; /** * [ADR-0131 D7] The deployment-level plumbing this package declares carries no @@ -16,9 +17,10 @@ import { SysMigration, SysMigrationJournal, SysSecret } from './system/index.js' * on `fields` alone is a phantom check: an object that merely omits the field * still gets the column. `resolveInjectedSystemColumns` is the derivation * `applySystemFields` consumes to do the injecting, so it is the authority on - * whether the column exists. The `sys_secret` control below is the same + * whether the column exists. The `sys_setting_audit` control below is the same * question asked of an object that DOES keep the column (its writer stamps the - * caller's organization), so the predicate is shown able to answer `true`. + * organization whose value changed), so the predicate is shown able to answer + * `true`. * * ## Why the capability is pinned beside the column * @@ -36,6 +38,8 @@ const DEPLOYMENT_LEVEL = [ ['sys_job_queue', SysJobQueue], ['sys_migration', SysMigration], ['sys_migration_journal', SysMigrationJournal], + // [ruling record 6257510973, Q4 A] The settings audit trail's global rung. + ['sys_platform_setting_audit', SysPlatformSettingAudit], ] as const; describe('[ADR-0131 D7] deployment-level plumbing: no tenant column, platform-only reads', () => { @@ -78,11 +82,53 @@ describe('[ADR-0131 D7] deployment-level plumbing: no tenant column, platform-on }); it('control: a tenant-attributed object keeps the column, so the predicate can answer true', () => { - // `sys_secret` is written with the business write's driver options, so the - // SQL driver stamps the caller's organization on it. It is NOT - // deployment-level today; its fate is decided by the C7 inventory. - const plan = resolveInjectedSystemColumns(SysSecret); + // `sys_setting_audit` keeps the tenant and user rungs' rows, each stamped + // with the organization whose value changed (D3); only its global rung left + // for `sys_platform_setting_audit` (ruling record 6257510973, Q4 A). + const plan = resolveInjectedSystemColumns(SysSettingAudit); expect(plan.tenant).toBe(true); expect([...plan.names]).toContain('organization_id'); }); }); + +/** + * [ADR-0131 D7 · ruling record 6257510973, Q4 A] `sys_secret` is + * deployment-level, in the 18.x line's EXPAND shape: out of the tenancy + * machinery, with the 18.0 organization column still declared, nullable and + * unread, until the upgrade ceremony drops it. + * + * Why not `systemFields: { tenant: false }` yet, as the D7 family above + * declares: an 18.0 database holds a physical `organization_id` on this table, + * and the v18 boot guard reads "an object that declares no organization column + * whose table still carries one" as the v17 shape — it refuses the boot. The + * declared column is what keeps an 18.0 database booting (R2) while nothing + * stamps or scopes by it. + */ +describe('[ADR-0131 D7] sys_secret: deployment-level, the 18.0 column kept declared until the ceremony', () => { + it('is out of the tenancy machinery: the declared opt-out, and no injected tenant column', () => { + expect(SysSecret.name).toBe('sys_secret'); + expect(isTenancyDisabled(SysSecret)).toBe(true); + expect(resolveInjectedSystemColumns(SysSecret).tenant).toBe(false); + // Per-object control: the same declaration without the opt-out is a + // tenant-column object again, so `true` above is the opt-out's doing. + const { tenancy: _optOut, ...withoutOptOut } = SysSecret as Record; + expect(resolveInjectedSystemColumns(withoutOptOut).tenant).toBe(true); + }); + + it('keeps the 18.0 column declared — nullable, read-only, the 255-wide string 18.0 provisioned', () => { + const column = (SysSecret.fields as Record).organization_id; + expect(column).toBeDefined(); + expect(column.type).toBe('text'); + expect(column.required ?? false).toBe(false); + expect(column.readonly).toBe(true); + expect(column.maxLength).toBe(255); + // Not the boot guard's v17 shape: that is `systemFields.tenant: false` with + // NO declared column, which would refuse an 18.0 database. + expect((SysSecret as { systemFields?: unknown }).systemFields).toBeUndefined(); + }); + + it('keeps its read door: private by default, reads only on the generic API', () => { + expect((SysSecret as { access?: unknown }).access).toEqual({ default: 'private' }); + expect((SysSecret as { enable?: { apiMethods?: unknown } }).enable?.apiMethods).toEqual(['get', 'list']); + }); +}); diff --git a/packages/services/service-automation/src/flow-credential-deployment-level-expand.test.ts b/packages/services/service-automation/src/flow-credential-deployment-level-expand.test.ts new file mode 100644 index 00000000000..695b4505dc4 --- /dev/null +++ b/packages/services/service-automation/src/flow-credential-deployment-level-expand.test.ts @@ -0,0 +1,223 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D7 · ruling record 6259141885, Q1 A] `sys_flow_credential` is + * deployment-level — the 18.x line's EXPAND step, on a real ObjectQL engine + * over SQLite. + * + * The rig runs the `single` posture with one organization, as an 18.0 install + * does, so the engine's ADR-0131 D9 stamp is live: the control shows it + * stamping an organization-less system insert into a tenant-column object. The + * same engine writes the channel's rows — and the `sys_secret` rows their + * `value` field mints — with no organization, so that absence is the objects' + * declarations, not a rig that stamps nothing. + * + * The dual read: an 18.0 install stamped both rows with the Default + * Organization. Rows of that shape, written below with the 18.0 columns, are + * still the channel's: indexed, resolved to their plaintext, and left as they + * are — re-stamping, clearing or moving them is the upgrade ceremony's. + * + * Every value is a probe sentinel, not a credential. + */ +import { afterEach, describe, expect, it } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { SysSecret } from '@objectstack/platform-objects'; +import type { CryptoContext, CryptoHandle, ICryptoProvider } from '@objectstack/spec/contracts'; + +import { FLOW_CREDENTIAL_OBJECT, FlowCredentialChannel } from './flow-credential-channel.js'; +import { SysFlowCredential } from './sys-flow-credential.object.js'; + +const DEFAULT_ORG = 'org_default'; +const HOOK = 'pin-expand-hook-41d7'; +const SIGN = 'pin-expand-sign-9b03'; +const LEGACY = 'pin-expand-legacy-c51e'; +const SYSTEM = { isSystem: true } as const; + +/** Reversible stand-in cipher — the seam's behaviour is the engine's, not the cipher's. */ +function fakeCrypto(): ICryptoProvider { + let n = 0; + return { + async encrypt(plain: string, _ctx: CryptoContext): Promise { + n += 1; + return { id: `sec_${n}`, kmsKeyId: 'test', alg: 'test-rev', version: 1, ciphertext: [...plain].reverse().join('') }; + }, + async decrypt(handle: CryptoHandle): Promise { + return [...handle.ciphertext].reverse().join(''); + }, + async rotateKey(handle: CryptoHandle): Promise { + return { ...handle, version: handle.version + 1 }; + }, + digest: (plain: string) => `d:${plain.length}`, + keyedDigest: async (plain: string) => `k:${plain.length}`, + }; +} + +/** The minimal organization object the engine's D9 probe asks for. */ +const ORG_OBJECT = { name: 'sys_organization', fields: { name: { type: 'text' } } } as never; +/** A platform object that keeps its injected organization column: the stamp's control. */ +const STAMPED_PROBE = { name: 'sys_probe_ledger', fields: { subject: { type: 'text' } } } as never; + +const engines: Array<{ ql: ObjectQL; driver: SqlDriver }> = []; +afterEach(async () => { + for (const { ql } of engines.splice(0)) { + try { await (ql as any).destroy?.(); } catch { /* noop */ } + } +}); + +/** + * The two tables as an 18.0 install created them on SQLite — its DDL, verbatim + * in shape: the injected `organization_id` a 255-wide string beside the audit + * columns, and the same indexes. + */ +const DDL_18_0 = [ + "CREATE TABLE `sys_secret` (`id` varchar(255), `created_at` datetime default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), " + + "`updated_at` datetime default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), `organization_id` varchar(255), " + + '`created_by` varchar(255), `updated_by` varchar(255), `rotated_at` datetime, `namespace` varchar(128), ' + + "`key` varchar(128), `kms_key_id` varchar(256), `alg` text default 'aes-256-gcm', `version` float default '1', " + + '`ciphertext` text, primary key (`id`))', + 'CREATE INDEX `idx_sys_secret_kms_key_id` on `sys_secret` (`kms_key_id`)', + 'CREATE INDEX `idx_sys_secret_namespace_key` on `sys_secret` (`namespace`, `key`)', + "CREATE TABLE `sys_flow_credential` (`id` varchar(255), `created_at` datetime default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), " + + "`updated_at` datetime default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), `organization_id` varchar(255), " + + '`created_by` varchar(255), `updated_by` varchar(255), `flow_name` varchar(255), `state` varchar(255), ' + + '`node_id` text, `credential_key` text, `position` varchar(64), `value` varchar(255), primary key (`id`))', + 'CREATE UNIQUE INDEX `uniq_sys_flow_credential_flow_name_state_position` on `sys_flow_credential` (`flow_name`, `state`, `position`)', +]; + +/** + * The real engine over an in-memory SQLite database. `from18` first creates + * the two tables with the 18.0 DDL and hands the 18.0 rows to `seed18`, so the + * engine's schema sync meets an 18.0 database, as an upgrade does. + */ +async function boot( + opts: { from18?: (knex: any) => Promise } = {}, +): Promise<{ ql: ObjectQL; driver: SqlDriver }> { + const ql = new ObjectQL(); + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + engines.push({ ql, driver }); + await driver.connect(); + if (opts.from18) { + const knex = (driver as any).knex; + for (const statement of DDL_18_0) await knex.raw(statement); + await opts.from18(knex); + } + ql.registerDriver(driver, true); + await ql.init(); + for (const o of [SysSecret, SysFlowCredential, ORG_OBJECT, STAMPED_PROBE]) { + (ql as any).registry.registerObject(o as never, 'test'); + } + await ql.syncSchemas(); + ql.setCryptoProvider(fakeCrypto()); + ql.setTenancyPostureProvider(() => 'single'); + await (driver as any).knex('sys_organization').insert({ id: DEFAULT_ORG, name: 'Default Organization' }); + return { ql, driver }; +} + +/** The physical rows, read past the engine — what the database holds. */ +async function physical(driver: SqlDriver, table: string): Promise { + return (driver as any).knex(table).select('*'); +} + +/** An inbound flow whose start node holds a hook secret and whose `http` node signs. */ +function inbound() { + return { + name: 'pin_expand', + label: 'Pin expand', + type: 'api', + nodes: [ + { id: 'begin', type: 'start', label: 'Start', config: { triggerType: 'api', hookId: 'h1', secret: HOOK } }, + { id: 'callout', type: 'http', label: 'Callout', config: { url: 'https://example.invalid/out', method: 'POST', signingSecret: SIGN } }, + { id: 'finish', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'begin', target: 'callout' }, + { id: 'e2', source: 'callout', target: 'finish' }, + ], + }; +} + +describe('[18.x expand · Q1 A] sys_flow_credential: the new write shape, under single with the Default Organization', () => { + it('control: the rig stamps an organization-less system insert into a tenant-column object', async () => { + const { ql, driver } = await boot(); + await ql.insert('sys_probe_ledger', { subject: 'probe' }, { context: SYSTEM } as never); + expect(await physical(driver, 'sys_probe_ledger')).toEqual([expect.objectContaining({ organization_id: DEFAULT_ORG })]); + }); + + it('a channel write stores its rows, and the secrets they mint, with no organization', async () => { + const { ql, driver } = await boot(); + const channel = new FlowCredentialChannel(() => ql as never); + await channel.store({ name: 'pin_expand', state: 'active', body: inbound() }); + + const rows = await physical(driver, FLOW_CREDENTIAL_OBJECT); + expect(rows).toHaveLength(2); + for (const row of rows) expect(row.organization_id).toBeNull(); + const secrets = await physical(driver, 'sys_secret'); + expect(secrets).toHaveLength(2); + for (const secret of secrets) expect(secret.organization_id).toBeNull(); + + // The write shape is still the channel's: both positions resolve. + await channel.loadIndex(); + expect(await channel.resolve('pin_expand', 'begin', 'secret')).toBe(HOOK); + expect(await channel.resolve('pin_expand', 'callout', 'signingSecret')).toBe(SIGN); + }); +}); + +describe('[18.x expand · Q1 A] the dual read, on an 18.0 database', () => { + /** The 18.0 rows: both stamped with the Default Organization, the credential holding a `secret:` ref. */ + const seed18 = async (knex: any) => { + await knex('sys_secret').insert({ + id: 'sec_18', organization_id: DEFAULT_ORG, namespace: FLOW_CREDENTIAL_OBJECT, key: 'value', + kms_key_id: 'test', alg: 'test-rev', version: 1, ciphertext: [...LEGACY].reverse().join(''), + }); + await knex(FLOW_CREDENTIAL_OBJECT).insert({ + id: 'cred_18', organization_id: DEFAULT_ORG, flow_name: 'pin_legacy', state: 'active', node_id: 'begin', + credential_key: 'secret', position: 'p'.repeat(64), value: 'secret:sec_18', + }); + }; + + it('the schema sync over the 18.0 tables proposes no drop, and keeps the organization column', async () => { + const { driver } = await boot({ from18: seed18 }); + for (const table of [FLOW_CREDENTIAL_OBJECT, 'sys_secret']) { + const columns = Object.keys(await (driver as any).knex(table).columnInfo()); + expect(columns, table).toContain('organization_id'); + } + const drift = ((await (driver as any).detectManagedDrift()) as any[]).filter( + (d) => [FLOW_CREDENTIAL_OBJECT, 'sys_secret'].includes(d.table), + ); + // No entry at all: not the destructive `drop_column` of the column the + // objects now leave unread, and no type change on it either. + expect(drift).toEqual([]); + }); + + it('an 18.0 credential and its secret are indexed, resolve, and keep their stamp', async () => { + const { ql, driver } = await boot({ from18: seed18 }); + const channel = new FlowCredentialChannel(() => ql as never); + expect(await channel.loadIndex()).toBe(true); + expect(channel.holds('pin_legacy', 'begin', 'secret')).toBe(true); + expect(await channel.resolve('pin_legacy', 'begin', 'secret')).toBe(LEGACY); + + // Read, not rewritten: the 18.0 stamps are the ceremony's to drop. + expect(await physical(driver, FLOW_CREDENTIAL_OBJECT)).toEqual([ + expect.objectContaining({ id: 'cred_18', organization_id: DEFAULT_ORG }), + ]); + expect(await physical(driver, 'sys_secret')).toEqual([ + expect.objectContaining({ id: 'sec_18', organization_id: DEFAULT_ORG }), + ]); + }); + + it('a new credential lands beside the 18.0 one, unstamped, and both resolve', async () => { + const { ql, driver } = await boot({ from18: seed18 }); + const channel = new FlowCredentialChannel(() => ql as never); + await channel.store({ name: 'pin_expand', state: 'active', body: inbound() }); + await channel.loadIndex(); + expect(await channel.resolve('pin_legacy', 'begin', 'secret')).toBe(LEGACY); + expect(await channel.resolve('pin_expand', 'begin', 'secret')).toBe(HOOK); + + const byId = new Map((await physical(driver, FLOW_CREDENTIAL_OBJECT)).map((r) => [r.id, r])); + expect(byId.get('cred_18')?.organization_id).toBe(DEFAULT_ORG); + const fresh = [...byId.values()].filter((r) => r.flow_name === 'pin_expand'); + expect(fresh).toHaveLength(2); + for (const row of fresh) expect(row.organization_id).toBeNull(); + }); +}); diff --git a/packages/services/service-settings/src/manifest.test.ts b/packages/services/service-settings/src/manifest.test.ts index ef2b40a6c60..4a191d53a51 100644 --- a/packages/services/service-settings/src/manifest.test.ts +++ b/packages/services/service-settings/src/manifest.test.ts @@ -4,14 +4,16 @@ import { describe, it, expect } from 'vitest'; import { settingsObjects } from './manifest.js'; describe('service-settings manifest objects (#4270)', () => { - it('owns exactly the K/V store, its global rung and its audit trail', () => { + it('owns exactly the K/V store, its global rung and its audit trail, split the same way', () => { // [ADR-0131 D7] `sys_platform_setting` is the cascade's global rung. It // registers WITH `sys_setting` because the resolver reads both on every - // resolution. + // resolution. The audit trail splits the same way (ruling record + // 6257510973, Q4 A): `sys_platform_setting_audit` is its global rung. expect(settingsObjects.map((o: any) => o.name)).toEqual([ 'sys_setting', 'sys_platform_setting', 'sys_setting_audit', + 'sys_platform_setting_audit', ]); }); diff --git a/packages/services/service-settings/src/settings-deployment-level-expand.test.ts b/packages/services/service-settings/src/settings-deployment-level-expand.test.ts new file mode 100644 index 00000000000..b2f95c02fcb --- /dev/null +++ b/packages/services/service-settings/src/settings-deployment-level-expand.test.ts @@ -0,0 +1,260 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D7 · ruling record 6257510973, Q4 A] The 18.x expand step for the + * settings producer: `sys_secret` is deployment-level, and the settings audit + * trail's global rung is `sys_platform_setting_audit`. + * + * Pinned over a real `ObjectQL` engine and the real platform objects, under the + * `single` posture with one organization — the posture an 18.0 install runs — + * so the engine's ADR-0131 D9 stamp is live in this rig. The control below + * shows it stamping an organization-less system insert into + * `sys_setting_audit`; the same engine leaves a `sys_secret` row unstamped, so + * the absence is the object's declaration, not a rig that stamps nothing. + * + * Two halves, as the 18.x line's expand rule asks: + * + * - **The new write shape.** A secret names no organization, at any rung. A + * global-rung audit row goes to `sys_platform_setting_audit`, with no + * organization; a tenant-rung row stays in `sys_setting_audit`, stamped. + * - **The dual read, on 18.0-shaped rows.** An 18.0 install stamped its + * secrets with the Default Organization and wrote its global-rung audit + * rows into `sys_setting_audit`. Those rows are read as they are: the + * stamped secret opens by id, and the 18.0 global audit row is still served + * beside the new one. Nothing re-stamps, clears or moves them — that is the + * upgrade ceremony's. + */ + +import { describe, it, expect } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { + SysPlatformSetting, + SysPlatformSettingAudit, + SysSecret, + SysSetting, + SysSettingAudit, +} from '@objectstack/platform-objects/system'; +import type { SettingsManifest } from '@objectstack/spec/system'; +import { SettingsService } from './settings-service.js'; +import { SettingsServicePlugin, buildSettingAuditWriter, wrapEngineAsSettingsEngine } from './settings-service-plugin.js'; +import { LocalCryptoProvider } from './local-crypto-provider.js'; + +const OWNER_PACKAGE = 'com.objectstack.test.settings-deployment-level-expand'; +const DEFAULT_ORG = 'org_default'; +const SYS = { context: { isSystem: true } } as const; + +const GLOBAL_MANIFEST: SettingsManifest = { + namespace: 'relay_probe', + version: 1, + label: 'Relay (global)', + scope: 'global', + readPermission: 'manage_platform_settings', + writePermission: 'manage_platform_settings', + specifiers: [{ type: 'password', key: 'api_key', label: 'API key', required: false, encrypted: true }], +}; + +const TENANT_MANIFEST: SettingsManifest = { + namespace: 'tenant_vault', + version: 1, + label: 'Tenant vault', + scope: 'tenant', + specifiers: [{ type: 'password', key: 'token', label: 'Token', required: false, encrypted: true }], +}; + +/** The minimal organization object the engine's D9 probe asks for. */ +const ORG_OBJECT = { name: 'sys_organization', fields: { name: { type: 'text' } } } as any; + +interface Write { + object: string; + data: Record; + options: Record | undefined; +} + +/** A Map-backed driver that also records each create, with the options the engine handed it. */ +function makeRecordingDriver() { + const store = new Map>>(); + const writes: Write[] = []; + let nextId = 0; + const copy = (r: Record) => ({ ...r }); + const rowsOf = (object: string) => { + let s = store.get(object); + if (!s) { s = new Map(); store.set(object, s); } + return s; + }; + const matches = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + return Object.entries(where).every(([k, v]) => { + if (k === '$or') return (v as any[]).some((b) => matches(row, b)); + if (k === '$and') return (v as any[]).every((b) => matches(row, b)); + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + return (row[k] ?? null) === (v ?? null); + }); + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {} as any, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast: any) { + const hits = [...rowsOf(object).values()].filter((r) => matches(r, ast?.where)); + const page = typeof ast?.limit === 'number' ? hits.slice(0, ast.limit) : hits; + return page.map(copy); + }, + async findOne(object: string, ast: any) { + for (const r of rowsOf(object).values()) if (matches(r, ast?.where)) return copy(r); + return null; + }, + async create(object: string, data: Record, options?: Record) { + writes.push({ object, data: { ...data }, options }); + nextId += 1; + const id = (data.id as string) ?? `row_${nextId}`; + // What a SQL driver persists: the engine's tenant stamp lands in the + // organization column when the record does not already carry one. + const tenantId = options?.tenantId; + const row = { ...data, id, ...(tenantId && data.organization_id === undefined ? { organization_id: tenantId } : {}) }; + rowsOf(object).set(id, row); + return copy(row); + }, + async update(object: string, id: string, data: Record) { + const s = rowsOf(object); + const cur = s.get(id); + if (!cur) return null; + const next = { ...cur, ...data, id }; + s.set(id, next); + return copy(next); + }, + async upsert(object: string, data: Record) { + const id = data.id as string | undefined; + return id && rowsOf(object).has(id) ? this.update(object, id, data) : this.create(object, data); + }, + async delete(object: string, id: string) { return rowsOf(object).delete(id); }, + async count(object: string, ast: any) { return (await this.find(object, ast)).length; }, + async bulkCreate(object: string, rows: Record[], options?: Record) { + return Promise.all(rows.map((r) => this.create(object, r, options))); + }, + async bulkUpdate() { return []; }, + async bulkDelete() {}, + async updateMany() { return 0; }, + async deleteMany() { return 0; }, + async syncSchema() {}, async dropTable() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, rowsOf, writes }; +} + +async function boot() { + const engine = new ObjectQL(); + const { driver, rowsOf, writes } = makeRecordingDriver(); + engine.registerDriver(driver, true); + await engine.init(); + for (const o of [SysSetting, SysPlatformSetting, SysSettingAudit, SysPlatformSettingAudit, SysSecret, ORG_OBJECT]) { + engine.registry.registerObject(o as any, OWNER_PACKAGE); + } + engine.setTenancyPostureProvider(() => 'single'); + rowsOf('sys_organization').set(DEFAULT_ORG, { id: DEFAULT_ORG, name: 'Default Organization' }); + + const cryptoProvider = new LocalCryptoProvider(); + const svc = new SettingsService({ + env: {}, + engine: wrapEngineAsSettingsEngine(engine as any), + cryptoProvider, + secretStore: (new SettingsServicePlugin() as any).buildSecretStore(engine), + auditWriter: buildSettingAuditWriter(engine as any, { error: (m) => { throw new Error(m); } }), + }); + svc.registerManifest(GLOBAL_MANIFEST); + svc.registerManifest(TENANT_MANIFEST); + const rows = (object: string) => [...rowsOf(object).values()]; + const seed = (object: string, row: Record) => rowsOf(object).set(String(row.id), { ...row }); + const writesTo = (object: string) => writes.filter((w) => w.object === object); + return { engine, svc, cryptoProvider, rows, seed, writesTo }; +} + +describe('[18.x expand · Q4 A] the new write shape, under single with the Default Organization', () => { + it('control: the rig stamps an organization-less system insert into a tenant-column object', async () => { + const { engine, rows } = await boot(); + await engine.insert('sys_setting_audit', { + namespace: 'n', key: 'k', scope: 'tenant', action: 'set', source: 'api', encrypted: false, + created_at: new Date().toISOString(), + }, SYS as any); + expect(rows('sys_setting_audit')).toEqual([expect.objectContaining({ organization_id: DEFAULT_ORG })]); + }); + + it('a tenant value: the secret names no organization; the audit row is stamped in sys_setting_audit', async () => { + const { svc, rows, writesTo } = await boot(); + await svc.setMany('tenant_vault', { token: 's3cret' }, { tenantId: DEFAULT_ORG, userId: undefined }); + + const [secretWrite] = writesTo('sys_secret'); + expect(secretWrite?.data).toMatchObject({ namespace: 'tenant_vault', key: 'token' }); + expect(secretWrite?.data).not.toHaveProperty('organization_id'); + expect(secretWrite?.options?.tenantId).toBeUndefined(); + expect(rows('sys_secret')).toHaveLength(1); + expect(rows('sys_secret')[0]!.organization_id ?? null).toBeNull(); + + expect(rows('sys_setting_audit')).toEqual([ + expect.objectContaining({ namespace: 'tenant_vault', key: 'token', scope: 'tenant', organization_id: DEFAULT_ORG }), + ]); + expect(rows('sys_platform_setting_audit')).toEqual([]); + }); + + it('a global value: the secret names no organization; the audit row goes to sys_platform_setting_audit, unstamped', async () => { + const { svc, rows, writesTo } = await boot(); + await svc.setMany('relay_probe', { api_key: 're_123' }, { tenantId: DEFAULT_ORG, userId: undefined }); + + expect(rows('sys_secret')).toHaveLength(1); + expect(rows('sys_secret')[0]!.organization_id ?? null).toBeNull(); + expect(writesTo('sys_secret')[0]?.options?.tenantId).toBeUndefined(); + + const platformAudit = rows('sys_platform_setting_audit'); + expect(platformAudit).toEqual([ + expect.objectContaining({ namespace: 'relay_probe', key: 'api_key', scope: 'global', action: 'set', encrypted: true }), + ]); + expect(platformAudit[0]!.organization_id ?? null).toBeNull(); + expect(writesTo('sys_platform_setting_audit')[0]?.options?.tenantId).toBeUndefined(); + expect(rows('sys_setting_audit')).toEqual([]); + }); +}); + +describe('[18.x expand · Q4 A] the dual read, on 18.0-shaped rows', () => { + it('an 18.0 secret stamped with the Default Organization opens by id, and keeps its stamp', async () => { + const { svc, cryptoProvider, seed, rows } = await boot(); + // The 18.0 shape: the ciphertext row carries the organization its + // `sys_setting` row did, and the setting holds the handle. + const handle = await cryptoProvider.encrypt('legacy-token', { + scope: 'settings', namespace: 'tenant_vault', key: 'token', tenantId: DEFAULT_ORG, + }); + seed('sys_secret', { + id: handle.id, organization_id: DEFAULT_ORG, namespace: 'tenant_vault', key: 'token', + kms_key_id: handle.kmsKeyId, alg: handle.alg, version: handle.version, ciphertext: handle.ciphertext, + }); + seed('sys_setting', { + id: 'set_18', organization_id: DEFAULT_ORG, namespace: 'tenant_vault', key: 'token', scope: 'tenant', + user_id: null, value: null, value_enc: handle.id, encrypted: true, locked: false, + }); + + const got = await svc.get('tenant_vault', 'token', { tenantId: DEFAULT_ORG, userId: undefined }); + expect(got.value).toBe('legacy-token'); + expect(got.source).toBe('tenant'); + // Read, not rewritten: the 18.0 stamp is the ceremony's to drop. + expect(rows('sys_secret')).toEqual([expect.objectContaining({ id: handle.id, organization_id: DEFAULT_ORG })]); + }); + + it('the global rung\'s history reads both tables: the 18.0 row in sys_setting_audit and the new one in the sibling', async () => { + const { engine, svc, seed } = await boot(); + // The 18.0 shape: a global-rung audit row in sys_setting_audit, stamped + // with the Default Organization by the 18.0 engine. + seed('sys_setting_audit', { + id: 'aud_18', organization_id: DEFAULT_ORG, namespace: 'relay_probe', key: 'api_key', scope: 'global', + action: 'set', source: 'api', actor_id: null, encrypted: true, new_hash: 'hmac-sha256:18', + created_at: '2026-10-19T00:00:00.000Z', + }); + await svc.setMany('relay_probe', { api_key: 're_456' }, { tenantId: DEFAULT_ORG, userId: undefined }); + + const where = { namespace: 'relay_probe', key: 'api_key', scope: 'global' }; + const legacy = (await engine.find('sys_setting_audit', { where }, SYS as any)) as any[]; + const current = (await engine.find('sys_platform_setting_audit', { where }, SYS as any)) as any[]; + expect(legacy).toEqual([expect.objectContaining({ id: 'aud_18', organization_id: DEFAULT_ORG, new_hash: 'hmac-sha256:18' })]); + expect(current).toHaveLength(1); + expect(current[0]!.organization_id ?? null).toBeNull(); + expect(current[0]!.new_hash).not.toBe('hmac-sha256:18'); + }); +}); diff --git a/packages/services/service-settings/src/settings-read-door.pin.test.ts b/packages/services/service-settings/src/settings-read-door.pin.test.ts index 328c919f91e..a0648d69505 100644 --- a/packages/services/service-settings/src/settings-read-door.pin.test.ts +++ b/packages/services/service-settings/src/settings-read-door.pin.test.ts @@ -17,7 +17,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; -import { SysPlatformSetting, SysSetting, SysSettingAudit } from '@objectstack/platform-objects/system'; +import { SysPlatformSetting, SysPlatformSettingAudit, SysSetting, SysSettingAudit } from '@objectstack/platform-objects/system'; import { SettingsService } from './settings-service.js'; import { SETTINGS_READ_DOOR_OBJECTS, registerSettingsReadDoor, settingsReadDoorMiddleware } from './settings-read-door.js'; @@ -102,7 +102,7 @@ beforeEach(async () => { const memory = makeMemoryDriver(); engine.registerDriver(memory.driver, true); await engine.init(); - for (const o of [SysSetting, SysSettingAudit, SysPlatformSetting]) { + for (const o of [SysSetting, SysSettingAudit, SysPlatformSetting, SysPlatformSettingAudit]) { engine.registry.registerObject(o as any, '@objectstack/platform-objects'); } const svc = new SettingsService({ env: {} }); @@ -128,6 +128,12 @@ beforeEach(async () => { namespace, key: 'k', value: 'v', value_enc: null, encrypted: false, locked: false, locked_reason: null, }, SYS); ids[namespace].sys_platform_setting = String((platform as any).id); + const platformAudit = await engine.insert('sys_platform_setting_audit', { + namespace, key: 'k', scope: 'global', action: 'set', source: 'api', actor_id: null, + old_hash: null, new_hash: 'h', encrypted: false, request_id: null, reason: null, + created_at: new Date().toISOString(), + }, SYS); + ids[namespace].sys_platform_setting_audit = String((platformAudit as any).id); } }); diff --git a/packages/services/service-settings/src/settings-secret-organization.test.ts b/packages/services/service-settings/src/settings-secret-organization.test.ts index 19e43841ebe..79d553a202f 100644 --- a/packages/services/service-settings/src/settings-secret-organization.test.ts +++ b/packages/services/service-settings/src/settings-secret-organization.test.ts @@ -1,13 +1,19 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [ADR-0131 D3/D9] The two rows a settings write lands BESIDE its `sys_setting` - * row — the `sys_secret` ciphertext of an encrypted value and the per-key - * `sys_setting_audit` history row — belong to the organization the setting - * row does: the caller's, for a `tenant` or `user` value. A `global` value is - * deployment-level (its row lives in `sys_platform_setting`, which has no - * organization), so neither row names one; whether such rows may stay in - * these two tenant-column objects is a question this file does not answer. + * The two rows a settings write lands BESIDE its settings row — the + * `sys_secret` ciphertext of an encrypted value and the per-key audit history + * row. + * + * - The ciphertext is deployment-level (ADR-0131 D7, ruling record + * 6257510973 · Q4 A): a secret is owned by the row that holds its handle, so + * it names no organization, whatever the setting's rung. + * - The history row belongs to the organization the setting row does: the + * caller's, for a `tenant` or `user` value (ADR-0131 D3/D9), in + * `sys_setting_audit`. A `global` value is deployment-level, so its row + * names none and goes to `sys_platform_setting_audit`, which has no + * organization column (Q4 A) — the split `sys_setting` / + * `sys_platform_setting` makes for the values. */ import { randomBytes } from 'node:crypto'; @@ -16,7 +22,12 @@ import type { SettingsManifest } from '@objectstack/spec/system'; import { SettingsService } from './settings-service.js'; import { LocalCryptoProvider } from './local-crypto-provider.js'; import { mailSettingsManifest } from './manifests/mail.manifest.js'; -import { buildSettingAuditWriter } from './settings-service-plugin.js'; +import { + buildSettingAuditWriter, + PLATFORM_SETTING_AUDIT_OBJECT, + SETTING_AUDIT_OBJECT, + settingAuditObjectFor, +} from './settings-service-plugin.js'; import type { SettingsSecretStore } from './settings-service.types.js'; /** A tenant-scoped namespace carrying one encrypted value. */ @@ -49,13 +60,14 @@ function boot() { return { svc, secrets, history }; } -describe('[ADR-0131 D3/D9] settings secret and history rows name their setting\'s organization', () => { - it('a tenant value: the ciphertext and the history row carry the caller\'s organization', async () => { +describe('[ADR-0131 D7/D9] a settings secret names no organization; its history row names the setting\'s', () => { + it('a tenant value: the ciphertext names no organization, the history row carries the caller\'s', async () => { const { svc, secrets, history } = boot(); await svc.setMany('tenant_vault', { token: 's3cret' }, { tenantId: 'org-1', userId: 'usr-1' }); expect(secrets).toHaveLength(1); - expect(secrets[0]).toMatchObject({ namespace: 'tenant_vault', key: 'token', organization_id: 'org-1' }); + expect(secrets[0]).toMatchObject({ namespace: 'tenant_vault', key: 'token' }); + expect('organization_id' in secrets[0]).toBe(false); expect(history.filter((e) => e.key === 'token')).toEqual([ expect.objectContaining({ scope: 'tenant', encrypted: true, organizationId: 'org-1' }), ]); @@ -77,13 +89,15 @@ describe('[ADR-0131 D3/D9] settings secret and history rows name their setting\' }); }); -describe('[ADR-0131 D3/D9] the sys_setting_audit writer stamps the entry\'s organization', () => { +describe('[ADR-0131 D3/D7] the settings audit writer: tenant rows stamped in sys_setting_audit, global rows to the sibling', () => { function engine() { const inserted: Array> = []; + const objects: string[] = []; return { inserted, + objects, async findOne() { return { id: 'usr-1' }; }, - async insert(_object: string, row: Record) { inserted.push({ ...row }); return row; }, + async insert(object: string, row: Record) { objects.push(object); inserted.push({ ...row }); return row; }, }; } const entry = { @@ -91,14 +105,29 @@ describe('[ADR-0131 D3/D9] the sys_setting_audit writer stamps the entry\'s orga actorId: 'usr-1', oldHash: null, newHash: 'hmac-sha256:x', encrypted: true, }; - it('writes organization_id when the entry names one, and no organization_id when it does not', async () => { + it('a tenant or user row goes to sys_setting_audit with its organization; a global row to sys_platform_setting_audit with none', async () => { const eng = engine(); const writer = buildSettingAuditWriter(eng as any, { error: (m) => { throw new Error(m); }, warn: (m) => { throw new Error(m); } }); await writer.write({ ...entry, organizationId: 'org-1' }); + await writer.write({ ...entry, scope: 'user', organizationId: 'org-1' }); await writer.write({ ...entry, scope: 'global' }); + // An entry that names an organization at the global rung still writes none: + // the sibling has no organization column to hold it. + await writer.write({ ...entry, scope: 'global', organizationId: 'org-1' }); + + expect(eng.objects).toEqual(['sys_setting_audit', 'sys_setting_audit', 'sys_platform_setting_audit', 'sys_platform_setting_audit']); + expect(eng.inserted[0]).toMatchObject({ key: 'token', scope: 'tenant', organization_id: 'org-1' }); + expect(eng.inserted[1]).toMatchObject({ key: 'token', scope: 'user', organization_id: 'org-1' }); + expect(eng.inserted[2]).toMatchObject({ key: 'token', scope: 'global' }); + expect('organization_id' in eng.inserted[2]).toBe(false); + expect('organization_id' in eng.inserted[3]).toBe(false); + }); - expect(eng.inserted[0]).toMatchObject({ key: 'token', organization_id: 'org-1' }); - expect('organization_id' in eng.inserted[1]).toBe(false); + it('routes by rung through one predicate: global to the sibling, tenant and user to sys_setting_audit', () => { + expect(settingAuditObjectFor('global')).toBe(PLATFORM_SETTING_AUDIT_OBJECT); + expect(settingAuditObjectFor('tenant')).toBe(SETTING_AUDIT_OBJECT); + expect(settingAuditObjectFor('user')).toBe(SETTING_AUDIT_OBJECT); + expect([PLATFORM_SETTING_AUDIT_OBJECT, SETTING_AUDIT_OBJECT]).toEqual(['sys_platform_setting_audit', 'sys_setting_audit']); }); it('reports a lost history row at error once with the consequence, and on a reduced sink through warn', async () => { diff --git a/scripts/platform-object-tenancy-census.json b/scripts/platform-object-tenancy-census.json index dd9392b32e4..3f3fb7cf4d4 100644 --- a/scripts/platform-object-tenancy-census.json +++ b/scripts/platform-object-tenancy-census.json @@ -24,14 +24,14 @@ "predicate": "resolveTenantFieldName(registered schema) !== null, where the registered schema is the authored schema plus the columns resolveInjectedSystemColumns says the registration injects (the applySystemFields pass). Both functions are loaded from source and executed; neither is re-spelled here.", "population": "every object registered by a tracked packages/**/*.object.ts module whose name carries a platform prefix (sys_ / cloud_ / ai_). The cloud repository's own cloud_ objects are not in this tree and so not in this census.", "totals": { - "registered": 83, - "inReach": 44, - "outOfReach": 39 + "registered": 84, + "inReach": 42, + "outOfReach": 42 }, "reasonTotals": { "managedBy: 'better-auth'": 25, - "systemFields.tenant: false": 14, - "tenancy.enabled: false": 2 + "systemFields.tenant: false": 15, + "tenancy.enabled: false": 4 }, "unexplained": [], "objects": [ @@ -187,9 +187,11 @@ { "name": "sys_flow_credential", "file": "packages/services/service-automation/src/sys-flow-credential.object.ts", - "reach": "in", - "tenantField": "organization_id", - "reasons": [] + "reach": "out", + "tenantField": null, + "reasons": [ + "tenancy.enabled: false" + ] }, { "name": "sys_flow_dispatch", @@ -482,6 +484,15 @@ "systemFields.tenant: false" ] }, + { + "name": "sys_platform_setting_audit", + "file": "packages/platform-objects/src/system/sys-platform-setting-audit.object.ts", + "reach": "out", + "tenantField": null, + "reasons": [ + "systemFields.tenant: false" + ] + }, { "name": "sys_presence", "file": "packages/services/service-realtime/src/objects/sys-presence.object.ts", @@ -571,9 +582,11 @@ { "name": "sys_secret", "file": "packages/platform-objects/src/system/sys-secret.object.ts", - "reach": "in", - "tenantField": "organization_id", - "reasons": [] + "reach": "out", + "tenantField": null, + "reasons": [ + "tenancy.enabled: false" + ] }, { "name": "sys_session", From 8162ab7be0aa7f18b6430f1bf21d021e952e12ff Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 13:12:07 +0000 Subject: [PATCH 03/11] test(services): settings and automation pins follow the global audit rung to its sibling; checklist re-pointed (WIP) Claude-Session: https://claude.ai/code/session_01HZT9daB4YX41N1SVVxYcek Co-authored-by: Claude --- ...eployment-level-secret-audit-credential.md | 2 +- .../areas/platform-core.json | 25 +- ...el-secret-audit-credential.dogfood.test.ts | 222 ++++++++++++++++++ .../src/config-change-audit.test.ts | 30 ++- .../src/settings-engine-bind-window.test.ts | 25 +- 5 files changed, 277 insertions(+), 27 deletions(-) create mode 100644 packages/qa/dogfood/test/deployment-level-secret-audit-credential.dogfood.test.ts diff --git a/.changeset/24530-deployment-level-secret-audit-credential.md b/.changeset/24530-deployment-level-secret-audit-credential.md index ab0520022a1..9eaf0b26e2d 100644 --- a/.changeset/24530-deployment-level-secret-audit-credential.md +++ b/.changeset/24530-deployment-level-secret-audit-credential.md @@ -16,7 +16,7 @@ Clause-②: yes (narrowing) - **`sys_secret` is deployment-level.** - **Before.** Under `single`, every `sys_secret` row was stamped with the Default Organization, and an encrypted tenant or user setting wrote its organization on the secret. - - **Now.** The object declares `tenancy: { enabled: false }`: nothing stamps, walls or scopes it by organization. A secret is owned by the row that holds its handle, and every read stays by id under privilege. The settings service no longer passes `organization_id` to its secret store, and `SettingsSecretStore.insert` no longer declares the property. + - **Now.** The object declares `tenancy: { enabled: false }`: nothing stamps, walls or scopes it by organization. A secret is owned by the row that holds its handle, and every read stays by id under privilege. The settings service no longer writes `organization_id` on a secret. - **The 18.0 column stays.** `organization_id` is still declared, nullable, read-only and unread, with the shape 18.0 gave it (a 255-wide string, no longer a lookup), so an 18.0 database boots and its schema sync proposes no change. A row an 18.0 install stamped keeps its organization and opens by id as before. The upgrade ceremony drops the column. - **`sys_flow_credential` is deployment-level**, in the same shape: `tenancy: { enabled: false }`, the 18.0 column kept declared and unread. A credential's ownership follows its flow, which is environment metadata. New credential rows, and the `sys_secret` rows their values mint, carry no organization. 18.0 rows keep theirs and resolve as before. - **The settings audit trail's global rung moves to `sys_platform_setting_audit`.** diff --git a/docs/qa/platform-checklist/areas/platform-core.json b/docs/qa/platform-checklist/areas/platform-core.json index 88de0c9c457..d24578474d9 100644 --- a/docs/qa/platform-checklist/areas/platform-core.json +++ b/docs/qa/platform-checklist/areas/platform-core.json @@ -633,7 +633,7 @@ "title": "Settings hub round-trip: a value saves, PERSISTS, reaches an observable consumer, audits, env-locks, tests honestly, and stores secrets as handles served MASKED — anonymous denied", "since": "v15", "status": "active", - "revision": 4, + "revision": 5, "priority": "P1", "surface": "mixed", "personas": [ @@ -680,7 +680,7 @@ "evidence": "the before/after consumer output" }, { - "clause": "every write appends a sys_setting_audit row (namespace/key/scope/action='set'/source='api'/actor_id/new_hash)", + "clause": "every write appends a per-key audit row (namespace/key/scope/action='set'/source='api'/actor_id/new_hash): in sys_setting_audit for a tenant- or user-scope key, in the deployment-level sys_platform_setting_audit for a global-scope key (ADR-0131 D7)", "oracle": "api", "verify": "GET /api/v1/data/sys_setting_audit shows the row for localization.timezone (SettingsServicePlugin.buildAuditWriter)", "evidence": "the audit row" @@ -784,7 +784,8 @@ { "revision": 1, "date": "2026-08-08", "change": "initial — settings hub round-trip with persistence + observable-consumer effect (honoring the ai.json form-is-not-effect caveat), sys_setting_audit, env-lock server refusal, honest test action, sys_secret handle-not-plaintext, anonymous deny", "ref": "claude/platform-test-checklist-ocwugl" }, { "revision": 2, "date": "2026-08-11", "change": "secret clauses strengthened from negative-only ('never returns the plaintext', which omission and a vanished key also satisfy) to the POSITIVE contract #7522/PR #7554 pinned at the route level: set→mask / unset→null presence-preserving, masking across values..value AND every cascadeChain entry, source/locked/lockedReason unchanged, both specifier flavours, plus the distinct echoed-mask-PUT-is-a-no-op round-trip clause and the in-process-path-is-NOT-masked guard", "ref": "#7573" }, { "revision": 3, "date": "2026-08-18", "change": "corrected the open-framework namespace count from 10 to 11 in the fixture list, step 0 and the per-namespace clause. builtinSettingsManifests holds 10, but lifecycle is registered separately by ObjectQLPlugin at kernel:ready (packages/objectql/src/lifecycle/lifecycle-settings.ts, 5 specifiers) and is served alongside them — so a runner enumerating only the manifests directory undercounts, and the clause could never be satisfied as written (#9453 CF-7)", "ref": "#9386" }, - { "revision": 4, "date": "2026-10-16", "change": "steps[1], [2], [3], [5] and [12] and acceptance[7] (clause, verify) and [11] (clause, verify, evidence) re-pointed (checklist-accuracy findings of QA run #23939, wave #23878). Four premises had moved: the `os dev --seed-admin` dev seed writes the host zone into localization.timezone at tenant scope (#23175), so the source is 'tenant' before any PUT and the badge flip needs a key the seed did not write (#23939 used default_country); the analytics date bucket has no stock observable, so the effect step names the two consumers #23939 used (auth/me/localization and a rendered datetime); global-scope secrets live in sys_platform_setting.value_enc, not sys_setting; and an anonymous caller meets the ADR-0056 D2 floor first, 401 UNAUTHENTICATED on both routes, not 403 SETTINGS_FORBIDDEN. acceptance[0].clause, [1].clause and [3].verify follow the same premises: the source reads the scope (on a dev-seeded boot it never read 'default', so there is no flip to see), the effect clause names the two stock consumers instead of the analytics bucket, and the badge is read on a key whose source was 'default' before the save. Not loosened: anonymous must still be refused on both routes with no namespace enumerated, and a secret must still never be plaintext", "ref": "#24036" } + { "revision": 4, "date": "2026-10-16", "change": "steps[1], [2], [3], [5] and [12] and acceptance[7] (clause, verify) and [11] (clause, verify, evidence) re-pointed (checklist-accuracy findings of QA run #23939, wave #23878). Four premises had moved: the `os dev --seed-admin` dev seed writes the host zone into localization.timezone at tenant scope (#23175), so the source is 'tenant' before any PUT and the badge flip needs a key the seed did not write (#23939 used default_country); the analytics date bucket has no stock observable, so the effect step names the two consumers #23939 used (auth/me/localization and a rendered datetime); global-scope secrets live in sys_platform_setting.value_enc, not sys_setting; and an anonymous caller meets the ADR-0056 D2 floor first, 401 UNAUTHENTICATED on both routes, not 403 SETTINGS_FORBIDDEN. acceptance[0].clause, [1].clause and [3].verify follow the same premises: the source reads the scope (on a dev-seeded boot it never read 'default', so there is no flip to see), the effect clause names the two stock consumers instead of the analytics bucket, and the badge is read on a key whose source was 'default' before the save. Not loosened: anonymous must still be refused on both routes with no namespace enumerated, and a secret must still never be plaintext", "ref": "#24036" }, + { "revision": 5, "date": "2026-10-19", "change": "acceptance[2].clause re-pointed: the settings audit trail's global rung moved to the deployment-level sys_platform_setting_audit (ADR-0131 D7, ruling record 6257510973 Q4 A), so 'every write appends a sys_setting_audit row' stopped being true for a global-scope key. The clause now names both tables by scope; its verify reads localization.timezone, a tenant-scope key, whose row is still in sys_setting_audit. Not loosened: every write still appends exactly one per-key row", "ref": "#24530" } ] }, { @@ -2869,7 +2870,7 @@ "title": "The settings audit trail keeps no fingerprint of a secret-valued setting that a reader can match offline: both ledgers record the platform's keyed digest or none, never the value or an unkeyed hash, so a caller who can only read the audit rows learns that the secret changed and nothing about its value", "since": "v17", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "api", "personas": [ @@ -2881,7 +2882,7 @@ "requires": [ "the settings service wired with a crypto provider and the sys_secret store, which is the stock wiring: SettingsServicePlugin binds both on kernel:ready (the same requirement platform-core.settings-hub-roundtrip names)", "an encrypted settings specifier to write: mail.api_key (type 'password', encrypted: true, mail.manifest.ts), written with provider 'resend' so the manifest's validator accepts the patch", - "plugin-audit mounted, so the generic ledger (sys_audit_log) exists. The per-key ledger (sys_setting_audit) is the settings service's own and is always there" + "plugin-audit mounted, so the generic ledger (sys_audit_log) exists. The per-key ledger is the settings service's own and is always there: sys_platform_setting_audit for a global-scope key, sys_setting_audit for a tenant- or user-scope key" ], "knownGaps": [ "the no-keyed-digest arm is not reachable on a stock boot. A host that builds the settings service with only a CryptoAdapter, or a crypto provider that refuses a keyed digest, records the write with NO fingerprint (sys_setting_audit.new_hash null, the bare encrypted marker in sys_audit_log) and warns once per key, and the write itself still lands. Score that arm from the unit pin (automated.ref); ⛔ never build a custom host to reach it", @@ -2890,7 +2891,7 @@ }, "steps": [ "generate a fresh random value V1 for this run (never a value used anywhere else). As admin, PUT /api/settings/mail { provider: 'resend', api_key: V1, from_email: 'ops@example.com' }", - "read the per-key ledger: GET /api/v1/data/sys_setting_audit filtered to namespace=mail and key=api_key; capture the newest row (action set, encrypted true, new_hash)", + "read the per-key ledger: GET /api/v1/data/sys_platform_setting_audit filtered to namespace=mail and key=api_key; capture the newest row (action set, encrypted true, new_hash). mail is a GLOBAL-scope namespace, and the per-key ledger of the global rung is the deployment-level sys_platform_setting_audit (ADR-0131 D7, packages/services/service-settings/src/settings-service-plugin.ts#settingAuditObjectFor); a tenant- or user-scope key's row lands in sys_setting_audit", "read the generic ledger: GET /api/v1/data/sys_audit_log filtered to action=config_change; capture the newest row for mail.api_key (object_name 'sys_platform_setting' — mail is a GLOBAL-scope namespace, and a global-scope value's row names the tenant-less table it lives in, packages/services/service-settings/src/config-change-audit.ts#CONFIG_CHANGE_GLOBAL_OBJECT_NAME; a tenant-scope key's row names 'sys_setting'; new_value is a JSON string carrying namespace, key, scope and digest; metadata carries encrypted: true)", "OFFLINE, with no server key: compute the unkeyed digests a leak would carry — the FNV-1a 32 of V1's canonical JSON (the quoted string), spelled 'fnv32:' + 8 lowercase hex, which is the unkeyed digest this settings service writes for a non-secret key (packages/services/service-settings/src/crypto-adapter.ts#digest), and, belt and braces, the SHA-256 hex of V1 (bare, and with a 'sha256:' prefix) — and search both captured rows, every field, for each of them and for V1 itself", "write V1 again, then a different value V2, then V1 a third time; re-read both ledgers and compare the fingerprints of the three writes", @@ -2899,7 +2900,7 @@ ], "acceptance": [ { - "clause": "a secret-valued setting's write records on BOTH ledgers a fingerprint of the keyed shape hmac-sha256 followed by 64 hex digits: sys_setting_audit.new_hash, and the digest inside sys_audit_log's config_change new_value (inside the encrypted marker). Neither row carries the value", + "clause": "a secret-valued setting's write records on BOTH ledgers a fingerprint of the keyed shape hmac-sha256 followed by 64 hex digits: the per-key ledger's new_hash (sys_platform_setting_audit for the global mail key), and the digest inside sys_audit_log's config_change new_value (inside the encrypted marker). Neither row carries the value", "oracle": "api", "verify": "new_hash matches the shape 'hmac-sha256:' + 64 lowercase hex; the config_change row's new_value.digest is the encrypted marker wrapping that same keyed digest; neither row, in any field, contains V1", "evidence": "both captured rows, quoted in full" @@ -2917,9 +2918,9 @@ "evidence": "the three fingerprints per ledger" }, { - "clause": "a reset records no fingerprint: the reset row carries new_hash null on sys_setting_audit, and the config_change row carries no new_value", + "clause": "a reset records no fingerprint: the reset row carries new_hash null on the per-key ledger (sys_platform_setting_audit for the global mail key), and the config_change row carries no new_value", "oracle": "api", - "verify": "the reset rows: sys_setting_audit action 'reset' with new_hash null; sys_audit_log config_change new_value null", + "verify": "the reset rows: sys_platform_setting_audit action 'reset' with new_hash null; sys_audit_log config_change new_value null", "evidence": "the two reset rows" }, { @@ -2969,6 +2970,12 @@ "date": "2026-10-16", "change": "steps[2], [3] and [6], acceptance[1].clause and [4].verify and negative[0] corrected (checklist-accuracy finding of QA run #23939, wave #23878). The non-secret digest is FNV-1a 32 of canonical JSON ('fnv32:'), not SHA-256 (crypto-adapter.ts digest), so the step-7 control computed with SHA-256 never matched and could not prove the offline search would find an unkeyed digest. The control and the secret search now compute the same algorithm — FNV-1a 32 first, SHA-256 kept as a second net — and the generic-ledger row for global-scope mail names sys_platform_setting, not sys_setting. Not loosened: the search now covers the algorithm the service actually uses, so a leaked unkeyed digest is caught where the old search could miss it", "ref": "#24036" + }, + { + "revision": 3, + "date": "2026-10-19", + "change": "fixtures.requires[2], steps[1] and acceptance[0].clause, [3] (clause, verify) re-pointed: mail is a global-scope namespace, and the settings audit trail's global rung moved to the deployment-level sys_platform_setting_audit (ADR-0131 D7, ruling record 6257510973 Q4 A). Global-rung rows an 18.0 install wrote stay in sys_setting_audit until the upgrade ceremony moves them. Not loosened: the same keyed-digest, no-value and reset rules are scored on the same rows", + "ref": "#24530" } ] }, diff --git a/packages/qa/dogfood/test/deployment-level-secret-audit-credential.dogfood.test.ts b/packages/qa/dogfood/test/deployment-level-secret-audit-credential.dogfood.test.ts new file mode 100644 index 00000000000..afca2ecbf33 --- /dev/null +++ b/packages/qa/dogfood/test/deployment-level-secret-audit-credential.dogfood.test.ts @@ -0,0 +1,222 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [ADR-0131 D7 · 18.x step 2] `sys_secret` and `sys_flow_credential` are +// deployment-level, and the settings audit trail's global rung is +// `sys_platform_setting_audit` (ruling records 6257510973, Q4 A, and +// 6259141885, Q1 A) — the EXPAND step, on the booted showcase, composed the way +// `os serve` composes it: the audit plugin and the ownership boot guard. +// +// Two boots over one SQLite file: +// +// A. A fresh install (R1). Through the real doors, an operator saves a tenant +// setting, an encrypted global setting and a flow with two credentials. +// The new write shape: no secret and no credential row names an +// organization; the global setting's history row is in the sibling, with +// no organization; the tenant setting's is in `sys_setting_audit`, +// stamped. +// B. The same database with its rows put in the 18.0 shape — what an 18.0 +// install wrote: the secrets and credentials stamped with the Default +// Organization, and the global setting's history row in +// `sys_setting_audit`, stamped. The boot guard does not refuse it (the +// physical organization columns are DECLARED, so this is not the v17 +// shape); the stamped secret opens, the stamped credentials resolve, the +// global rung's history is served from both tables; and once the stack +// stops, every 18.0 row still holds its organization (expand-first: only +// the upgrade ceremony re-stamps, moves or drops). +// +// This file builds its own 18.0-shaped rows; the 18.x line's upgrade harness +// boots a whole 18.0 database, and this step runs it once it lands. +// +// NOT eligible for the shared showcase project: it writes org-wide settings and +// boots twice over its own file. +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { AuditPlugin } from '@objectstack/plugin-audit'; +import { OrganizationOwnershipBootGuardPlugin } from '@objectstack/runtime'; +import type { VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; + +const FLOW = 'expand_18x_inbound'; +/** Placeholders, sealed by the platform like any other value; they open nothing. */ +const HOOK_SECRET = 'expand-18x-not-a-real-hook-secret'; +const AI_KEY = 'expand-18x-not-a-real-api-key'; +const WORKSPACE = 'Expand 18x Workspace'; +const SYSTEM = { context: { isSystem: true } }; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const rowsOf = (body: any): any[] => (Array.isArray(body) ? body : (body?.records ?? body?.data ?? [])); + +function boot(databaseFile: string): Promise { + return bootShowcase({ databaseFile, extraPlugins: [new AuditPlugin(), new OrganizationOwnershipBootGuardPlugin()] }); +} + +function inboundFlow() { + return { + name: FLOW, + label: 'Expand 18x inbound', + type: 'api', + status: 'active', + nodes: [ + { id: 'start', type: 'start', label: 'Start', config: { hookId: 'h1', secret: HOOK_SECRET } }, + { id: 'call', type: 'http', label: 'Call', config: { url: 'https://example.invalid/out', method: 'POST', signingSecret: HOOK_SECRET } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'call' }, + { id: 'e2', source: 'call', target: 'end' }, + ], + }; +} + +/** One table's rows, read from the file past every engine. */ +function tableRows(file: string, table: string): Array> { + const db = new DatabaseSync(file, { readOnly: true }); + try { + return db.prepare(`SELECT * FROM "${table}"`).all() as Array>; + } finally { + db.close(); + } +} + +describe('[18.x step 2] sys_secret, sys_flow_credential and the global audit rung are deployment-level — expand-first', () => { + const dir = mkdtempSync(join(tmpdir(), 'dogfood-expand-18x-')); + const file = join(dir, 'expand.db'); + let stack: VerifyStack | undefined; + let organizationId = ''; + let secretIds: string[] = []; + let credentialIds: string[] = []; + + beforeAll(async () => { + stack = await boot(file); + const tok = await stack.signIn(); + const orgs = rowsOf(await (await stack.apiAs(tok, 'GET', '/auth/organization/list')).json()); + const org = orgs.find((o: any) => o?.slug === 'default') ?? orgs[0]; + organizationId = String(org?.id ?? ''); + expect(organizationId, 'the boot created the Default Organization').not.toBe(''); + expect((await stack.apiAs(tok, 'POST', '/auth/organization/set-active', { organizationId })).status).toBe(200); + + const put = (ns: string, body: unknown) => + stack!.raw(`/api/settings/${ns}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${tok}` }, + body: JSON.stringify(body), + }); + const branding = await put('branding', { workspace_name: WORKSPACE }); + expect(branding.status, await branding.clone().text()).toBe(200); + const ai = await put('ai', { gateway_api_key: AI_KEY }); + expect(ai.status, await ai.clone().text()).toBe(200); + const flow = await stack.apiAs(tok, 'PUT', `/meta/flow/${FLOW}`, inboundFlow()); + expect([200, 201], await flow.clone().text()).toContain(flow.status); + await stack.stop(); + stack = undefined; + }, 240_000); + + afterAll(async () => { + await stack?.stop(); + rmSync(dir, { recursive: true, force: true }); + }); + + it('A. a fresh install writes the new shape: no secret and no credential names an organization', () => { + const secrets = tableRows(file, 'sys_secret'); + secretIds = secrets.map((r) => String(r.id)); + // The ai key's ciphertext, and one per flow credential. + expect(secrets.filter((r) => r.namespace === 'ai' && r.key === 'gateway_api_key')).toHaveLength(1); + expect(secrets.filter((r) => r.namespace === 'sys_flow_credential')).toHaveLength(2); + for (const r of secrets) expect(r.organization_id, `sys_secret ${r.id}`).toBeNull(); + + const credentials = tableRows(file, 'sys_flow_credential').filter((r) => r.flow_name === FLOW); + credentialIds = credentials.map((r) => String(r.id)); + expect(credentials).toHaveLength(2); + for (const r of credentials) expect(r.organization_id, `sys_flow_credential ${r.id}`).toBeNull(); + }); + + it('A. the global setting\'s history row is in the sibling, unstamped; the tenant setting\'s stays in sys_setting_audit, stamped', () => { + const platform = tableRows(file, 'sys_platform_setting_audit'); + expect(platform).toEqual([expect.objectContaining({ namespace: 'ai', key: 'gateway_api_key', scope: 'global', action: 'set' })]); + expect(Object.keys(platform[0]!)).not.toContain('organization_id'); + + const audit = tableRows(file, 'sys_setting_audit'); + expect(audit.filter((r) => r.scope === 'global')).toEqual([]); + expect(audit.filter((r) => r.namespace === 'branding' && r.key === 'workspace_name')).toEqual([ + expect.objectContaining({ scope: 'tenant', organization_id: organizationId }), + ]); + }); + + describe('B. the same database with its rows in the 18.0 shape', () => { + const LEGACY_AUDIT = 'expand18x_legacy_global_audit'; + let bootError: unknown; + + beforeAll(async () => { + // What an 18.0 install wrote: the Default Organization's stamp on every + // secret and credential, and the global rung's history in sys_setting_audit. + const db = new DatabaseSync(file); + try { + db.prepare('UPDATE "sys_secret" SET "organization_id" = ?').run(organizationId); + db.prepare('UPDATE "sys_flow_credential" SET "organization_id" = ?').run(organizationId); + db.prepare( + 'INSERT INTO "sys_setting_audit" ("id", "organization_id", "namespace", "key", "scope", "action", "source", "new_hash", "encrypted", "created_at") ' + + 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + ).run(LEGACY_AUDIT, organizationId, 'ai', 'gateway_api_key', 'global', 'set', 'api', 'hmac-sha256:18.0', 1, '2026-10-19T00:00:00.000Z'); + } finally { + db.close(); + } + try { + stack = await boot(file); + } catch (error) { + bootError = error; + } + }, 240_000); + + it('boots: the boot guard does not read the declared, unread organization columns as the v17 shape', () => { + expect(bootError, String((bootError as Error | undefined)?.stack ?? bootError)).toBeUndefined(); + expect(stack).toBeDefined(); + }); + + it('the stamped secret opens, and the stamped credentials resolve', async () => { + const settings = await stack!.kernel.getServiceAsync('settings'); + const got = await settings.get('ai', 'gateway_api_key', { tenantId: organizationId }); + expect(got?.value).toBe(AI_KEY); + + const ql = await stack!.kernel.getServiceAsync('objectql'); + for (const id of credentialIds) { + expect(await ql.resolveSecretField('sys_flow_credential', id, 'value'), `credential ${id}`).toBe(HOOK_SECRET); + } + }); + + it('the global rung\'s history is served from both tables: the 18.0 row and the new one', async () => { + const ql = await stack!.kernel.getServiceAsync('objectql'); + const where = { namespace: 'ai', key: 'gateway_api_key', scope: 'global' }; + const legacy = rowsOf(await ql.find('sys_setting_audit', { where }, SYSTEM)); + const current = rowsOf(await ql.find('sys_platform_setting_audit', { where }, SYSTEM)); + expect(legacy.map((r) => r.id)).toEqual([LEGACY_AUDIT]); + expect(current).toHaveLength(1); + + // And through the data API, as the platform admin reads each grid. + const tok = await stack!.signIn(); + for (const [object, id] of [['sys_setting_audit', LEGACY_AUDIT], ['sys_platform_setting_audit', String(current[0].id)]] as const) { + const res = await stack!.apiAs(tok, 'GET', `/data/${object}`); + expect(res.status, object).toBe(200); + expect(rowsOf(await res.json()).map((r: any) => r.id), object).toContain(id); + } + }); + + it('every 18.0 row keeps its organization once the stack stops — nothing re-stamped, moved or dropped', async () => { + await stack?.stop(); + stack = undefined; + const secrets = tableRows(file, 'sys_secret'); + for (const id of secretIds) { + expect(secrets.find((r) => String(r.id) === id)?.organization_id, `sys_secret ${id}`).toBe(organizationId); + } + const credentials = tableRows(file, 'sys_flow_credential'); + for (const id of credentialIds) { + expect(credentials.find((r) => String(r.id) === id)?.organization_id, `sys_flow_credential ${id}`).toBe(organizationId); + } + expect(tableRows(file, 'sys_setting_audit').find((r) => r.id === LEGACY_AUDIT)).toEqual( + expect.objectContaining({ organization_id: organizationId, scope: 'global' }), + ); + }); + }); +}); diff --git a/packages/services/service-settings/src/config-change-audit.test.ts b/packages/services/service-settings/src/config-change-audit.test.ts index 4b427c9e273..ecadbbd5d24 100644 --- a/packages/services/service-settings/src/config-change-audit.test.ts +++ b/packages/services/service-settings/src/config-change-audit.test.ts @@ -40,7 +40,7 @@ import { describe, expect, it, vi } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; -import { SysPlatformSetting, SysSecret, SysSetting, SysSettingAudit } from '@objectstack/platform-objects/system'; +import { SysPlatformSetting, SysPlatformSettingAudit, SysSecret, SysSetting, SysSettingAudit } from '@objectstack/platform-objects/system'; import type { SettingsManifest } from '@objectstack/spec/system'; import type { IHttpRequest, IHttpResponse, IHttpServer, RouteHandler } from '@objectstack/spec/contracts'; import { @@ -232,6 +232,8 @@ async function bootPlugin(opts: BootOptions = {}) { engine.registry.registerObject(SysSetting as any, OWNER_PACKAGE); engine.registry.registerObject(SysPlatformSetting as any, OWNER_PACKAGE); engine.registry.registerObject(SysSettingAudit as any, OWNER_PACKAGE); + // [ADR-0131 D7, Q4 A] The settings audit trail's global rung. + engine.registry.registerObject(SysPlatformSettingAudit as any, OWNER_PACKAGE); // [#21516] A secret-typed setting writes through `sys_secret`; the engine // refuses a name its registry does not hold, so the harness registers it as // a boot does (platform-objects owns it). @@ -308,8 +310,15 @@ async function bootPlugin(opts: BootOptions = {}) { /** Every `sys_audit_log` row the write path asked the engine to insert. */ ledgerRows: () => ledgerInserts.map((i) => i.row), ledgerOpts: () => ledgerInserts.map((i) => i.opts), - /** REAL `sys_setting_audit` rows, read out of the driver's store. */ - settingAuditRows: () => [...rowsOf('sys_setting_audit').values()], + /** + * REAL settings-audit rows of this file's manifest, read out of the + * driver's store. It is `scope: 'global'`, so they are + * `sys_platform_setting_audit` rows — the audit trail's global rung + * (ADR-0131 D7, ruling record 6257510973 · Q4 A). + */ + settingAuditRows: () => [...rowsOf('sys_platform_setting_audit').values()], + /** REAL `sys_setting_audit` rows — the tenant and user rungs' trail. */ + scopedSettingAuditRows: () => [...rowsOf('sys_setting_audit').values()], /** * REAL settings rows of this file's manifest. It is `scope: 'global'`, so * they are `sys_platform_setting` rows — the global rung's own store @@ -459,11 +468,13 @@ describe('#8145 — dual-write: `sys_setting_audit` keeps its rows', () => { await boot.service.setMany('branding_test', { workspace_name: 'ObjectStack' }, boot.writeCtx); - // The presence half. This is a REAL row in the REAL `sys_setting_audit` - // object, so the assertion cannot pass emptily on a fixture that never wrote - // there — which is exactly how an "existing behaviour unchanged" pin goes - // green for the wrong reason. + // The presence half. This is a REAL row in the REAL settings audit trail — + // `sys_platform_setting_audit`, the global rung's (ADR-0131 D7, Q4 A) — + // so the assertion cannot pass emptily on a fixture that never wrote there, + // which is exactly how an "existing behaviour unchanged" pin goes green for + // the wrong reason. const settingAudit = boot.settingAuditRows(); + expect(boot.scopedSettingAuditRows()).toHaveLength(0); expect(settingAudit).toHaveLength(1); expect(settingAudit[0]).toMatchObject({ namespace: 'branding_test', @@ -622,6 +633,8 @@ describe('#8145 — a refused write emits NO config_change row', () => { engine.registry.registerObject(SysSetting as any, OWNER_PACKAGE); engine.registry.registerObject(SysPlatformSetting as any, OWNER_PACKAGE); engine.registry.registerObject(SysSettingAudit as any, OWNER_PACKAGE); + // [ADR-0131 D7, Q4 A] The settings audit trail's global rung. + engine.registry.registerObject(SysPlatformSettingAudit as any, OWNER_PACKAGE); // [#21516] A secret-typed setting writes through `sys_secret`; the engine // refuses a name its registry does not hold, so the harness registers it as // a boot does (platform-objects owns it). @@ -660,7 +673,8 @@ describe('#8145 — a refused write emits NO config_change row', () => { // The #8026 pre-flight refuses the WHOLE batch before the write loop, so the // plain sibling key is neither persisted nor audited — a half-audited batch // would be worse than no audit at all. - const settingAudit = () => [...rowsOf('sys_setting_audit').values()]; + // The manifest is global, so its trail is the global rung's (Q4 A). + const settingAudit = () => [...rowsOf('sys_platform_setting_audit').values()]; expect(ledgerRows).toHaveLength(0); expect(settingAudit()).toHaveLength(0); expect([...rowsOf('sys_setting').values()]).toHaveLength(0); diff --git a/packages/services/service-settings/src/settings-engine-bind-window.test.ts b/packages/services/service-settings/src/settings-engine-bind-window.test.ts index be2318ba021..5a3f10ba664 100644 --- a/packages/services/service-settings/src/settings-engine-bind-window.test.ts +++ b/packages/services/service-settings/src/settings-engine-bind-window.test.ts @@ -55,7 +55,7 @@ import { describe, expect, it } from 'vitest'; import { LiteKernel } from '@objectstack/core'; import type { Plugin, PluginContext } from '@objectstack/core'; import { ObjectQL } from '@objectstack/objectql'; -import { SysPlatformSetting, SysSecret, SysSetting, SysSettingAudit } from '@objectstack/platform-objects/system'; +import { SysPlatformSetting, SysPlatformSettingAudit, SysSecret, SysSetting, SysSettingAudit } from '@objectstack/platform-objects/system'; import type { SettingsManifest } from '@objectstack/spec/system'; import { SettingsService } from './settings-service.js'; import { SettingsServicePlugin, wrapEngineAsSettingsEngine } from './settings-service-plugin.js'; @@ -233,9 +233,12 @@ async function bootKernel(opts: { withEngine?: boolean } = {}) { engine.registerDriver(driver, true); await engine.init(); // [#21516] The settings service writes its audit row through - // `sys_setting_audit`; the engine refuses a name its registry does not hold, - // so the harness registers it beside the two it already did, as a boot does. - for (const o of [SysSetting, SysPlatformSetting, SysSecret, SysSettingAudit]) engine.registry.registerObject(o as any, OWNER_PACKAGE); + // `sys_setting_audit` (or, for the global rung, `sys_platform_setting_audit` + // — ADR-0131 D7, Q4 A); the engine refuses a name its registry does not hold, + // so the harness registers them beside the two it already did, as a boot does. + for (const o of [SysSetting, SysPlatformSetting, SysSecret, SysSettingAudit, SysPlatformSettingAudit]) { + engine.registry.registerObject(o as any, OWNER_PACKAGE); + } const probe = new ReadyHookFromInitPlugin(); const kernel = new LiteKernel({ logger: { level: 'error' } as never }); @@ -251,9 +254,10 @@ async function bootKernel(opts: { withEngine?: boolean } = {}) { return { kernel, engine, svc, probe, rowsOf, // The probe manifests are `scope: 'global'`, so their rows are the global - // rung's: `sys_platform_setting` (ADR-0131 D7). + // rung's: `sys_platform_setting`, and its trail `sys_platform_setting_audit` + // (ADR-0131 D7; ruling record 6257510973, Q4 A). settingRows: () => [...rowsOf('sys_platform_setting').values()], - auditRows: () => [...rowsOf('sys_setting_audit').values()], + auditRows: () => [...rowsOf('sys_platform_setting_audit').values()], }; } @@ -376,9 +380,12 @@ describe('engine-less callers outside the window observe nothing new', () => { engine.registerDriver(driver, true); await engine.init(); // [#21516] The settings service writes its audit row through - // `sys_setting_audit`; the engine refuses a name its registry does not hold, - // so the harness registers it beside the two it already did, as a boot does. - for (const o of [SysSetting, SysPlatformSetting, SysSecret, SysSettingAudit]) engine.registry.registerObject(o as any, OWNER_PACKAGE); + // `sys_setting_audit` (or, for the global rung, `sys_platform_setting_audit` + // — ADR-0131 D7, Q4 A); the engine refuses a name its registry does not hold, + // so the harness registers them beside the two it already did, as a boot does. + for (const o of [SysSetting, SysPlatformSetting, SysSecret, SysSettingAudit, SysPlatformSettingAudit]) { + engine.registry.registerObject(o as any, OWNER_PACKAGE); + } const svc = new SettingsService({ env: {}, engineBindPending: true }); svc.registerManifest(probeManifest); From fc628bc39775ccaa2e0f892b5c290d1b4b208084 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 13:18:20 +0000 Subject: [PATCH 04/11] test(dogfood): the boot's own global-rung audit rows land in the sibling too (WIP) Claude-Session: https://claude.ai/code/session_01HZT9daB4YX41N1SVVxYcek Co-authored-by: Claude --- ...ployment-level-secret-audit-credential.dogfood.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/qa/dogfood/test/deployment-level-secret-audit-credential.dogfood.test.ts b/packages/qa/dogfood/test/deployment-level-secret-audit-credential.dogfood.test.ts index afca2ecbf33..efe0378c4ec 100644 --- a/packages/qa/dogfood/test/deployment-level-secret-audit-credential.dogfood.test.ts +++ b/packages/qa/dogfood/test/deployment-level-secret-audit-credential.dogfood.test.ts @@ -134,9 +134,13 @@ describe('[18.x step 2] sys_secret, sys_flow_credential and the global audit run }); it('A. the global setting\'s history row is in the sibling, unstamped; the tenant setting\'s stays in sys_setting_audit, stamped', () => { + // The boot's own global-rung writes (the auth plugin records its API-key + // policy start) land here too, so the operator's row is found by its key. const platform = tableRows(file, 'sys_platform_setting_audit'); - expect(platform).toEqual([expect.objectContaining({ namespace: 'ai', key: 'gateway_api_key', scope: 'global', action: 'set' })]); - expect(Object.keys(platform[0]!)).not.toContain('organization_id'); + expect(platform.filter((r) => r.namespace === 'ai')).toEqual([ + expect.objectContaining({ key: 'gateway_api_key', scope: 'global', action: 'set' }), + ]); + for (const r of platform) expect(Object.keys(r), `sys_platform_setting_audit ${r.id}`).not.toContain('organization_id'); const audit = tableRows(file, 'sys_setting_audit'); expect(audit.filter((r) => r.scope === 'global')).toEqual([]); From d727f381b7e3de5f81a793c292ccde68f3130547 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 13:34:42 +0000 Subject: [PATCH 05/11] fix(service-settings): each settings-audit write names its table literally; tenant-audit census regenerated Claude-Session: https://claude.ai/code/session_01HZT9daB4YX41N1SVVxYcek Co-authored-by: Claude --- .../docs/permissions/tenant-audit-census.mdx | 38 +++++++++---------- ...08-tenant-audit-write-call-sites.counts.md | 35 ++++++++--------- .../src/settings-service-plugin.ts | 29 +++++++++----- 3 files changed, 56 insertions(+), 46 deletions(-) diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index 3f71d3bdc5b..a50429a2fd8 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -83,7 +83,7 @@ what moved this page's population from 225 to 227; nothing about the two sites changed, only whether this instrument could see them. **The expensive failure direction is a keyword.** Sites whose receiver the author -typed `any` have no type to read, and there are 40 of them — just under a fifth +typed `any` have no type to read, and there are 41 of them — just under a fifth of the population, concentrated in exactly the seed and bootstrap paths this control exists for. Scoring an unreadable receiver as "not an engine" would have dropped every one of them silently, with a clean exit and a smaller number that @@ -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 **52 of the 231 sites are spelled that way**. A +forwarding shim cannot, and **52 of the 232 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. @@ -187,10 +187,10 @@ reproduce them. Where it disagrees, it disagrees on the page: | carried figure | where it survives | this census | | :--- | :--- | ---: | -| 175 write call sites | quoted in the merged changeset | **231** | +| 175 write call sites | quoted in the merged changeset | **232** | | 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **31** more whose options argument is unreadable | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **160 of 231** decidable, **71** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 128 decidably elevated, 0 decidably not, 95 undecidable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **161 of 232** decidable, **71** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 129 decidably elevated, 0 decidably not, 95 undecidable | | 141 and 132, two independent re-derivations | the card that filed this work | — | **The differences are not reconciled, and deliberately so.** The old census's @@ -200,18 +200,18 @@ be stated is what this instrument counts, which is written above and re-runnable at any commit. Two structural facts do plausibly widen this reading against any hand or regex -one, and both are counted in the generated tables below: the 40 sites reached +one, and both are counted in the generated tables below: the 41 sites reached through an erased (`any`) receiver, and the 52 that name their object through a `const` rather than inline. An instrument that read either the way a person does would report a smaller number and would not say so. The fourth row is the one worth flagging to anyone citing it. **The 135 / 77% figure has no surviving corroboration anywhere in the tree.** This census reads -128 of 231 (55%) as decidably elevated, with 95 more whose elevation is a +129 of 232 (56%) as decidably elevated, with 95 more whose elevation is a run-time fact — so the claim is neither confirmed nor refuted, and the honest answer is that a static reading cannot settle it. -⇒ **Cite `2 / 231`, and say what it is**: the sites whose options argument was +⇒ **Cite `2 / 232`, and say what it is**: the sites whose options argument was READ and holds no tenant context, against a decidably tenancy-enabled object. That is the control's provable yield surface. ⛔ Do not cite it as "the sites without tenant context" — **31 further sites** have an options argument this @@ -223,28 +223,28 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **231** | -| …whose object name is statically decidable | 160 | +| write call sites on the application surface | **232** | +| …whose object name is statically decidable | 161 | | …whose object name is chosen at run time | 71 | -| …against an object with tenancy ENABLED | 159 | -| …against an object that declares tenancy off | 1 | -| threading a tenant context | 171 | +| …against an object with tenancy ENABLED | 147 | +| …against an object that declares tenancy off | 14 | +| threading a tenant context | 172 | | 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 | 52 | | …of those, against a decidably tenancy-enabled object | 31 | -| threading a decidably ELEVATED (`isSystem`) context | 128 | +| threading a decidably ELEVATED (`isSystem`) context | 129 | | threading a context that is decidably NOT elevated | 0 | | threading a context whose elevation is a run-time fact | 95 | | how the instrument reached the site | count | | :--- | ---: | | receiver carried a readable engine type | 191 | -| receiver erased, placed by the object NAME | 30 | +| receiver erased, placed by the object NAME | 31 | | receiver erased, placed by an `object: string` PARAMETER | 5 | | receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | -| object name spelled inline | 108 | +| object name spelled inline | 109 | | object name spelled through a `const` | 52 | | object name is an `object: string` parameter | 7 | | object name is some other run-time expression | 64 | @@ -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-19 at `2623d6d6c`. +Measured on 2026-10-19 at `fc628bc39`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 670 | +| tracked non-test sources scanned | 671 | | engine-shaped types recognised | 79 | -| declared objects in the registry | 116 | +| declared objects in the registry | 117 | | same-named calls subtracted as non-engine | 166 | {/* END GENERATED: tenant-audit-census */} diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index 7626b4b1f53..56b43355cd2 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 231 | -| Object name statically decidable | 160 | +| Write call sites | 232 | +| Object name statically decidable | 161 | | Object name chosen at run time | 71 | -| Against a tenancy-enabled object | 159 | -| Against an object declaring tenancy off | 1 | -| Threading a tenant context | 171 | +| Against a tenancy-enabled object | 147 | +| Against an object declaring tenancy off | 14 | +| Threading a tenant context | 172 | | Provably carrying none | 8 | | …and decidably tenancy-enabled | 2 | | Options argument unreadable | 52 | | …and decidably tenancy-enabled | 31 | -| Threading a decidably elevated context | 128 | +| Threading a decidably elevated context | 129 | | Threading a decidably non-elevated context | 0 | | Threading a context of undecidable elevation | 95 | @@ -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-19 at `2623d6d6c`. +Measured on 2026-10-19 at `fc628bc39`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 670 | +| tracked non-test sources scanned | 671 | | engine-shaped types recognised | 79 | -| declared objects in the registry | 116 | +| declared objects in the registry | 117 | | same-named calls subtracted as non-engine | 166 | ## Every site @@ -189,9 +189,9 @@ Measured on 2026-10-19 at `2623d6d6c`. | `packages/services/service-automation/src/builtin/crud-nodes.ts` | `delete` | `objectName` | undecidable | context, elevation undecidable | 1 | | `packages/services/service-automation/src/builtin/crud-nodes.ts` | `insert` | `objectName` | undecidable | context, elevation undecidable | 1 | | `packages/services/service-automation/src/builtin/crud-nodes.ts` | `update` | `objectName` | undecidable | context, elevation undecidable | 1 | -| `packages/services/service-automation/src/flow-credential-channel.ts` | `delete` | `sys_flow_credential` | enabled | elevated | 5 | -| `packages/services/service-automation/src/flow-credential-channel.ts` | `insert` | `sys_flow_credential` | enabled | elevated | 1 | -| `packages/services/service-automation/src/flow-credential-channel.ts` | `update` | `sys_flow_credential` | enabled | elevated | 2 | +| `packages/services/service-automation/src/flow-credential-channel.ts` | `delete` | `sys_flow_credential` | disabled | elevated | 5 | +| `packages/services/service-automation/src/flow-credential-channel.ts` | `insert` | `sys_flow_credential` | disabled | elevated | 1 | +| `packages/services/service-automation/src/flow-credential-channel.ts` | `update` | `sys_flow_credential` | disabled | elevated | 2 | | `packages/services/service-automation/src/flow-credential-migration.ts` | `insert` | `DATA_MIGRATION_FLAG_OBJECT` | undecidable | elevated | 1 | | `packages/services/service-automation/src/flow-credential-migration.ts` | `update` | `DATA_MIGRATION_FLAG_OBJECT` | undecidable | elevated | 1 | | `packages/services/service-automation/src/flow-dispatch-store.ts` | `insert` | `sys_flow_dispatch` | enabled | elevated | 1 | @@ -202,8 +202,8 @@ Measured on 2026-10-19 at `2623d6d6c`. | `packages/services/service-datasource/src/datasource-admin-plugin.ts` | `delete` | `sys_metadata` | enabled | elevated | 1 | | `packages/services/service-datasource/src/datasource-admin-plugin.ts` | `insert` | `sys_metadata` | enabled | elevated | 1 | | `packages/services/service-datasource/src/datasource-admin-plugin.ts` | `update` | `sys_metadata` | enabled | elevated | 2 | -| `packages/services/service-datasource/src/datasource-secret-binder.ts` | `delete` | `sys_secret` | enabled | elevated | 1 | -| `packages/services/service-datasource/src/datasource-secret-binder.ts` | `insert` | `sys_secret` | enabled | elevated | 1 | +| `packages/services/service-datasource/src/datasource-secret-binder.ts` | `delete` | `sys_secret` | disabled | elevated | 1 | +| `packages/services/service-datasource/src/datasource-secret-binder.ts` | `insert` | `sys_secret` | disabled | elevated | 1 | | `packages/services/service-job/src/db-job-adapter.ts` | `insert` | `sys_job` | enabled | elevated | 1 | | `packages/services/service-job/src/db-job-adapter.ts` | `update` | `sys_job` | enabled | elevated | 3 | | `packages/services/service-job/src/db-job-adapter.ts` | `insert` | `sys_job_run` | enabled | elevated | 1 | @@ -229,9 +229,10 @@ Measured on 2026-10-19 at `2623d6d6c`. | `packages/services/service-settings/src/config-change-audit.ts` | `insert` | `sys_audit_log` | enabled | context, elevation undecidable | 1 | | `packages/services/service-settings/src/settings-service-plugin.ts` | `insert` | `objectName` | undecidable | options unreadable | 1 | | `packages/services/service-settings/src/settings-service-plugin.ts` | `update` | `objectName` | undecidable | options unreadable | 2 | -| `packages/services/service-settings/src/settings-service-plugin.ts` | `delete` | `sys_secret` | enabled | elevated | 1 | -| `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_platform_setting_audit` | enabled | elevated | 1 | +| `packages/services/service-settings/src/settings-service-plugin.ts` | `delete` | `sys_secret` | disabled | elevated | 1 | +| `packages/services/service-settings/src/settings-service-plugin.ts` | `insert` | `sys_secret` | disabled | elevated | 1 | +| `packages/services/service-settings/src/settings-service-plugin.ts` | `update` | `sys_secret` | disabled | 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` | `object` | undecidable | context, elevation undecidable | 1 | | `packages/services/service-settings/src/settings-service.ts` | `update` | `object` | undecidable | context, elevation undecidable | 1 | diff --git a/packages/services/service-settings/src/settings-service-plugin.ts b/packages/services/service-settings/src/settings-service-plugin.ts index 5a10bb2f673..958f95af684 100644 --- a/packages/services/service-settings/src/settings-service-plugin.ts +++ b/packages/services/service-settings/src/settings-service-plugin.ts @@ -624,7 +624,7 @@ export function buildSettingAuditWriter( }, entry.actorId ?? null, ); - await eng.insert(object, { + const row = { namespace: entry.namespace, key: entry.key, scope: entry.scope, @@ -637,16 +637,25 @@ export function buildSettingAuditWriter( request_id: entry.requestId ?? null, reason: entry.reason ?? null, created_at: new Date().toISOString(), + }; + // The explicit system opt-in, as the settings row write carries: the + // settings audit is the platform's own ledger, written after the + // settings door already authorized the change, and not a write that + // may rely on a missing principal to pass the security middleware's + // principal-less hand-off (ADR-0096 D5). One literal object per call + // site, so each write names the table it lands in. + if (object === PLATFORM_SETTING_AUDIT_OBJECT) { + // [ADR-0131 D7] A global value is deployment-level: its row names no + // organization, in the sibling that has no organization column. + await eng.insert('sys_platform_setting_audit', row, { bypassTenantAudit: true, context: { isSystem: true } }); + } else { // [ADR-0131 D3/D9] The organization whose value changed — the - // settings row's own owner. A global value names none, and its row - // goes to the sibling that has no organization column (D7). - ...(object === SETTING_AUDIT_OBJECT && entry.organizationId ? { organization_id: entry.organizationId } : {}), - // The explicit system opt-in, as the settings row write carries: - // the settings audit is the platform's own ledger, written after - // the settings door already authorized the change, and not a - // write that may rely on a missing principal to pass the security - // middleware's principal-less hand-off (ADR-0096 D5). - }, { bypassTenantAudit: true, context: { isSystem: true } }); + // settings row's own owner. + await eng.insert('sys_setting_audit', { + ...row, + ...(entry.organizationId ? { organization_id: entry.organizationId } : {}), + }, { bypassTenantAudit: true, context: { isSystem: true } }); + } } catch (err: any) { // A durability loss, not a functional one: the settings write already // landed and everything keeps looking normal, while the per-key From 5dba75590dbf1a4e676190048daebf0c49cbef74 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 13:57:22 +0000 Subject: [PATCH 06/11] fix(platform-objects): translate the retired organization column's label; query options typed in the expand pin Claude-Session: https://claude.ai/code/session_01HZT9daB4YX41N1SVVxYcek Co-authored-by: Claude --- .../src/apps/translations/en.objects.generated.ts | 4 ++++ .../src/apps/translations/es-ES.objects.generated.ts | 4 ++++ .../src/apps/translations/ja-JP.objects.generated.ts | 4 ++++ .../src/apps/translations/zh-CN.objects.generated.ts | 4 ++++ .../src/settings-deployment-level-expand.test.ts | 6 +++--- 5 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index 1d6c8950c6f..6edd1ef62d0 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -3386,6 +3386,10 @@ export const enObjects: NonNullable = { ciphertext: { label: "Ciphertext", help: "Provider-encoded ciphertext blob (base64 / JSON). Implementation-defined; only the matching ICryptoProvider can read it." + }, + organization_id: { + label: "Organization (18.0, retired)", + help: "Retired: the organization an 18.0 install stamped on this row. Unread and never written; the upgrade ceremony drops the column." } }, _views: { diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index d9088440594..a5cdcf2e06c 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -3386,6 +3386,10 @@ export const esESObjects: NonNullable = { ciphertext: { label: "Texto cifrado", help: "Blob de texto cifrado codificado por el proveedor (base64 / JSON). La implementación lo define; solo el ICryptoProvider correspondiente puede leerlo." + }, + organization_id: { + label: "Organización (18.0, retirada)", + help: "Retirada: la organización que una instalación 18.0 registró en esta fila. No se lee ni se escribe; la ceremonia de actualización elimina la columna." } }, _views: { diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index c2c3a1a1857..c56530716d8 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -3386,6 +3386,10 @@ export const jaJPObjects: NonNullable = { ciphertext: { label: "暗号文", help: "プロバイダーエンコードされた暗号文 blob(base64 / JSON)。実装定義のため、対応する ICryptoProvider のみが読み取り可能。" + }, + organization_id: { + label: "組織(18.0、廃止済み)", + help: "廃止済み:18.0 のインストールがこの行に記録した組織。読み取りも書き込みも行われません。アップグレード手順でこの列は削除されます。" } }, _views: { diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index a2ceffed877..6dbe38f53cc 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -3386,6 +3386,10 @@ export const zhCNObjects: NonNullable = { ciphertext: { label: "密文", help: "提供方编码后的密文数据(base64 / JSON)。实现定义;仅匹配的 ICryptoProvider 可读取。" + }, + organization_id: { + label: "组织(18.0,已退役)", + help: "已退役:18.0 安装在此行上记录的组织。不再读取,也不再写入;升级仪式会删除该列。" } }, _views: { diff --git a/packages/services/service-settings/src/settings-deployment-level-expand.test.ts b/packages/services/service-settings/src/settings-deployment-level-expand.test.ts index b2f95c02fcb..16cbf012466 100644 --- a/packages/services/service-settings/src/settings-deployment-level-expand.test.ts +++ b/packages/services/service-settings/src/settings-deployment-level-expand.test.ts @@ -175,7 +175,7 @@ describe('[18.x expand · Q4 A] the new write shape, under single with the Defau await engine.insert('sys_setting_audit', { namespace: 'n', key: 'k', scope: 'tenant', action: 'set', source: 'api', encrypted: false, created_at: new Date().toISOString(), - }, SYS as any); + }, SYS); expect(rows('sys_setting_audit')).toEqual([expect.objectContaining({ organization_id: DEFAULT_ORG })]); }); @@ -250,8 +250,8 @@ describe('[18.x expand · Q4 A] the dual read, on 18.0-shaped rows', () => { await svc.setMany('relay_probe', { api_key: 're_456' }, { tenantId: DEFAULT_ORG, userId: undefined }); const where = { namespace: 'relay_probe', key: 'api_key', scope: 'global' }; - const legacy = (await engine.find('sys_setting_audit', { where }, SYS as any)) as any[]; - const current = (await engine.find('sys_platform_setting_audit', { where }, SYS as any)) as any[]; + const legacy = (await engine.find('sys_setting_audit', { where }, SYS)) as any[]; + const current = (await engine.find('sys_platform_setting_audit', { where }, SYS)) as any[]; expect(legacy).toEqual([expect.objectContaining({ id: 'aud_18', organization_id: DEFAULT_ORG, new_hash: 'hmac-sha256:18' })]); expect(current).toHaveLength(1); expect(current[0]!.organization_id ?? null).toBeNull(); From 07709efdfbe8946ed045b6a809b3f7f01e6962b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 14:25:11 +0000 Subject: [PATCH 07/11] fix(cli): keep sys_secret and sys_flow_credential at fate 3 until the ceremony drops their declared column The kept ceremony class blocks its column-drops step on a column the runtime still declares, so the fate moves with the declaration, in step 5. Claude-Session: https://claude.ai/code/session_01HZT9daB4YX41N1SVVxYcek Co-authored-by: Claude --- .../src/utils/organization-ownership-inventory.ts | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/utils/organization-ownership-inventory.ts b/packages/cli/src/utils/organization-ownership-inventory.ts index 785597ecd06..18539d4411e 100644 --- a/packages/cli/src/utils/organization-ownership-inventory.ts +++ b/packages/cli/src/utils/organization-ownership-inventory.ts @@ -625,19 +625,9 @@ export const ORGANIZATION_OWNERSHIP_INVENTORY: readonly InventoryEntry[] = [ fate: 'column-drop', citation: `${CENSUS_MAIN}: systemFields.tenant: false; ${D7}: the settings audit trail's global rung (ruling record 6257510973, Q4 A)`, }, - { - object: 'sys_secret', - census: P1, - fate: 'column-drop', - citation: `${D7}: a secret is owned by the row that references it (ruling record 6257510973, Q4 A); the 18.x expand step keeps the column declared and unread until this drop`, - }, + { object: 'sys_secret', census: P1, fate: 'attribution', citation: `re-verification 6018712820: sys_secret is tenant-attributed by one producer (moved here from C6); ${D10_SINGLE}`, anchors: [] }, { object: 'sys_scim_connection_credential', census: P1, fate: 'attribution', citation: `${D3_ORG_ROWS}; ${D10_SINGLE}`, anchors: [] }, - { - object: 'sys_flow_credential', - census: PMAIN, - fate: 'column-drop', - citation: `${D7}: a credential's ownership follows its flow, which is environment metadata (ruling record 6259141885, Q1 A); the 18.x expand step keeps the column declared and unread until this drop`, - }, + { object: 'sys_flow_credential', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach, added since the first census); ${D10_SINGLE}`, anchors: [] }, // Inbound mail (`@objectstack/service-mailbox`): every row is written by the // system with the mailbox's organization stamped (ADR-0131 D9), so a NULL // row has no producer today; the parents below are where one would take it. From e09294fe1cf43fd26bf8a30d0d7cabe20789ea5d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 14:27:35 +0000 Subject: [PATCH 08/11] revert(services): sys_secret and sys_flow_credential stay as 18.0 shipped them; this step builds the settings-audit sibling only Per the epic seat's binding scope correction (step 0's measured plan, os-dev-report 6268559156): the column-less flip of sys_secret and sys_flow_credential moves to step 6, with the opened ceremony. Claude-Session: https://claude.ai/code/session_01HZT9daB4YX41N1SVVxYcek Co-authored-by: Claude --- ...eployment-level-secret-audit-credential.md | 27 --- ...4530-settings-audit-global-rung-sibling.md | 20 ++ .../apps/translations/en.objects.generated.ts | 4 - .../translations/es-ES.objects.generated.ts | 4 - .../translations/ja-JP.objects.generated.ts | 4 - .../translations/zh-CN.objects.generated.ts | 4 - .../deployment-level-no-tenant-column.test.ts | 58 +---- .../src/system/sys-secret.object.ts | 36 --- ...audit-global-rung-sibling.dogfood.test.ts} | 113 ++------- ...credential-deployment-level-expand.test.ts | 223 ------------------ .../src/sys-flow-credential.object.ts | 26 -- .../settings-deployment-level-expand.test.ts | 71 ++---- .../src/settings-secret-organization.test.ts | 29 +-- .../service-settings/src/settings-service.ts | 8 +- .../src/settings-service.types.ts | 9 +- scripts/platform-object-tenancy-census.json | 22 +- 16 files changed, 104 insertions(+), 554 deletions(-) delete mode 100644 .changeset/24530-deployment-level-secret-audit-credential.md create mode 100644 .changeset/24530-settings-audit-global-rung-sibling.md rename packages/qa/dogfood/test/{deployment-level-secret-audit-credential.dogfood.test.ts => settings-audit-global-rung-sibling.dogfood.test.ts} (54%) delete mode 100644 packages/services/service-automation/src/flow-credential-deployment-level-expand.test.ts diff --git a/.changeset/24530-deployment-level-secret-audit-credential.md b/.changeset/24530-deployment-level-secret-audit-credential.md deleted file mode 100644 index 9eaf0b26e2d..00000000000 --- a/.changeset/24530-deployment-level-secret-audit-credential.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@objectstack/platform-objects': minor -'@objectstack/service-settings': minor -'@objectstack/service-automation': minor -'@objectstack/spec': minor -'@objectstack/cli': minor ---- - -feat(services)!: `sys_secret` and `sys_flow_credential` are deployment-level, and the settings audit trail's global rung moves to the new `sys_platform_setting_audit` - -Clause-②: yes (narrowing) - - - -**BREAKING** for a reader of the settings audit trail, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D7; ruling records 6257510973, Q4 A, and 6259141885, Q1 A). This is the expand half of the change: it adds the new write shape and reads both shapes. It moves, deletes and re-stamps no row an 18.0 install wrote, and it drops no column. - -- **`sys_secret` is deployment-level.** - - **Before.** Under `single`, every `sys_secret` row was stamped with the Default Organization, and an encrypted tenant or user setting wrote its organization on the secret. - - **Now.** The object declares `tenancy: { enabled: false }`: nothing stamps, walls or scopes it by organization. A secret is owned by the row that holds its handle, and every read stays by id under privilege. The settings service no longer writes `organization_id` on a secret. - - **The 18.0 column stays.** `organization_id` is still declared, nullable, read-only and unread, with the shape 18.0 gave it (a 255-wide string, no longer a lookup), so an 18.0 database boots and its schema sync proposes no change. A row an 18.0 install stamped keeps its organization and opens by id as before. The upgrade ceremony drops the column. -- **`sys_flow_credential` is deployment-level**, in the same shape: `tenancy: { enabled: false }`, the 18.0 column kept declared and unread. A credential's ownership follows its flow, which is environment metadata. New credential rows, and the `sys_secret` rows their values mint, carry no organization. 18.0 rows keep theirs and resolve as before. -- **The settings audit trail's global rung moves to `sys_platform_setting_audit`.** - - **Before.** A write of a global-scope settings key appended its history row to `sys_setting_audit`, stamped with the Default Organization under `single`. Any principal the settings read door let read the namespace could list it there. - - **Now.** That row goes to the new `sys_platform_setting_audit`: the same row shape, `scope` always `global`, no organization column (`systemFields: { tenant: false }`). Reading it through the generic data API requires the `manage_platform_settings` capability, as `sys_platform_setting` does, and each namespace's `readPermission` applies there too. Tenant and user rows stay in `sys_setting_audit`, stamped as before. - - **18.0 rows.** Global-rung rows an 18.0 install wrote stay in `sys_setting_audit` until the upgrade ceremony moves them. The global rung's history is both tables. - -**The remedy.** If you list or filter the global rung's settings history (`sys_setting_audit` with `scope = global`), read `sys_platform_setting_audit` as well, with a principal that holds `manage_platform_settings`. Nothing to change for a reader of tenant or user rows, or for a reader of a secret or a flow credential by id. diff --git a/.changeset/24530-settings-audit-global-rung-sibling.md b/.changeset/24530-settings-audit-global-rung-sibling.md new file mode 100644 index 00000000000..76b1ba02f9e --- /dev/null +++ b/.changeset/24530-settings-audit-global-rung-sibling.md @@ -0,0 +1,20 @@ +--- +'@objectstack/platform-objects': minor +'@objectstack/service-settings': minor +'@objectstack/spec': minor +'@objectstack/cli': minor +--- + +feat(service-settings)!: the settings audit trail's global rung moves to the new deployment-level `sys_platform_setting_audit` + +Clause-②: yes (narrowing) + + + +**BREAKING** for a reader of the settings audit trail, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D7; ruling record 6257510973, Q4 A). This is the expand half of the change: it adds the new write shape, and the 18.0 rows stay where they are and readable. No row an 18.0 install wrote is moved, deleted or re-stamped. + +- **Before.** A write of a global-scope settings key appended its history row to `sys_setting_audit`, stamped with the Default Organization under `single`. Any principal the settings read door let read the namespace could list it there. +- **Now.** That row goes to the new `sys_platform_setting_audit`. It has the same row shape, `scope` is always `global`, and there is no organization column (`systemFields: { tenant: false }`). Reading it through the generic data API requires the `manage_platform_settings` capability, as `sys_platform_setting` does, and each namespace's `readPermission` applies there too. Tenant and user rows stay in `sys_setting_audit`, stamped as before. +- **18.0 rows.** Global-rung rows an 18.0 install wrote stay in `sys_setting_audit`, stamped, until the upgrade ceremony moves them. The global rung's history is both tables: the sibling, plus `sys_setting_audit` rows at `scope = global`. + +**The remedy.** If you list or filter the global rung's settings history (`sys_setting_audit` with `scope = global`), read `sys_platform_setting_audit` as well, with a principal that holds `manage_platform_settings`. Select the 18.0 rows by `scope`, never by a missing organization: they carry the Default Organization. Nothing changes for a reader of tenant or user rows. diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index 6edd1ef62d0..1d6c8950c6f 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -3386,10 +3386,6 @@ export const enObjects: NonNullable = { ciphertext: { label: "Ciphertext", help: "Provider-encoded ciphertext blob (base64 / JSON). Implementation-defined; only the matching ICryptoProvider can read it." - }, - organization_id: { - label: "Organization (18.0, retired)", - help: "Retired: the organization an 18.0 install stamped on this row. Unread and never written; the upgrade ceremony drops the column." } }, _views: { diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index a5cdcf2e06c..d9088440594 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -3386,10 +3386,6 @@ export const esESObjects: NonNullable = { ciphertext: { label: "Texto cifrado", help: "Blob de texto cifrado codificado por el proveedor (base64 / JSON). La implementación lo define; solo el ICryptoProvider correspondiente puede leerlo." - }, - organization_id: { - label: "Organización (18.0, retirada)", - help: "Retirada: la organización que una instalación 18.0 registró en esta fila. No se lee ni se escribe; la ceremonia de actualización elimina la columna." } }, _views: { diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index c56530716d8..c2c3a1a1857 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -3386,10 +3386,6 @@ export const jaJPObjects: NonNullable = { ciphertext: { label: "暗号文", help: "プロバイダーエンコードされた暗号文 blob(base64 / JSON)。実装定義のため、対応する ICryptoProvider のみが読み取り可能。" - }, - organization_id: { - label: "組織(18.0、廃止済み)", - help: "廃止済み:18.0 のインストールがこの行に記録した組織。読み取りも書き込みも行われません。アップグレード手順でこの列は削除されます。" } }, _views: { diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index 6dbe38f53cc..a2ceffed877 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -3386,10 +3386,6 @@ export const zhCNObjects: NonNullable = { ciphertext: { label: "密文", help: "提供方编码后的密文数据(base64 / JSON)。实现定义;仅匹配的 ICryptoProvider 可读取。" - }, - organization_id: { - label: "组织(18.0,已退役)", - help: "已退役:18.0 安装在此行上记录的组织。不再读取,也不再写入;升级仪式会删除该列。" } }, _views: { diff --git a/packages/platform-objects/src/deployment-level-no-tenant-column.test.ts b/packages/platform-objects/src/deployment-level-no-tenant-column.test.ts index 9b944df68f4..8461ed39aeb 100644 --- a/packages/platform-objects/src/deployment-level-no-tenant-column.test.ts +++ b/packages/platform-objects/src/deployment-level-no-tenant-column.test.ts @@ -4,8 +4,7 @@ import { describe, it, expect } from 'vitest'; import { resolveInjectedSystemColumns } from '@objectstack/spec/data'; import { PLATFORM_CAPABILITIES } from '@objectstack/spec/security'; import { SysJob, SysJobRun, SysJobQueue } from './audit/index.js'; -import { isTenancyDisabled } from '@objectstack/spec/data'; -import { SysMigration, SysMigrationJournal, SysPlatformSettingAudit, SysSecret, SysSettingAudit } from './system/index.js'; +import { SysMigration, SysMigrationJournal, SysPlatformSettingAudit, SysSecret } from './system/index.js'; /** * [ADR-0131 D7] The deployment-level plumbing this package declares carries no @@ -17,10 +16,9 @@ import { SysMigration, SysMigrationJournal, SysPlatformSettingAudit, SysSecret, * on `fields` alone is a phantom check: an object that merely omits the field * still gets the column. `resolveInjectedSystemColumns` is the derivation * `applySystemFields` consumes to do the injecting, so it is the authority on - * whether the column exists. The `sys_setting_audit` control below is the same + * whether the column exists. The `sys_secret` control below is the same * question asked of an object that DOES keep the column (its writer stamps the - * organization whose value changed), so the predicate is shown able to answer - * `true`. + * caller's organization), so the predicate is shown able to answer `true`. * * ## Why the capability is pinned beside the column * @@ -82,53 +80,11 @@ describe('[ADR-0131 D7] deployment-level plumbing: no tenant column, platform-on }); it('control: a tenant-attributed object keeps the column, so the predicate can answer true', () => { - // `sys_setting_audit` keeps the tenant and user rungs' rows, each stamped - // with the organization whose value changed (D3); only its global rung left - // for `sys_platform_setting_audit` (ruling record 6257510973, Q4 A). - const plan = resolveInjectedSystemColumns(SysSettingAudit); + // `sys_secret` is written with the business write's driver options, so the + // SQL driver stamps the caller's organization on it. It is NOT + // deployment-level today; its fate is decided by the C7 inventory. + const plan = resolveInjectedSystemColumns(SysSecret); expect(plan.tenant).toBe(true); expect([...plan.names]).toContain('organization_id'); }); }); - -/** - * [ADR-0131 D7 · ruling record 6257510973, Q4 A] `sys_secret` is - * deployment-level, in the 18.x line's EXPAND shape: out of the tenancy - * machinery, with the 18.0 organization column still declared, nullable and - * unread, until the upgrade ceremony drops it. - * - * Why not `systemFields: { tenant: false }` yet, as the D7 family above - * declares: an 18.0 database holds a physical `organization_id` on this table, - * and the v18 boot guard reads "an object that declares no organization column - * whose table still carries one" as the v17 shape — it refuses the boot. The - * declared column is what keeps an 18.0 database booting (R2) while nothing - * stamps or scopes by it. - */ -describe('[ADR-0131 D7] sys_secret: deployment-level, the 18.0 column kept declared until the ceremony', () => { - it('is out of the tenancy machinery: the declared opt-out, and no injected tenant column', () => { - expect(SysSecret.name).toBe('sys_secret'); - expect(isTenancyDisabled(SysSecret)).toBe(true); - expect(resolveInjectedSystemColumns(SysSecret).tenant).toBe(false); - // Per-object control: the same declaration without the opt-out is a - // tenant-column object again, so `true` above is the opt-out's doing. - const { tenancy: _optOut, ...withoutOptOut } = SysSecret as Record; - expect(resolveInjectedSystemColumns(withoutOptOut).tenant).toBe(true); - }); - - it('keeps the 18.0 column declared — nullable, read-only, the 255-wide string 18.0 provisioned', () => { - const column = (SysSecret.fields as Record).organization_id; - expect(column).toBeDefined(); - expect(column.type).toBe('text'); - expect(column.required ?? false).toBe(false); - expect(column.readonly).toBe(true); - expect(column.maxLength).toBe(255); - // Not the boot guard's v17 shape: that is `systemFields.tenant: false` with - // NO declared column, which would refuse an 18.0 database. - expect((SysSecret as { systemFields?: unknown }).systemFields).toBeUndefined(); - }); - - it('keeps its read door: private by default, reads only on the generic API', () => { - expect((SysSecret as { access?: unknown }).access).toEqual({ default: 'private' }); - expect((SysSecret as { enable?: { apiMethods?: unknown } }).enable?.apiMethods).toEqual(['get', 'list']); - }); -}); diff --git a/packages/platform-objects/src/system/sys-secret.object.ts b/packages/platform-objects/src/system/sys-secret.object.ts index cf6441fccf9..6c099986490 100644 --- a/packages/platform-objects/src/system/sys-secret.object.ts +++ b/packages/platform-objects/src/system/sys-secret.object.ts @@ -33,22 +33,6 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * engine fails CLOSED without this store, it is registered by * `PlatformObjectsPlugin` (platform infrastructure), not by service-settings. * - * ## Deployment-level (ADR-0131 D7, ruling record 6257510973 · Q4 A) - * - * A secret is owned by the row that references it — `sys_setting` / - * `sys_platform_setting`, the webhook, the flow credential, the field's record, - * the datasource — never by an organization of its own. Every read is by id, - * under privilege. So nothing stamps or scopes this table by organization. - * - * In the 18.x expand step the 18.0 `organization_id` column stays DECLARED - * (below), nullable and unread, and `tenancy: { enabled: false }` takes the - * object out of the tenancy machinery. Declaring the column is what keeps an - * 18.0 database booting: with `systemFields: { tenant: false }` and no - * declared column, the v18 boot guard reads the physical column an 18.0 - * install left here as the v17 shape and refuses the boot, and the drift report - * offers to drop it. The upgrade ceremony drops the column; then this object - * declares `systemFields: { tenant: false }` like the rest of D7's family. - * * @namespace sys */ export const SysSecret = ObjectSchema.create({ @@ -67,9 +51,6 @@ export const SysSecret = ObjectSchema.create({ // opt-in (`isSystem: true`), which the middleware short-circuits. A // principal-less read is refused (ADR-0096 D5). access: { default: 'private' }, - // [ADR-0131 D7] Deployment-level: no tenant scope, no stamp. See the header - // for why the 18.0 column is still declared below. - tenancy: { enabled: false }, description: 'Cipher store written by three privileged producers (see managedBy); each holds its handle in its own column. Never holds plaintext.', highlightFields: ['namespace', 'key', 'kms_key_id', 'version', 'rotated_at'], @@ -173,23 +154,6 @@ export const SysSecret = ObjectSchema.create({ description: 'Provider-encoded ciphertext blob (base64 / JSON). Implementation-defined; only the matching ICryptoProvider can read it.', }), - - /** - * The 18.0 organization column, kept until the upgrade ceremony drops it. - * No writer sets it and no reader reads it; a row an 18.0 install wrote - * still holds its organization here, and it opens by id like any other. - * Same physical shape as the column 18.0 injected (a 255-wide string), so - * the schema sync sees no drift on an 18.0 table. - */ - organization_id: Field.text({ - label: 'Organization (18.0, retired)', - required: false, - readonly: true, - hidden: true, - maxLength: 255, - description: - 'Retired: the organization an 18.0 install stamped on this row. Unread and never written; the upgrade ceremony drops the column.', - }), }, indexes: [ diff --git a/packages/qa/dogfood/test/deployment-level-secret-audit-credential.dogfood.test.ts b/packages/qa/dogfood/test/settings-audit-global-rung-sibling.dogfood.test.ts similarity index 54% rename from packages/qa/dogfood/test/deployment-level-secret-audit-credential.dogfood.test.ts rename to packages/qa/dogfood/test/settings-audit-global-rung-sibling.dogfood.test.ts index efe0378c4ec..312ab07145c 100644 --- a/packages/qa/dogfood/test/deployment-level-secret-audit-credential.dogfood.test.ts +++ b/packages/qa/dogfood/test/settings-audit-global-rung-sibling.dogfood.test.ts @@ -1,30 +1,27 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // -// [ADR-0131 D7 · 18.x step 2] `sys_secret` and `sys_flow_credential` are -// deployment-level, and the settings audit trail's global rung is -// `sys_platform_setting_audit` (ruling records 6257510973, Q4 A, and -// 6259141885, Q1 A) — the EXPAND step, on the booted showcase, composed the way -// `os serve` composes it: the audit plugin and the ownership boot guard. +// [ADR-0131 D7 · 18.x step 2] The settings audit trail's global rung is the +// deployment-level `sys_platform_setting_audit` (ruling record 6257510973, +// Q4 A) — the EXPAND step, on the booted showcase, composed the way `os serve` +// composes it: the audit plugin and the ownership boot guard. // // Two boots over one SQLite file: // -// A. A fresh install (R1). Through the real doors, an operator saves a tenant -// setting, an encrypted global setting and a flow with two credentials. -// The new write shape: no secret and no credential row names an -// organization; the global setting's history row is in the sibling, with -// no organization; the tenant setting's is in `sys_setting_audit`, +// A. A fresh install (R1). Through the real settings door, an operator saves +// a tenant setting and an encrypted global setting. The new write shape: +// the global setting's history row is in the sibling, with no +// organization column; the tenant setting's stays in `sys_setting_audit`, // stamped. -// B. The same database with its rows put in the 18.0 shape — what an 18.0 -// install wrote: the secrets and credentials stamped with the Default -// Organization, and the global setting's history row in -// `sys_setting_audit`, stamped. The boot guard does not refuse it (the -// physical organization columns are DECLARED, so this is not the v17 -// shape); the stamped secret opens, the stamped credentials resolve, the -// global rung's history is served from both tables; and once the stack -// stops, every 18.0 row still holds its organization (expand-first: only -// the upgrade ceremony re-stamps, moves or drops). +// B. The same database with an 18.0-shaped row added — what an 18.0 install +// wrote: the global setting's history row in `sys_setting_audit`, stamped +// with the Default Organization. HEAD boots over it (the boot guard does +// not refuse); the global rung's history is served from both tables, +// selected by SCOPE in `sys_setting_audit` (the 18.0 row is stamped, so a +// NULL-organization selector would miss it); and once the stack stops the +// 18.0 row is still there, stamped (expand-first: only the upgrade +// ceremony moves it). // -// This file builds its own 18.0-shaped rows; the 18.x line's upgrade harness +// This file builds its own 18.0-shaped row; the 18.x line's upgrade harness // boots a whole 18.0 database, and this step runs it once it lands. // // NOT eligible for the shared showcase project: it writes org-wide settings and @@ -39,9 +36,7 @@ import { OrganizationOwnershipBootGuardPlugin } from '@objectstack/runtime'; import type { VerifyStack } from '@objectstack/verify'; import { bootShowcase } from './showcase-boot.js'; -const FLOW = 'expand_18x_inbound'; -/** Placeholders, sealed by the platform like any other value; they open nothing. */ -const HOOK_SECRET = 'expand-18x-not-a-real-hook-secret'; +/** A placeholder, sealed by the platform like any other value; it opens nothing. */ const AI_KEY = 'expand-18x-not-a-real-api-key'; const WORKSPACE = 'Expand 18x Workspace'; const SYSTEM = { context: { isSystem: true } }; @@ -53,24 +48,6 @@ function boot(databaseFile: string): Promise { return bootShowcase({ databaseFile, extraPlugins: [new AuditPlugin(), new OrganizationOwnershipBootGuardPlugin()] }); } -function inboundFlow() { - return { - name: FLOW, - label: 'Expand 18x inbound', - type: 'api', - status: 'active', - nodes: [ - { id: 'start', type: 'start', label: 'Start', config: { hookId: 'h1', secret: HOOK_SECRET } }, - { id: 'call', type: 'http', label: 'Call', config: { url: 'https://example.invalid/out', method: 'POST', signingSecret: HOOK_SECRET } }, - { id: 'end', type: 'end', label: 'End' }, - ], - edges: [ - { id: 'e1', source: 'start', target: 'call' }, - { id: 'e2', source: 'call', target: 'end' }, - ], - }; -} - /** One table's rows, read from the file past every engine. */ function tableRows(file: string, table: string): Array> { const db = new DatabaseSync(file, { readOnly: true }); @@ -81,13 +58,11 @@ function tableRows(file: string, table: string): Array> { } } -describe('[18.x step 2] sys_secret, sys_flow_credential and the global audit rung are deployment-level — expand-first', () => { +describe('[18.x step 2] the settings audit trail\'s global rung is deployment-level — expand-first', () => { const dir = mkdtempSync(join(tmpdir(), 'dogfood-expand-18x-')); const file = join(dir, 'expand.db'); let stack: VerifyStack | undefined; let organizationId = ''; - let secretIds: string[] = []; - let credentialIds: string[] = []; beforeAll(async () => { stack = await boot(file); @@ -108,8 +83,6 @@ describe('[18.x step 2] sys_secret, sys_flow_credential and the global audit run expect(branding.status, await branding.clone().text()).toBe(200); const ai = await put('ai', { gateway_api_key: AI_KEY }); expect(ai.status, await ai.clone().text()).toBe(200); - const flow = await stack.apiAs(tok, 'PUT', `/meta/flow/${FLOW}`, inboundFlow()); - expect([200, 201], await flow.clone().text()).toContain(flow.status); await stack.stop(); stack = undefined; }, 240_000); @@ -119,20 +92,6 @@ describe('[18.x step 2] sys_secret, sys_flow_credential and the global audit run rmSync(dir, { recursive: true, force: true }); }); - it('A. a fresh install writes the new shape: no secret and no credential names an organization', () => { - const secrets = tableRows(file, 'sys_secret'); - secretIds = secrets.map((r) => String(r.id)); - // The ai key's ciphertext, and one per flow credential. - expect(secrets.filter((r) => r.namespace === 'ai' && r.key === 'gateway_api_key')).toHaveLength(1); - expect(secrets.filter((r) => r.namespace === 'sys_flow_credential')).toHaveLength(2); - for (const r of secrets) expect(r.organization_id, `sys_secret ${r.id}`).toBeNull(); - - const credentials = tableRows(file, 'sys_flow_credential').filter((r) => r.flow_name === FLOW); - credentialIds = credentials.map((r) => String(r.id)); - expect(credentials).toHaveLength(2); - for (const r of credentials) expect(r.organization_id, `sys_flow_credential ${r.id}`).toBeNull(); - }); - it('A. the global setting\'s history row is in the sibling, unstamped; the tenant setting\'s stays in sys_setting_audit, stamped', () => { // The boot's own global-rung writes (the auth plugin records its API-key // policy start) land here too, so the operator's row is found by its key. @@ -149,17 +108,15 @@ describe('[18.x step 2] sys_secret, sys_flow_credential and the global audit run ]); }); - describe('B. the same database with its rows in the 18.0 shape', () => { + describe('B. the same database with an 18.0-shaped global audit row', () => { const LEGACY_AUDIT = 'expand18x_legacy_global_audit'; let bootError: unknown; beforeAll(async () => { - // What an 18.0 install wrote: the Default Organization's stamp on every - // secret and credential, and the global rung's history in sys_setting_audit. + // What an 18.0 install wrote: the global rung's history in + // sys_setting_audit, stamped with the Default Organization. const db = new DatabaseSync(file); try { - db.prepare('UPDATE "sys_secret" SET "organization_id" = ?').run(organizationId); - db.prepare('UPDATE "sys_flow_credential" SET "organization_id" = ?').run(organizationId); db.prepare( 'INSERT INTO "sys_setting_audit" ("id", "organization_id", "namespace", "key", "scope", "action", "source", "new_hash", "encrypted", "created_at") ' + 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', @@ -174,28 +131,18 @@ describe('[18.x step 2] sys_secret, sys_flow_credential and the global audit run } }, 240_000); - it('boots: the boot guard does not read the declared, unread organization columns as the v17 shape', () => { + it('boots: nothing about the sibling reads as the v17 shape', () => { expect(bootError, String((bootError as Error | undefined)?.stack ?? bootError)).toBeUndefined(); expect(stack).toBeDefined(); }); - it('the stamped secret opens, and the stamped credentials resolve', async () => { - const settings = await stack!.kernel.getServiceAsync('settings'); - const got = await settings.get('ai', 'gateway_api_key', { tenantId: organizationId }); - expect(got?.value).toBe(AI_KEY); - - const ql = await stack!.kernel.getServiceAsync('objectql'); - for (const id of credentialIds) { - expect(await ql.resolveSecretField('sys_flow_credential', id, 'value'), `credential ${id}`).toBe(HOOK_SECRET); - } - }); - - it('the global rung\'s history is served from both tables: the 18.0 row and the new one', async () => { + it('the global rung\'s history is served from both tables: the 18.0 row selected by scope, and the new one', async () => { const ql = await stack!.kernel.getServiceAsync('objectql'); const where = { namespace: 'ai', key: 'gateway_api_key', scope: 'global' }; const legacy = rowsOf(await ql.find('sys_setting_audit', { where }, SYSTEM)); const current = rowsOf(await ql.find('sys_platform_setting_audit', { where }, SYSTEM)); expect(legacy.map((r) => r.id)).toEqual([LEGACY_AUDIT]); + expect(legacy[0].organization_id).toBe(organizationId); expect(current).toHaveLength(1); // And through the data API, as the platform admin reads each grid. @@ -207,17 +154,9 @@ describe('[18.x step 2] sys_secret, sys_flow_credential and the global audit run } }); - it('every 18.0 row keeps its organization once the stack stops — nothing re-stamped, moved or dropped', async () => { + it('the 18.0 row keeps its place and its organization once the stack stops — nothing moved or re-stamped', async () => { await stack?.stop(); stack = undefined; - const secrets = tableRows(file, 'sys_secret'); - for (const id of secretIds) { - expect(secrets.find((r) => String(r.id) === id)?.organization_id, `sys_secret ${id}`).toBe(organizationId); - } - const credentials = tableRows(file, 'sys_flow_credential'); - for (const id of credentialIds) { - expect(credentials.find((r) => String(r.id) === id)?.organization_id, `sys_flow_credential ${id}`).toBe(organizationId); - } expect(tableRows(file, 'sys_setting_audit').find((r) => r.id === LEGACY_AUDIT)).toEqual( expect.objectContaining({ organization_id: organizationId, scope: 'global' }), ); diff --git a/packages/services/service-automation/src/flow-credential-deployment-level-expand.test.ts b/packages/services/service-automation/src/flow-credential-deployment-level-expand.test.ts deleted file mode 100644 index 695b4505dc4..00000000000 --- a/packages/services/service-automation/src/flow-credential-deployment-level-expand.test.ts +++ /dev/null @@ -1,223 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [ADR-0131 D7 · ruling record 6259141885, Q1 A] `sys_flow_credential` is - * deployment-level — the 18.x line's EXPAND step, on a real ObjectQL engine - * over SQLite. - * - * The rig runs the `single` posture with one organization, as an 18.0 install - * does, so the engine's ADR-0131 D9 stamp is live: the control shows it - * stamping an organization-less system insert into a tenant-column object. The - * same engine writes the channel's rows — and the `sys_secret` rows their - * `value` field mints — with no organization, so that absence is the objects' - * declarations, not a rig that stamps nothing. - * - * The dual read: an 18.0 install stamped both rows with the Default - * Organization. Rows of that shape, written below with the 18.0 columns, are - * still the channel's: indexed, resolved to their plaintext, and left as they - * are — re-stamping, clearing or moving them is the upgrade ceremony's. - * - * Every value is a probe sentinel, not a credential. - */ -import { afterEach, describe, expect, it } from 'vitest'; -import { ObjectQL } from '@objectstack/objectql'; -import { SqlDriver } from '@objectstack/driver-sql'; -import { SysSecret } from '@objectstack/platform-objects'; -import type { CryptoContext, CryptoHandle, ICryptoProvider } from '@objectstack/spec/contracts'; - -import { FLOW_CREDENTIAL_OBJECT, FlowCredentialChannel } from './flow-credential-channel.js'; -import { SysFlowCredential } from './sys-flow-credential.object.js'; - -const DEFAULT_ORG = 'org_default'; -const HOOK = 'pin-expand-hook-41d7'; -const SIGN = 'pin-expand-sign-9b03'; -const LEGACY = 'pin-expand-legacy-c51e'; -const SYSTEM = { isSystem: true } as const; - -/** Reversible stand-in cipher — the seam's behaviour is the engine's, not the cipher's. */ -function fakeCrypto(): ICryptoProvider { - let n = 0; - return { - async encrypt(plain: string, _ctx: CryptoContext): Promise { - n += 1; - return { id: `sec_${n}`, kmsKeyId: 'test', alg: 'test-rev', version: 1, ciphertext: [...plain].reverse().join('') }; - }, - async decrypt(handle: CryptoHandle): Promise { - return [...handle.ciphertext].reverse().join(''); - }, - async rotateKey(handle: CryptoHandle): Promise { - return { ...handle, version: handle.version + 1 }; - }, - digest: (plain: string) => `d:${plain.length}`, - keyedDigest: async (plain: string) => `k:${plain.length}`, - }; -} - -/** The minimal organization object the engine's D9 probe asks for. */ -const ORG_OBJECT = { name: 'sys_organization', fields: { name: { type: 'text' } } } as never; -/** A platform object that keeps its injected organization column: the stamp's control. */ -const STAMPED_PROBE = { name: 'sys_probe_ledger', fields: { subject: { type: 'text' } } } as never; - -const engines: Array<{ ql: ObjectQL; driver: SqlDriver }> = []; -afterEach(async () => { - for (const { ql } of engines.splice(0)) { - try { await (ql as any).destroy?.(); } catch { /* noop */ } - } -}); - -/** - * The two tables as an 18.0 install created them on SQLite — its DDL, verbatim - * in shape: the injected `organization_id` a 255-wide string beside the audit - * columns, and the same indexes. - */ -const DDL_18_0 = [ - "CREATE TABLE `sys_secret` (`id` varchar(255), `created_at` datetime default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), " - + "`updated_at` datetime default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), `organization_id` varchar(255), " - + '`created_by` varchar(255), `updated_by` varchar(255), `rotated_at` datetime, `namespace` varchar(128), ' - + "`key` varchar(128), `kms_key_id` varchar(256), `alg` text default 'aes-256-gcm', `version` float default '1', " - + '`ciphertext` text, primary key (`id`))', - 'CREATE INDEX `idx_sys_secret_kms_key_id` on `sys_secret` (`kms_key_id`)', - 'CREATE INDEX `idx_sys_secret_namespace_key` on `sys_secret` (`namespace`, `key`)', - "CREATE TABLE `sys_flow_credential` (`id` varchar(255), `created_at` datetime default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), " - + "`updated_at` datetime default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), `organization_id` varchar(255), " - + '`created_by` varchar(255), `updated_by` varchar(255), `flow_name` varchar(255), `state` varchar(255), ' - + '`node_id` text, `credential_key` text, `position` varchar(64), `value` varchar(255), primary key (`id`))', - 'CREATE UNIQUE INDEX `uniq_sys_flow_credential_flow_name_state_position` on `sys_flow_credential` (`flow_name`, `state`, `position`)', -]; - -/** - * The real engine over an in-memory SQLite database. `from18` first creates - * the two tables with the 18.0 DDL and hands the 18.0 rows to `seed18`, so the - * engine's schema sync meets an 18.0 database, as an upgrade does. - */ -async function boot( - opts: { from18?: (knex: any) => Promise } = {}, -): Promise<{ ql: ObjectQL; driver: SqlDriver }> { - const ql = new ObjectQL(); - const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); - engines.push({ ql, driver }); - await driver.connect(); - if (opts.from18) { - const knex = (driver as any).knex; - for (const statement of DDL_18_0) await knex.raw(statement); - await opts.from18(knex); - } - ql.registerDriver(driver, true); - await ql.init(); - for (const o of [SysSecret, SysFlowCredential, ORG_OBJECT, STAMPED_PROBE]) { - (ql as any).registry.registerObject(o as never, 'test'); - } - await ql.syncSchemas(); - ql.setCryptoProvider(fakeCrypto()); - ql.setTenancyPostureProvider(() => 'single'); - await (driver as any).knex('sys_organization').insert({ id: DEFAULT_ORG, name: 'Default Organization' }); - return { ql, driver }; -} - -/** The physical rows, read past the engine — what the database holds. */ -async function physical(driver: SqlDriver, table: string): Promise { - return (driver as any).knex(table).select('*'); -} - -/** An inbound flow whose start node holds a hook secret and whose `http` node signs. */ -function inbound() { - return { - name: 'pin_expand', - label: 'Pin expand', - type: 'api', - nodes: [ - { id: 'begin', type: 'start', label: 'Start', config: { triggerType: 'api', hookId: 'h1', secret: HOOK } }, - { id: 'callout', type: 'http', label: 'Callout', config: { url: 'https://example.invalid/out', method: 'POST', signingSecret: SIGN } }, - { id: 'finish', type: 'end', label: 'End' }, - ], - edges: [ - { id: 'e1', source: 'begin', target: 'callout' }, - { id: 'e2', source: 'callout', target: 'finish' }, - ], - }; -} - -describe('[18.x expand · Q1 A] sys_flow_credential: the new write shape, under single with the Default Organization', () => { - it('control: the rig stamps an organization-less system insert into a tenant-column object', async () => { - const { ql, driver } = await boot(); - await ql.insert('sys_probe_ledger', { subject: 'probe' }, { context: SYSTEM } as never); - expect(await physical(driver, 'sys_probe_ledger')).toEqual([expect.objectContaining({ organization_id: DEFAULT_ORG })]); - }); - - it('a channel write stores its rows, and the secrets they mint, with no organization', async () => { - const { ql, driver } = await boot(); - const channel = new FlowCredentialChannel(() => ql as never); - await channel.store({ name: 'pin_expand', state: 'active', body: inbound() }); - - const rows = await physical(driver, FLOW_CREDENTIAL_OBJECT); - expect(rows).toHaveLength(2); - for (const row of rows) expect(row.organization_id).toBeNull(); - const secrets = await physical(driver, 'sys_secret'); - expect(secrets).toHaveLength(2); - for (const secret of secrets) expect(secret.organization_id).toBeNull(); - - // The write shape is still the channel's: both positions resolve. - await channel.loadIndex(); - expect(await channel.resolve('pin_expand', 'begin', 'secret')).toBe(HOOK); - expect(await channel.resolve('pin_expand', 'callout', 'signingSecret')).toBe(SIGN); - }); -}); - -describe('[18.x expand · Q1 A] the dual read, on an 18.0 database', () => { - /** The 18.0 rows: both stamped with the Default Organization, the credential holding a `secret:` ref. */ - const seed18 = async (knex: any) => { - await knex('sys_secret').insert({ - id: 'sec_18', organization_id: DEFAULT_ORG, namespace: FLOW_CREDENTIAL_OBJECT, key: 'value', - kms_key_id: 'test', alg: 'test-rev', version: 1, ciphertext: [...LEGACY].reverse().join(''), - }); - await knex(FLOW_CREDENTIAL_OBJECT).insert({ - id: 'cred_18', organization_id: DEFAULT_ORG, flow_name: 'pin_legacy', state: 'active', node_id: 'begin', - credential_key: 'secret', position: 'p'.repeat(64), value: 'secret:sec_18', - }); - }; - - it('the schema sync over the 18.0 tables proposes no drop, and keeps the organization column', async () => { - const { driver } = await boot({ from18: seed18 }); - for (const table of [FLOW_CREDENTIAL_OBJECT, 'sys_secret']) { - const columns = Object.keys(await (driver as any).knex(table).columnInfo()); - expect(columns, table).toContain('organization_id'); - } - const drift = ((await (driver as any).detectManagedDrift()) as any[]).filter( - (d) => [FLOW_CREDENTIAL_OBJECT, 'sys_secret'].includes(d.table), - ); - // No entry at all: not the destructive `drop_column` of the column the - // objects now leave unread, and no type change on it either. - expect(drift).toEqual([]); - }); - - it('an 18.0 credential and its secret are indexed, resolve, and keep their stamp', async () => { - const { ql, driver } = await boot({ from18: seed18 }); - const channel = new FlowCredentialChannel(() => ql as never); - expect(await channel.loadIndex()).toBe(true); - expect(channel.holds('pin_legacy', 'begin', 'secret')).toBe(true); - expect(await channel.resolve('pin_legacy', 'begin', 'secret')).toBe(LEGACY); - - // Read, not rewritten: the 18.0 stamps are the ceremony's to drop. - expect(await physical(driver, FLOW_CREDENTIAL_OBJECT)).toEqual([ - expect.objectContaining({ id: 'cred_18', organization_id: DEFAULT_ORG }), - ]); - expect(await physical(driver, 'sys_secret')).toEqual([ - expect.objectContaining({ id: 'sec_18', organization_id: DEFAULT_ORG }), - ]); - }); - - it('a new credential lands beside the 18.0 one, unstamped, and both resolve', async () => { - const { ql, driver } = await boot({ from18: seed18 }); - const channel = new FlowCredentialChannel(() => ql as never); - await channel.store({ name: 'pin_expand', state: 'active', body: inbound() }); - await channel.loadIndex(); - expect(await channel.resolve('pin_legacy', 'begin', 'secret')).toBe(LEGACY); - expect(await channel.resolve('pin_expand', 'begin', 'secret')).toBe(HOOK); - - const byId = new Map((await physical(driver, FLOW_CREDENTIAL_OBJECT)).map((r) => [r.id, r])); - expect(byId.get('cred_18')?.organization_id).toBe(DEFAULT_ORG); - const fresh = [...byId.values()].filter((r) => r.flow_name === 'pin_expand'); - expect(fresh).toHaveLength(2); - for (const row of fresh) expect(row.organization_id).toBeNull(); - }); -}); diff --git a/packages/services/service-automation/src/sys-flow-credential.object.ts b/packages/services/service-automation/src/sys-flow-credential.object.ts index df0b13171f7..daa0672b0b4 100644 --- a/packages/services/service-automation/src/sys-flow-credential.object.ts +++ b/packages/services/service-automation/src/sys-flow-credential.object.ts @@ -33,16 +33,6 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * Env-wide, like the engine's flow map, which keys flows by bare name: every * metadata row is env-wide (ADR-0131 D6), a flow's stored rows included. * - * Deployment-level (ADR-0131 D7, ruling record 6259141885 · Q1 A): a - * credential's ownership follows its flow, which is environment metadata, so - * nothing stamps or scopes this table by organization. In the 18.x expand step - * the 18.0 `organization_id` column stays DECLARED, nullable and unread, and - * `tenancy: { enabled: false }` takes the object out of the tenancy machinery. - * With `systemFields: { tenant: false }` and no declared column, the v18 boot - * guard would read the physical column an 18.0 install left here as the v17 - * shape and refuse the boot. The upgrade ceremony drops the column; then this - * object declares `systemFields: { tenant: false }`. - * * Writers: the automation plugin's credential channel (the metadata save door, * the publish promotion, a delete, the one-time migration), under a system * context. Readers: the same channel and the engine's verification and `http` @@ -61,9 +51,6 @@ export const SysFlowCredential = ObjectSchema.create({ // wildcard grant. Everything reads and writes it through the engine under a // system context. access: { default: 'private' }, - // [ADR-0131 D7] Deployment-level: no tenant scope, no stamp. See the header - // for why the 18.0 column is still declared below. - tenancy: { enabled: false }, description: "Write-only store of flow credentials (an inbound hook's secret, an http node's signing secret), one encrypted row per credential position of a flow in one lifecycle state. The flow definition keeps no copy.", displayNameField: 'flow_name', @@ -123,19 +110,6 @@ export const SysFlowCredential = ObjectSchema.create({ defaultValue: 'NOW()', readonly: true, }), - - // The 18.0 organization column, kept until the upgrade ceremony drops it: - // never written, never read. Same physical shape as the column 18.0 - // injected (a 255-wide string), so the schema sync sees no drift. - organization_id: Field.text({ - label: 'Organization (18.0, retired)', - required: false, - readonly: true, - hidden: true, - maxLength: 255, - description: - 'Retired: the organization an 18.0 install stamped on this row. Unread and never written; the upgrade ceremony drops the column.', - }), }, indexes: [ diff --git a/packages/services/service-settings/src/settings-deployment-level-expand.test.ts b/packages/services/service-settings/src/settings-deployment-level-expand.test.ts index 16cbf012466..93a4f35712a 100644 --- a/packages/services/service-settings/src/settings-deployment-level-expand.test.ts +++ b/packages/services/service-settings/src/settings-deployment-level-expand.test.ts @@ -2,27 +2,26 @@ /** * [ADR-0131 D7 · ruling record 6257510973, Q4 A] The 18.x expand step for the - * settings producer: `sys_secret` is deployment-level, and the settings audit - * trail's global rung is `sys_platform_setting_audit`. + * settings audit trail: its global rung is the deployment-level + * `sys_platform_setting_audit`. * * Pinned over a real `ObjectQL` engine and the real platform objects, under the * `single` posture with one organization — the posture an 18.0 install runs — - * so the engine's ADR-0131 D9 stamp is live in this rig. The control below - * shows it stamping an organization-less system insert into - * `sys_setting_audit`; the same engine leaves a `sys_secret` row unstamped, so - * the absence is the object's declaration, not a rig that stamps nothing. + * so the engine's ADR-0131 D9 stamp is live in this rig: the control shows it + * stamping an organization-less system insert into `sys_setting_audit`, so an + * unstamped sibling row is the sibling's shape, not a rig that stamps nothing. * * Two halves, as the 18.x line's expand rule asks: * - * - **The new write shape.** A secret names no organization, at any rung. A - * global-rung audit row goes to `sys_platform_setting_audit`, with no - * organization; a tenant-rung row stays in `sys_setting_audit`, stamped. - * - **The dual read, on 18.0-shaped rows.** An 18.0 install stamped its - * secrets with the Default Organization and wrote its global-rung audit - * rows into `sys_setting_audit`. Those rows are read as they are: the - * stamped secret opens by id, and the 18.0 global audit row is still served - * beside the new one. Nothing re-stamps, clears or moves them — that is the - * upgrade ceremony's. + * - **The new write shape.** A global-rung audit row goes to + * `sys_platform_setting_audit`, with no organization; a tenant-rung row + * stays in `sys_setting_audit`, stamped. + * - **The dual read, on 18.0-shaped rows.** An 18.0 install wrote its + * global-rung audit rows into `sys_setting_audit`, stamped with the Default + * Organization. The global rung's history is selected by SCOPE in that + * table (never by a NULL organization, which those rows do not have) beside + * the sibling; nothing re-stamps or moves them — that is the upgrade + * ceremony's. */ import { describe, it, expect } from 'vitest'; @@ -169,7 +168,7 @@ async function boot() { return { engine, svc, cryptoProvider, rows, seed, writesTo }; } -describe('[18.x expand · Q4 A] the new write shape, under single with the Default Organization', () => { +describe('[18.x expand · Q4 A] the settings audit trail\'s new write shape, under single with the Default Organization', () => { it('control: the rig stamps an organization-less system insert into a tenant-column object', async () => { const { engine, rows } = await boot(); await engine.insert('sys_setting_audit', { @@ -179,31 +178,20 @@ describe('[18.x expand · Q4 A] the new write shape, under single with the Defau expect(rows('sys_setting_audit')).toEqual([expect.objectContaining({ organization_id: DEFAULT_ORG })]); }); - it('a tenant value: the secret names no organization; the audit row is stamped in sys_setting_audit', async () => { - const { svc, rows, writesTo } = await boot(); + it('a tenant value: the audit row is stamped in sys_setting_audit, and the sibling takes nothing', async () => { + const { svc, rows } = await boot(); await svc.setMany('tenant_vault', { token: 's3cret' }, { tenantId: DEFAULT_ORG, userId: undefined }); - const [secretWrite] = writesTo('sys_secret'); - expect(secretWrite?.data).toMatchObject({ namespace: 'tenant_vault', key: 'token' }); - expect(secretWrite?.data).not.toHaveProperty('organization_id'); - expect(secretWrite?.options?.tenantId).toBeUndefined(); - expect(rows('sys_secret')).toHaveLength(1); - expect(rows('sys_secret')[0]!.organization_id ?? null).toBeNull(); - expect(rows('sys_setting_audit')).toEqual([ expect.objectContaining({ namespace: 'tenant_vault', key: 'token', scope: 'tenant', organization_id: DEFAULT_ORG }), ]); expect(rows('sys_platform_setting_audit')).toEqual([]); }); - it('a global value: the secret names no organization; the audit row goes to sys_platform_setting_audit, unstamped', async () => { + it('a global value: the audit row goes to sys_platform_setting_audit, unstamped, and sys_setting_audit takes nothing', async () => { const { svc, rows, writesTo } = await boot(); await svc.setMany('relay_probe', { api_key: 're_123' }, { tenantId: DEFAULT_ORG, userId: undefined }); - expect(rows('sys_secret')).toHaveLength(1); - expect(rows('sys_secret')[0]!.organization_id ?? null).toBeNull(); - expect(writesTo('sys_secret')[0]?.options?.tenantId).toBeUndefined(); - const platformAudit = rows('sys_platform_setting_audit'); expect(platformAudit).toEqual([ expect.objectContaining({ namespace: 'relay_probe', key: 'api_key', scope: 'global', action: 'set', encrypted: true }), @@ -215,29 +203,6 @@ describe('[18.x expand · Q4 A] the new write shape, under single with the Defau }); describe('[18.x expand · Q4 A] the dual read, on 18.0-shaped rows', () => { - it('an 18.0 secret stamped with the Default Organization opens by id, and keeps its stamp', async () => { - const { svc, cryptoProvider, seed, rows } = await boot(); - // The 18.0 shape: the ciphertext row carries the organization its - // `sys_setting` row did, and the setting holds the handle. - const handle = await cryptoProvider.encrypt('legacy-token', { - scope: 'settings', namespace: 'tenant_vault', key: 'token', tenantId: DEFAULT_ORG, - }); - seed('sys_secret', { - id: handle.id, organization_id: DEFAULT_ORG, namespace: 'tenant_vault', key: 'token', - kms_key_id: handle.kmsKeyId, alg: handle.alg, version: handle.version, ciphertext: handle.ciphertext, - }); - seed('sys_setting', { - id: 'set_18', organization_id: DEFAULT_ORG, namespace: 'tenant_vault', key: 'token', scope: 'tenant', - user_id: null, value: null, value_enc: handle.id, encrypted: true, locked: false, - }); - - const got = await svc.get('tenant_vault', 'token', { tenantId: DEFAULT_ORG, userId: undefined }); - expect(got.value).toBe('legacy-token'); - expect(got.source).toBe('tenant'); - // Read, not rewritten: the 18.0 stamp is the ceremony's to drop. - expect(rows('sys_secret')).toEqual([expect.objectContaining({ id: handle.id, organization_id: DEFAULT_ORG })]); - }); - it('the global rung\'s history reads both tables: the 18.0 row in sys_setting_audit and the new one in the sibling', async () => { const { engine, svc, seed } = await boot(); // The 18.0 shape: a global-rung audit row in sys_setting_audit, stamped diff --git a/packages/services/service-settings/src/settings-secret-organization.test.ts b/packages/services/service-settings/src/settings-secret-organization.test.ts index 79d553a202f..0f3f75cf5b2 100644 --- a/packages/services/service-settings/src/settings-secret-organization.test.ts +++ b/packages/services/service-settings/src/settings-secret-organization.test.ts @@ -1,19 +1,15 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * The two rows a settings write lands BESIDE its settings row — the - * `sys_secret` ciphertext of an encrypted value and the per-key audit history - * row. - * - * - The ciphertext is deployment-level (ADR-0131 D7, ruling record - * 6257510973 · Q4 A): a secret is owned by the row that holds its handle, so - * it names no organization, whatever the setting's rung. - * - The history row belongs to the organization the setting row does: the - * caller's, for a `tenant` or `user` value (ADR-0131 D3/D9), in - * `sys_setting_audit`. A `global` value is deployment-level, so its row - * names none and goes to `sys_platform_setting_audit`, which has no - * organization column (Q4 A) — the split `sys_setting` / - * `sys_platform_setting` makes for the values. + * [ADR-0131 D3/D9] The two rows a settings write lands BESIDE its `sys_setting` + * row — the `sys_secret` ciphertext of an encrypted value and the per-key + * `sys_setting_audit` history row — belong to the organization the setting + * row does: the caller's, for a `tenant` or `user` value. A `global` value is + * deployment-level (its row lives in `sys_platform_setting`, which has no + * organization), so neither row names one. Its history row goes to + * `sys_platform_setting_audit`, the audit trail's deployment-level global rung + * (ADR-0131 D7, ruling record 6257510973 · Q4 A); the secret's fate is the + * 18.x ceremony's. */ import { randomBytes } from 'node:crypto'; @@ -60,14 +56,13 @@ function boot() { return { svc, secrets, history }; } -describe('[ADR-0131 D7/D9] a settings secret names no organization; its history row names the setting\'s', () => { - it('a tenant value: the ciphertext names no organization, the history row carries the caller\'s', async () => { +describe('[ADR-0131 D3/D9] settings secret and history rows name their setting\'s organization', () => { + it('a tenant value: the ciphertext and the history row carry the caller\'s organization', async () => { const { svc, secrets, history } = boot(); await svc.setMany('tenant_vault', { token: 's3cret' }, { tenantId: 'org-1', userId: 'usr-1' }); expect(secrets).toHaveLength(1); - expect(secrets[0]).toMatchObject({ namespace: 'tenant_vault', key: 'token' }); - expect('organization_id' in secrets[0]).toBe(false); + expect(secrets[0]).toMatchObject({ namespace: 'tenant_vault', key: 'token', organization_id: 'org-1' }); expect(history.filter((e) => e.key === 'token')).toEqual([ expect.objectContaining({ scope: 'tenant', encrypted: true, organizationId: 'org-1' }), ]); diff --git a/packages/services/service-settings/src/settings-service.ts b/packages/services/service-settings/src/settings-service.ts index 4ab597bbc4e..e3a1e5290c8 100644 --- a/packages/services/service-settings/src/settings-service.ts +++ b/packages/services/service-settings/src/settings-service.ts @@ -2117,9 +2117,11 @@ export class SettingsService { alg: handle.alg, version: handle.version, ciphertext: handle.ciphertext, - // [ADR-0131 D7, ruling record 6257510973 · Q4 A] No - // organization: the secret is deployment-level, owned by the - // settings row that holds its handle in `value_enc`. + // [ADR-0131 D3/D9] The ciphertext of a tenant or user value is + // that organization's, as its `sys_setting` row is. A global + // value's is deployment-level and names none (see + // `SettingsSecretStore.insert`). + ...(organizationId ? { organization_id: organizationId } : {}), }); storedEnc = handle.id; digest = await this.secretAuditDigest(namespace, key, plain); diff --git a/packages/services/service-settings/src/settings-service.types.ts b/packages/services/service-settings/src/settings-service.types.ts index c094bc8015c..ecb09446119 100644 --- a/packages/services/service-settings/src/settings-service.types.ts +++ b/packages/services/service-settings/src/settings-service.types.ts @@ -242,8 +242,13 @@ export interface SettingsSecretStore { alg: string; version: number; ciphertext: string; - // [ADR-0131 D7, ruling record 6257510973 · Q4 A] No organization: a - // secret is deployment-level, owned by the row that holds its handle. + /** + * [ADR-0131 D3/D9] The organization that owns the secret — the owning + * `sys_setting` row's, for a tenant or user value. Absent for a global + * value: a deployment-level secret, whose fate in this tenant-column + * store is not settled yet. + */ + organization_id?: string; }): Promise<{ id: string }>; /** Look up the latest ciphertext for a handle id; null when missing. */ get(id: string): Promise<{ diff --git a/scripts/platform-object-tenancy-census.json b/scripts/platform-object-tenancy-census.json index 3f3fb7cf4d4..a3f38966041 100644 --- a/scripts/platform-object-tenancy-census.json +++ b/scripts/platform-object-tenancy-census.json @@ -25,13 +25,13 @@ "population": "every object registered by a tracked packages/**/*.object.ts module whose name carries a platform prefix (sys_ / cloud_ / ai_). The cloud repository's own cloud_ objects are not in this tree and so not in this census.", "totals": { "registered": 84, - "inReach": 42, - "outOfReach": 42 + "inReach": 44, + "outOfReach": 40 }, "reasonTotals": { "managedBy: 'better-auth'": 25, "systemFields.tenant: false": 15, - "tenancy.enabled: false": 4 + "tenancy.enabled: false": 2 }, "unexplained": [], "objects": [ @@ -187,11 +187,9 @@ { "name": "sys_flow_credential", "file": "packages/services/service-automation/src/sys-flow-credential.object.ts", - "reach": "out", - "tenantField": null, - "reasons": [ - "tenancy.enabled: false" - ] + "reach": "in", + "tenantField": "organization_id", + "reasons": [] }, { "name": "sys_flow_dispatch", @@ -582,11 +580,9 @@ { "name": "sys_secret", "file": "packages/platform-objects/src/system/sys-secret.object.ts", - "reach": "out", - "tenantField": null, - "reasons": [ - "tenancy.enabled: false" - ] + "reach": "in", + "tenantField": "organization_id", + "reasons": [] }, { "name": "sys_session", From 070d81252b3c534540607a78cd6e8782808efaa2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 14:53:34 +0000 Subject: [PATCH 09/11] chore(docs): tenant-audit census regenerated after sys_secret and sys_flow_credential stayed tenant-scoped Claude-Session: https://claude.ai/code/session_01HZT9daB4YX41N1SVVxYcek Co-authored-by: Claude --- .../docs/permissions/tenant-audit-census.mdx | 6 ++--- ...08-tenant-audit-write-call-sites.counts.md | 22 +++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index a50429a2fd8..470d23e08ae 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -226,8 +226,8 @@ cannot read, and they are neither in nor out. | write call sites on the application surface | **232** | | …whose object name is statically decidable | 161 | | …whose object name is chosen at run time | 71 | -| …against an object with tenancy ENABLED | 147 | -| …against an object that declares tenancy off | 14 | +| …against an object with tenancy ENABLED | 160 | +| …against an object that declares tenancy off | 1 | | threading a tenant context | 172 | | PROVABLY carrying none (options read, no context key) | **8** | | …of those, against a decidably tenancy-enabled object | **2** | @@ -297,7 +297,7 @@ 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-19 at `fc628bc39`. +Measured on 2026-10-19 at `e09294fe1`. | corpus scale (not enforced) | count | | :--- | ---: | diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index 56b43355cd2..fda19b351d4 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -36,8 +36,8 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Write call sites | 232 | | Object name statically decidable | 161 | | Object name chosen at run time | 71 | -| Against a tenancy-enabled object | 147 | -| Against an object declaring tenancy off | 14 | +| Against a tenancy-enabled object | 160 | +| Against an object declaring tenancy off | 1 | | Threading a tenant context | 172 | | Provably carrying none | 8 | | …and decidably tenancy-enabled | 2 | @@ -90,7 +90,7 @@ 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-19 at `fc628bc39`. +Measured on 2026-10-19 at `e09294fe1`. | corpus scale (not enforced) | count | | :--- | ---: | @@ -189,9 +189,9 @@ Measured on 2026-10-19 at `fc628bc39`. | `packages/services/service-automation/src/builtin/crud-nodes.ts` | `delete` | `objectName` | undecidable | context, elevation undecidable | 1 | | `packages/services/service-automation/src/builtin/crud-nodes.ts` | `insert` | `objectName` | undecidable | context, elevation undecidable | 1 | | `packages/services/service-automation/src/builtin/crud-nodes.ts` | `update` | `objectName` | undecidable | context, elevation undecidable | 1 | -| `packages/services/service-automation/src/flow-credential-channel.ts` | `delete` | `sys_flow_credential` | disabled | elevated | 5 | -| `packages/services/service-automation/src/flow-credential-channel.ts` | `insert` | `sys_flow_credential` | disabled | elevated | 1 | -| `packages/services/service-automation/src/flow-credential-channel.ts` | `update` | `sys_flow_credential` | disabled | elevated | 2 | +| `packages/services/service-automation/src/flow-credential-channel.ts` | `delete` | `sys_flow_credential` | enabled | elevated | 5 | +| `packages/services/service-automation/src/flow-credential-channel.ts` | `insert` | `sys_flow_credential` | enabled | elevated | 1 | +| `packages/services/service-automation/src/flow-credential-channel.ts` | `update` | `sys_flow_credential` | enabled | elevated | 2 | | `packages/services/service-automation/src/flow-credential-migration.ts` | `insert` | `DATA_MIGRATION_FLAG_OBJECT` | undecidable | elevated | 1 | | `packages/services/service-automation/src/flow-credential-migration.ts` | `update` | `DATA_MIGRATION_FLAG_OBJECT` | undecidable | elevated | 1 | | `packages/services/service-automation/src/flow-dispatch-store.ts` | `insert` | `sys_flow_dispatch` | enabled | elevated | 1 | @@ -202,8 +202,8 @@ Measured on 2026-10-19 at `fc628bc39`. | `packages/services/service-datasource/src/datasource-admin-plugin.ts` | `delete` | `sys_metadata` | enabled | elevated | 1 | | `packages/services/service-datasource/src/datasource-admin-plugin.ts` | `insert` | `sys_metadata` | enabled | elevated | 1 | | `packages/services/service-datasource/src/datasource-admin-plugin.ts` | `update` | `sys_metadata` | enabled | elevated | 2 | -| `packages/services/service-datasource/src/datasource-secret-binder.ts` | `delete` | `sys_secret` | disabled | elevated | 1 | -| `packages/services/service-datasource/src/datasource-secret-binder.ts` | `insert` | `sys_secret` | disabled | elevated | 1 | +| `packages/services/service-datasource/src/datasource-secret-binder.ts` | `delete` | `sys_secret` | enabled | elevated | 1 | +| `packages/services/service-datasource/src/datasource-secret-binder.ts` | `insert` | `sys_secret` | enabled | elevated | 1 | | `packages/services/service-job/src/db-job-adapter.ts` | `insert` | `sys_job` | enabled | elevated | 1 | | `packages/services/service-job/src/db-job-adapter.ts` | `update` | `sys_job` | enabled | elevated | 3 | | `packages/services/service-job/src/db-job-adapter.ts` | `insert` | `sys_job_run` | enabled | elevated | 1 | @@ -230,9 +230,9 @@ Measured on 2026-10-19 at `fc628bc39`. | `packages/services/service-settings/src/settings-service-plugin.ts` | `insert` | `objectName` | undecidable | options unreadable | 1 | | `packages/services/service-settings/src/settings-service-plugin.ts` | `update` | `objectName` | undecidable | options unreadable | 2 | | `packages/services/service-settings/src/settings-service-plugin.ts` | `insert` | `sys_platform_setting_audit` | enabled | elevated | 1 | -| `packages/services/service-settings/src/settings-service-plugin.ts` | `delete` | `sys_secret` | disabled | elevated | 1 | -| `packages/services/service-settings/src/settings-service-plugin.ts` | `insert` | `sys_secret` | disabled | elevated | 1 | -| `packages/services/service-settings/src/settings-service-plugin.ts` | `update` | `sys_secret` | disabled | elevated | 1 | +| `packages/services/service-settings/src/settings-service-plugin.ts` | `delete` | `sys_secret` | enabled | elevated | 1 | +| `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` | `object` | undecidable | context, elevation undecidable | 1 | | `packages/services/service-settings/src/settings-service.ts` | `update` | `object` | undecidable | context, elevation undecidable | 1 | From 237db494eba8aa0c64b56dbd5cad8dfda76ca101 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 15:46:47 +0000 Subject: [PATCH 10/11] chore(service-settings): tenant-audit census regenerated on the merged tree; the read-door docblock and dual-write title name the sibling Claude-Session: https://claude.ai/code/session_01HZT9daB4YX41N1SVVxYcek Co-authored-by: Claude --- content/docs/permissions/tenant-audit-census.mdx | 2 +- docs/audits/2026-08-tenant-audit-write-call-sites.counts.md | 2 +- .../services/service-settings/src/config-change-audit.test.ts | 2 +- packages/services/service-settings/src/settings-service.ts | 3 ++- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index 470d23e08ae..6483ef73753 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -297,7 +297,7 @@ 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-19 at `e09294fe1`. +Measured on 2026-10-19 at `5d8d4addc`. | corpus scale (not enforced) | count | | :--- | ---: | diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index fda19b351d4..1c8844e742c 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -90,7 +90,7 @@ 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-19 at `e09294fe1`. +Measured on 2026-10-19 at `5d8d4addc`. | corpus scale (not enforced) | count | | :--- | ---: | diff --git a/packages/services/service-settings/src/config-change-audit.test.ts b/packages/services/service-settings/src/config-change-audit.test.ts index ecadbbd5d24..b0b158404a0 100644 --- a/packages/services/service-settings/src/config-change-audit.test.ts +++ b/packages/services/service-settings/src/config-change-audit.test.ts @@ -462,7 +462,7 @@ describe('#8145 — a settings write reaches sys_audit_log as `config_change`', // 2. Dual-write: the settings-specific ledger is UNCHANGED // --------------------------------------------------------------------------- -describe('#8145 — dual-write: `sys_setting_audit` keeps its rows', () => { +describe('#8145 — dual-write: the settings audit trail keeps its rows (the global rung in `sys_platform_setting_audit`)', () => { it('one write leaves exactly one row on EACH ledger', async () => { const boot = await bootPlugin({ userId: 'usr_admin', tenantId: 'org_1' }); diff --git a/packages/services/service-settings/src/settings-service.ts b/packages/services/service-settings/src/settings-service.ts index e3a1e5290c8..cfeb5fe9fed 100644 --- a/packages/services/service-settings/src/settings-service.ts +++ b/packages/services/service-settings/src/settings-service.ts @@ -1331,7 +1331,8 @@ export class SettingsService { /** * The `namespace` predicate the generic read door ANDs onto a principal's * read of a settings store (`sys_setting`, `sys_setting_audit`, - * `sys_platform_setting`), given the capabilities the principal holds — or + * `sys_platform_setting`, `sys_platform_setting_audit`), given the + * capabilities the principal holds — or * `null` when it may read every namespace there is. * * The settings door has always applied a namespace's `readPermission` From 2b295cabd4d838576682f001485858831f0be334 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 17:10:11 +0000 Subject: [PATCH 11/11] chore: regenerate the tenancy census and the tenant-audit census on the merged tree (both siblings: sys_identity_email, sys_platform_setting_audit) Claude-Session: https://claude.ai/code/session_01HZT9daB4YX41N1SVVxYcek Co-authored-by: Claude --- .../docs/permissions/tenant-audit-census.mdx | 34 +++++++++---------- ...08-tenant-audit-write-call-sites.counts.md | 15 ++++---- scripts/platform-object-tenancy-census.json | 15 ++++++-- 3 files changed, 37 insertions(+), 27 deletions(-) diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index 17a6c38d3f0..f4daf3476a2 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -83,7 +83,7 @@ what moved this page's population from 225 to 227; nothing about the two sites changed, only whether this instrument could see them. **The expensive failure direction is a keyword.** Sites whose receiver the author -typed `any` have no type to read, and there are 40 of them — just under a fifth +typed `any` have no type to read, and there are 41 of them — just under a fifth of the population, concentrated in exactly the seed and bootstrap paths this control exists for. Scoring an unreadable receiver as "not an engine" would have dropped every one of them silently, with a clean exit and a smaller number that @@ -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 **52 of the 231 sites are spelled that way**. A +forwarding shim cannot, and **52 of the 232 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. @@ -187,10 +187,10 @@ reproduce them. Where it disagrees, it disagrees on the page: | carried figure | where it survives | this census | | :--- | :--- | ---: | -| 175 write call sites | quoted in the merged changeset | **231** | +| 175 write call sites | quoted in the merged changeset | **232** | | 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **31** more whose options argument is unreadable | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **157 of 231** decidable, **74** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 128 decidably elevated, 0 decidably not, 95 undecidable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **158 of 232** decidable, **74** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 129 decidably elevated, 0 decidably not, 95 undecidable | | 141 and 132, two independent re-derivations | the card that filed this work | — | **The differences are not reconciled, and deliberately so.** The old census's @@ -200,18 +200,18 @@ be stated is what this instrument counts, which is written above and re-runnable at any commit. Two structural facts do plausibly widen this reading against any hand or regex -one, and both are counted in the generated tables below: the 40 sites reached +one, and both are counted in the generated tables below: the 41 sites reached through an erased (`any`) receiver, and the 51 that name their object through a `const` rather than inline. An instrument that read either the way a person does would report a smaller number and would not say so. The fourth row is the one worth flagging to anyone citing it. **The 135 / 77% figure has no surviving corroboration anywhere in the tree.** This census reads -128 of 231 (55%) as decidably elevated, with 95 more whose elevation is a +129 of 232 (56%) as decidably elevated, with 95 more whose elevation is a run-time fact — so the claim is neither confirmed nor refuted, and the honest answer is that a static reading cannot settle it. -⇒ **Cite `2 / 231`, and say what it is**: the sites whose options argument was +⇒ **Cite `2 / 232`, and say what it is**: the sites whose options argument was READ and holds no tenant context, against a decidably tenancy-enabled object. That is the control's provable yield surface. ⛔ Do not cite it as "the sites without tenant context" — **31 further sites** have an options argument this @@ -223,28 +223,28 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **231** | -| …whose object name is statically decidable | 157 | +| write call sites on the application surface | **232** | +| …whose object name is statically decidable | 158 | | …whose object name is chosen at run time | 74 | -| …against an object with tenancy ENABLED | 156 | +| …against an object with tenancy ENABLED | 157 | | …against an object that declares tenancy off | 1 | -| threading a tenant context | 171 | +| threading a tenant context | 172 | | 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 | 52 | | …of those, against a decidably tenancy-enabled object | 31 | -| threading a decidably ELEVATED (`isSystem`) context | 128 | +| threading a decidably ELEVATED (`isSystem`) context | 129 | | threading a context that is decidably NOT elevated | 0 | | threading a context whose elevation is a run-time fact | 95 | | how the instrument reached the site | count | | :--- | ---: | | receiver carried a readable engine type | 191 | -| receiver erased, placed by the object NAME | 30 | +| receiver erased, placed by the object NAME | 31 | | receiver erased, placed by an `object: string` PARAMETER | 5 | | receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | -| object name spelled inline | 106 | +| object name spelled inline | 107 | | object name spelled through a `const` | 51 | | object name is an `object: string` parameter | 7 | | object name is some other run-time expression | 67 | @@ -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-19 at `ebe85c6d3`. +Measured on 2026-10-19 at `030483e7f`. | corpus scale (not enforced) | count | | :--- | ---: | | tracked non-test sources scanned | 672 | | engine-shaped types recognised | 79 | -| declared objects in the registry | 117 | +| declared objects in the registry | 118 | | same-named calls subtracted as non-engine | 166 | {/* END GENERATED: tenant-audit-census */} diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index 9522c1e6add..d26e4b18034 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 231 | -| Object name statically decidable | 157 | +| Write call sites | 232 | +| Object name statically decidable | 158 | | Object name chosen at run time | 74 | -| Against a tenancy-enabled object | 156 | +| Against a tenancy-enabled object | 157 | | Against an object declaring tenancy off | 1 | -| Threading a tenant context | 171 | +| Threading a tenant context | 172 | | Provably carrying none | 8 | | …and decidably tenancy-enabled | 2 | | Options argument unreadable | 52 | | …and decidably tenancy-enabled | 31 | -| Threading a decidably elevated context | 128 | +| Threading a decidably elevated context | 129 | | Threading a decidably non-elevated context | 0 | | Threading a context of undecidable elevation | 95 | @@ -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-19 at `ebe85c6d3`. +Measured on 2026-10-19 at `030483e7f`. | corpus scale (not enforced) | count | | :--- | ---: | | tracked non-test sources scanned | 672 | | engine-shaped types recognised | 79 | -| declared objects in the registry | 117 | +| declared objects in the registry | 118 | | same-named calls subtracted as non-engine | 166 | ## Every site @@ -229,6 +229,7 @@ Measured on 2026-10-19 at `ebe85c6d3`. | `packages/services/service-settings/src/config-change-audit.ts` | `insert` | `sys_audit_log` | enabled | context, elevation undecidable | 1 | | `packages/services/service-settings/src/settings-service-plugin.ts` | `insert` | `objectName` | undecidable | options unreadable | 1 | | `packages/services/service-settings/src/settings-service-plugin.ts` | `update` | `objectName` | undecidable | options unreadable | 2 | +| `packages/services/service-settings/src/settings-service-plugin.ts` | `insert` | `sys_platform_setting_audit` | enabled | elevated | 1 | | `packages/services/service-settings/src/settings-service-plugin.ts` | `delete` | `sys_secret` | enabled | elevated | 1 | | `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 | diff --git a/scripts/platform-object-tenancy-census.json b/scripts/platform-object-tenancy-census.json index a3f38966041..177a3745cab 100644 --- a/scripts/platform-object-tenancy-census.json +++ b/scripts/platform-object-tenancy-census.json @@ -24,13 +24,13 @@ "predicate": "resolveTenantFieldName(registered schema) !== null, where the registered schema is the authored schema plus the columns resolveInjectedSystemColumns says the registration injects (the applySystemFields pass). Both functions are loaded from source and executed; neither is re-spelled here.", "population": "every object registered by a tracked packages/**/*.object.ts module whose name carries a platform prefix (sys_ / cloud_ / ai_). The cloud repository's own cloud_ objects are not in this tree and so not in this census.", "totals": { - "registered": 84, + "registered": 85, "inReach": 44, - "outOfReach": 40 + "outOfReach": 41 }, "reasonTotals": { "managedBy: 'better-auth'": 25, - "systemFields.tenant: false": 15, + "systemFields.tenant: false": 16, "tenancy.enabled: false": 2 }, "unexplained": [], @@ -207,6 +207,15 @@ "tenantField": "organization_id", "reasons": [] }, + { + "name": "sys_identity_email", + "file": "packages/platform-objects/src/audit/sys-identity-email.object.ts", + "reach": "out", + "tenantField": null, + "reasons": [ + "systemFields.tenant: false" + ] + }, { "name": "sys_import_job", "file": "packages/platform-objects/src/audit/sys-import-job.object.ts",