From 2734290db77188a5a9e2791b9861e50d3659da1b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 19 Sep 2026 09:38:23 +0000 Subject: [PATCH 1/3] feat(security): record platform-admin standing on the audit ledger at boot Config-derived platform-admin standing left no durable record of who held it and from when. The boot already computed the per-entry summary and threw it into a log line; it is now written to the existing audit ledger as one entry per CHANGE of standing, plus the first-boot baseline. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF --- .../sys-audit-log-retired-actions.test.ts | 5 + .../src/objects/sys-audit-log.object.ts | 32 +- .../src/translations/en.objects.generated.ts | 3 +- .../translations/es-ES.objects.generated.ts | 3 +- .../translations/ja-JP.objects.generated.ts | 3 +- .../translations/zh-CN.objects.generated.ts | 3 +- .../src/bootstrap-platform-admin.ts | 165 ++++++- .../src/platform-admin-standing-audit.test.ts | 430 ++++++++++++++++++ .../src/platform-admin-standing-audit.ts | 266 +++++++++++ 9 files changed, 903 insertions(+), 7 deletions(-) create mode 100644 packages/plugins/plugin-security/src/platform-admin-standing-audit.test.ts create mode 100644 packages/plugins/plugin-security/src/platform-admin-standing-audit.ts diff --git a/packages/plugins/plugin-audit/src/objects/sys-audit-log-retired-actions.test.ts b/packages/plugins/plugin-audit/src/objects/sys-audit-log-retired-actions.test.ts index 762a6233d73..d5f87ae7883 100644 --- a/packages/plugins/plugin-audit/src/objects/sys-audit-log-retired-actions.test.ts +++ b/packages/plugins/plugin-audit/src/objects/sys-audit-log-retired-actions.test.ts @@ -79,6 +79,11 @@ const ACTIONS_WITH_WRITERS: ReadonlyArray = { login: "login", logout: "logout", config_change: "config_change", - import: "import" + import: "import", + platform_admin_standing_change: "platform_admin_standing_change" } }, user_id: { diff --git a/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts index ebb9cffe404..964c47bf678 100644 --- a/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts @@ -34,7 +34,8 @@ export const esESObjects: NonNullable = { login: "Inicio de sesión", logout: "Cierre de sesión", config_change: "Cambio de configuración", - import: "Importar" + import: "Importar", + platform_admin_standing_change: "Cambio de acceso de administrador de plataforma" } }, user_id: { diff --git a/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts index 3d5e41290c0..4eb57cf6f43 100644 --- a/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts @@ -34,7 +34,8 @@ export const jaJPObjects: NonNullable = { login: "ログイン", logout: "ログアウト", config_change: "構成変更", - import: "インポート" + import: "インポート", + platform_admin_standing_change: "プラットフォーム管理者権限の変更" } }, user_id: { diff --git a/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts index f33475c9ba6..704987edb7e 100644 --- a/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts @@ -34,7 +34,8 @@ export const zhCNObjects: NonNullable = { login: "登录", logout: "登出", config_change: "配置变更", - import: "导入" + import: "导入", + platform_admin_standing_change: "平台管理员权限变更" } }, user_id: { diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts index 886334b89c5..445b22fd165 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts @@ -90,7 +90,148 @@ import { reportSeedWriteRefusals, type SeedWriteRefusals, } from './per-organization-catalog.js'; -import { resolvePlatformAdminStanding } from './platform-admin-service.js'; +import { + resolvePlatformAdminStanding, + type PlatformAdminStandingEntry, +} from './platform-admin-service.js'; +import { + buildPlatformAdminStandingRow, + platformAdminStandingChanged, + platformAdminStandingSnapshot, + PLATFORM_ADMIN_STANDING_ACTION, + PLATFORM_ADMIN_STANDING_LEDGER, + readRecordedStandingSnapshot, + serializePlatformAdminStandingSnapshot, +} from './platform-admin-standing-audit.js'; + +/** + * The order the standing-audit read states TO THE DRIVER. + * + * `created_at` and not `id`: `sys_audit_log` ids carry no ordering, while + * `created_at` is the field every shipped list view on that object already + * sorts by, and it is the column the row's own meaning rests on. The read is + * capped at one row, and a capped read without an order returns whichever row + * that driver produced first — see {@link tryFind}. + */ +const STANDING_AUDIT_SCAN_ORDER: { field: string; order: 'asc' | 'desc' }[] = [ + { field: 'created_at', order: 'desc' }, +]; + +/** + * [#18412] Record a CHANGE of platform-admin standing on the existing audit + * ledger, and write nothing when nothing changed. + * + * ## Three states, and the third is the one a two-valued read would lose + * + * - the ledger is not mounted → SKIP, silently. `sys_audit_log` belongs to the + * OPTIONAL `@objectstack/plugin-audit`; a host that never mounted it + * (`serve --preset minimal`, an EE host that composes no audit) has not + * FAILED to write a row, it declined to have a ledger. Attempting the insert + * would throw and be reported as a degradation on a deployment behaving + * exactly as composed — the #18368 lesson, one file over. + * - the ledger is mounted and the last row is readable → compare, and write + * only on a difference. + * - ⛔ the read was REFUSED → write NOTHING and say so. A refusal is not + * 「no record exists」: reading it that way writes a fresh baseline on every + * boot, which is precisely the per-boot noise the ruling rejected. The + * record stays silent for this boot and the next successful read catches up, + * because the comparison is against the ledger rather than against a memo. + * + * Best-effort throughout: an audit write must never be what stops a deployment + * booting, and this runs after the bootstrap has already done its work. + */ +async function recordPlatformAdminStandingChange( + ql: any, + standing: readonly PlatformAdminStandingEntry[], + logger?: BootstrapOptions['logger'] & { debug?: (message: string) => void }, +): Promise { + // ⛔ Ask before writing, and only an engine that ANSWERS licenses the skip. + // `getSchema` is an ObjectQL member, not an `IDataEngine` one, so an engine + // that does not carry it has told us NOTHING about the ledger — that case + // leaves the write attempted rather than skipped. + const getSchema = ql?.getSchema; + let ledgerSchema: any; + if (typeof getSchema === 'function') { + try { + ledgerSchema = ql.getSchema(PLATFORM_ADMIN_STANDING_LEDGER); + } catch { + ledgerSchema = undefined; + } + if (ledgerSchema == null) return; + } + + const declared: string[] = Array.isArray(ledgerSchema?.fields) + ? ledgerSchema.fields.map((f: any) => f?.name).filter(Boolean) + : ledgerSchema?.fields && typeof ledgerSchema.fields === 'object' + ? Object.keys(ledgerSchema.fields) + : []; + const declaresField = (field: string): boolean => declared.includes(field); + + let refused: unknown; + const previousRows = await tryFind( + ql, + PLATFORM_ADMIN_STANDING_LEDGER, + { action: PLATFORM_ADMIN_STANDING_ACTION }, + 1, + STANDING_AUDIT_SCAN_ORDER, + undefined, + (e) => { + refused = e; + }, + ); + if (refused !== undefined) { + const message = + '[security] platform-admin standing was NOT recorded on this boot: the audit ledger ' + + `(${PLATFORM_ADMIN_STANDING_LEDGER}) refused the read of the last recorded snapshot, so ` + + 'this boot cannot tell whether standing changed. ⛔ Nothing was written — writing a ' + + 'baseline here would file a fresh row on every boot. The next boot whose read succeeds ' + + 'records the current standing. Cause: ' + + String((refused as any)?.message ?? refused); + if (logger?.error) logger.error(message); + else logger?.warn?.(message); + return; + } + + const snapshot = platformAdminStandingSnapshot(standing); + const serialized = serializePlatformAdminStandingSnapshot(snapshot); + const previousSerialized = readRecordedStandingSnapshot(previousRows[0]); + if (!platformAdminStandingChanged(previousSerialized, serialized)) { + logger?.debug?.( + '[security] platform-admin standing is unchanged since the last recorded entry — no ' + + 'audit row written. One entry per CHANGE of standing is the recorded shape (#18412).', + ); + return; + } + + const row = buildPlatformAdminStandingRow({ + snapshot, + previousSerialized, + declaresOrganizationId: declaresField('organization_id'), + declaresActor: declaresField('actor'), + }); + // ⛔ Through this file's ONE write door, not a second `ql.insert` beside it. + // `tryInsert` already threads the system execution context every write here + // needs, and a second door with identical semantics would be a second place + // for that context to be forgotten — it is also a second row on the + // tenant-audit write-call-site census for one write. + let insertRefusal: unknown; + await tryInsert(ql, PLATFORM_ADMIN_STANDING_LEDGER, row, undefined, (e) => { + insertRefusal = e; + }); + if (insertRefusal !== undefined) { + // The ledger IS mounted (or could not be asked) and the insert still + // failed, which is AGENTS.md's durability degradation to the letter: the + // deployment's administrators just changed and the record that was + // supposed to outlive the process is missing. Loud, and never fatal. + const message = + '[security] platform-admin standing CHANGED and the audit row was NOT written — the ' + + 'durable record of who administers this deployment is missing for this change, and ' + + 'nothing retries it. Boot itself is unaffected. Cause: ' + + String((insertRefusal as any)?.message ?? insertRefusal); + if (logger?.error) logger.error(message); + else logger?.warn?.(message); + } +} interface BootstrapOptions { /** Logger from PluginContext. */ @@ -226,6 +367,13 @@ async function tryFind( limit = 100, orderBy?: { field: string; order: 'asc' | 'desc' }[], offset?: number, + // ⛔ "Refused" and "empty" are not the same answer, and on most call sites in + // this file the difference is harmless because `[]` is the conservative + // reading. On the standing-audit read below it is NOT: `[]` would read as + // "no record has ever been written", which writes a duplicate baseline on + // every boot of a rig whose driver refused the query. A caller that cannot + // afford that conflation passes this and is told. + onRefusal?: (error: unknown) => void, ): Promise { try { const query: Record = { where, limit }; @@ -233,7 +381,8 @@ async function tryFind( if (offset !== undefined) query.offset = offset; const rows = await ql.find(object, query, { context: SYSTEM_CTX }); return Array.isArray(rows) ? rows : []; - } catch { + } catch (e) { + onRefusal?.(e); return []; } } @@ -246,11 +395,16 @@ async function tryFind( // boots. See `reportSeedWriteRefusals` in `per-organization-catalog.ts`. async function tryInsert( ql: any, object: string, data: any, refusals?: SeedWriteRefusals, + // Symmetric with {@link tryFind}'s observer, and for the same reason: a + // caller outside the SEED pass needs the refusal itself, not a `null` that + // the seed reporter will later summarize on a channel that is not its own. + onRefusal?: (error: unknown) => void, ): Promise { try { return await ql.insert(object, data, { context: SYSTEM_CTX }); } catch (e) { refusals?.record(object, e); + onRefusal?.(e); return null; } } @@ -789,6 +943,13 @@ export async function bootstrapPlatformAdmin( `at request time. ${summary}`, { standing: standing.map((s) => ({ ...s })) }, ); + // [#18412] …and the SAME answer, recorded durably. The log line above is + // the operator's first sight of it and nothing else: it is not queryable, + // it does not survive the process, and an auditor asking 「who held + // administrator standing three months ago, and since when」 cannot read it. + // This is the write that makes that question answerable — one entry per + // CHANGE of standing, plus the first-boot baseline. + await recordPlatformAdminStandingChange(ql, standing, logger); return { seeded: seededCount, adminPromoted: false, diff --git a/packages/plugins/plugin-security/src/platform-admin-standing-audit.test.ts b/packages/plugins/plugin-security/src/platform-admin-standing-audit.test.ts new file mode 100644 index 00000000000..5ee17a83916 --- /dev/null +++ b/packages/plugins/plugin-security/src/platform-admin-standing-audit.test.ts @@ -0,0 +1,430 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#18412] The durable record of platform-admin standing. + * + * Two halves, pinned apart on purpose: + * + * - the ROW SHAPE, tested through the writer's own builder rather than a + * hand-written copy of it — including the one property a later author is + * most likely to "repair": `organization_id` is NULL, by maintainer ruling + * (director batch #153 item 2, 2026-09-18) and by ADR-0131 §1.5, which + * rejects inventing a platform organization in its own words. A pin is the + * only thing that can notice that exception being undone, because every + * field on `sys_audit_log` is `readonly: true` and `validateRecord` skips + * readonly fields — nothing else in the stack refuses ANY value here. + * - the BOOT BEHAVIOUR: one entry per CHANGE of standing, plus the first-boot + * baseline, and silence in the two states where the ledger cannot answer. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; +import { + buildPlatformAdminStandingRow, + platformAdminStandingChanged, + platformAdminStandingSnapshot, + readRecordedStandingSnapshot, + serializePlatformAdminStandingSnapshot, + PLATFORM_ADMIN_STANDING_ACTION, + PLATFORM_ADMIN_STANDING_LEDGER, + PLATFORM_ADMIN_STANDING_OBJECT_NAME, +} from './platform-admin-standing-audit.js'; +import type { PlatformAdminStandingEntry } from './platform-admin-service.js'; + +const LEDGER = PLATFORM_ADMIN_STANDING_LEDGER; + +/** The `sys_audit_log` field set a multi-tenant deployment declares. */ +const TENANTED_LEDGER_FIELDS = [ + 'created_at', + 'action', + 'user_id', + 'actor', + 'object_name', + 'record_id', + 'old_value', + 'new_value', + 'tenant_id', + 'metadata', + 'organization_id', + 'id', +]; + +/** + * In-memory ql over the objects the walled bootstrap touches PLUS the ledger. + * + * `getSchema` is modelled because the writer's mount probe is three-valued and + * the third state — an engine that cannot be ASKED — is a case below. Pass + * `ledgerFields: null` for a deployment that never mounted `plugin-audit`, and + * `schema: false` for an engine carrying no `getSchema` at all. + */ +function makeQl( + seed: { + users?: any[]; + audit?: any[]; + ledgerFields?: string[] | null; + schema?: boolean; + findRefusesLedger?: boolean; + insertRefusesLedger?: boolean; + } = {}, +) { + const tables = new Map([ + ['sys_permission_set', []], + ['sys_user', (seed.users ?? []).map((r) => ({ ...r }))], + ['sys_user_permission_set', []], + ['sys_account', []], + [LEDGER, (seed.audit ?? []).map((r) => ({ ...r }))], + ]); + const ledgerFields = seed.ledgerFields === undefined ? TENANTED_LEDGER_FIELDS : seed.ledgerFields; + const ql: any = { + tables, + async find(object: string, q: any) { + if (object === LEDGER && seed.findRefusesLedger) { + throw new Error('driver refused: no index for sys_audit_log.action'); + } + const where = q?.where ?? {}; + const rows = (tables.get(object) ?? []).filter((r) => + Object.entries(where).every(([k, v]) => r[k] === v), + ); + // The read is ordered IN THE QUERY (`orderBy`), so the double honours it + // rather than handing back insertion order — a double that ignored the + // order would make the "last recorded snapshot" read pass on a driver + // where it cannot. + const order = q?.orderBy?.[0]; + if (order) { + rows.sort((a: any, b: any) => { + const av = String(a[order.field] ?? ''); + const bv = String(b[order.field] ?? ''); + return order.order === 'desc' ? (av < bv ? 1 : av > bv ? -1 : 0) : av < bv ? -1 : av > bv ? 1 : 0; + }); + } + return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; + }, + async insert(object: string, data: any) { + if (object === LEDGER && seed.insertRefusesLedger) { + throw new Error('driver refused: table sys_audit_log is read-only here'); + } + if (!tables.has(object)) tables.set(object, []); + tables.get(object)!.push({ ...data }); + return { id: data.id }; + }, + async update() { + return null; + }, + auditRows(): any[] { + return (tables.get(LEDGER) ?? []).filter((r) => r.action === PLATFORM_ADMIN_STANDING_ACTION); + }, + }; + if (seed.schema !== false) { + ql.getSchema = (name: string) => + name === LEDGER + ? ledgerFields === null + ? undefined + : { name: LEDGER, fields: Object.fromEntries(ledgerFields.map((f) => [f, {}])) } + : { name, fields: {} }; + } + return ql; +} + +const adminFullAccess = () => + ({ name: 'admin_full_access', label: 'Admin', objects: {}, systemPermissions: ['setup.access'] }) as any; + +const logger = () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }); + +const user = (id: string, email: string, createdAt: string, extra: Record = {}) => ({ + id, + email, + created_at: createdAt, + ...extra, +}); + +const entry = (over: Partial = {}): PlatformAdminStandingEntry => ({ + email: 'operator@corp.example', + declaredSpelling: 'Operator@Corp.example', + registered: true, + verified: true, + userId: 'u_owner', + ...over, +}); + +const OLD_POSTURE = process.env.OS_TENANCY_POSTURE; +const OLD_OWNER = process.env.OS_PLATFORM_OWNER_EMAIL; + +beforeEach(() => { + delete process.env.OS_TENANCY_POSTURE; + delete process.env.OS_PLATFORM_OWNER_EMAIL; +}); +afterEach(() => { + if (OLD_POSTURE === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = OLD_POSTURE; + if (OLD_OWNER === undefined) delete process.env.OS_PLATFORM_OWNER_EMAIL; + else process.env.OS_PLATFORM_OWNER_EMAIL = OLD_OWNER; +}); + +// ─────────────────────────────────────────────────────────────────────────── +// The row shape +// ─────────────────────────────────────────────────────────────────────────── +describe('the platform-admin standing row (#18412)', () => { + it('carries the declared entry, the holder and the delta — as a BASELINE when nothing precedes it', () => { + const snapshot = platformAdminStandingSnapshot([entry()]); + const row = buildPlatformAdminStandingRow({ + snapshot, + previousSerialized: null, + declaresOrganizationId: true, + declaresActor: true, + }); + expect(row.action).toBe('platform_admin_standing_change'); + expect(row.object_name).toBe(PLATFORM_ADMIN_STANDING_OBJECT_NAME); + expect(row.record_id).toBeNull(); + expect(row.user_id).toBeNull(); + // `old_value` is null on the baseline row AND ONLY THERE, so "is this the + // first record on this deployment?" is answerable from the row itself. + expect(row.old_value).toBeNull(); + expect(JSON.parse(String(row.new_value))).toEqual([ + { + email: 'operator@corp.example', + declaredSpelling: 'Operator@Corp.example', + registered: true, + verified: true, + userId: 'u_owner', + }, + ]); + expect(JSON.parse(String(row.metadata))).toEqual({ + event: 'platform_admin_standing.baseline', + declared: 1, + holders: 1, + }); + }); + + it('a later change carries BOTH sides, and says `changed` rather than `baseline`', () => { + const previous = serializePlatformAdminStandingSnapshot( + platformAdminStandingSnapshot([entry({ verified: false, userId: undefined })]), + ); + const row = buildPlatformAdminStandingRow({ + snapshot: platformAdminStandingSnapshot([entry()]), + previousSerialized: previous, + declaresOrganizationId: true, + declaresActor: true, + }); + expect(row.old_value).toBe(previous); + expect(JSON.parse(String(row.metadata)).event).toBe('platform_admin_standing.changed'); + expect(JSON.parse(String(row.old_value))[0].userId).toBeNull(); + expect(JSON.parse(String(row.new_value))[0].userId).toBe('u_owner'); + }); + + /** + * ⭐ The DECLARED EXCEPTION, pinned so undoing it cannot be silent. + * + * Nothing else can see this. Every `sys_audit_log` field is `readonly: true` + * and `validateRecord` skips readonly fields, so a tenant id stamped here + * would be accepted by the whole stack — and it would file a + * deployment-level fact behind one tenant's wall. ADR-0131 §1.5 rejects the + * alternative in its own words (「it is the natural repair and the wrong one + * … exists only to give NULL a new name」); the maintainer ruled this exact + * shape on 2026-09-18. + */ + it('⛔ organization_id and tenant_id are NULL — the ruled deployment-level shape, not an oversight', () => { + const row = buildPlatformAdminStandingRow({ + snapshot: platformAdminStandingSnapshot([entry()]), + previousSerialized: null, + declaresOrganizationId: true, + declaresActor: true, + }); + expect( + row.organization_id, + 'the boot-time platform-admin standing row is DEPLOYMENT-LEVEL: there is no platform ' + + 'organization on this tree (ADR-0131 §1.5 rejects inventing one), a tenant id would ' + + 'file a whole-deployment fact behind one tenant, and the first-boot baseline is ' + + 'written before any sys_organization row exists at all. NULL is the ruled shape ' + + '(#18412, director batch #153 item 2) and the shape ADR-0131 D7 will later make ' + + 'structural by dropping the column.', + ).toBeNull(); + expect(row.tenant_id).toBeNull(); + // ADR-0118 D1/D5 keeps `actor` two-valued; the boot is the system. + expect(row.actor).toBeNull(); + }); + + it('omits both conditional columns on a deployment whose ledger does not declare them', () => { + const row = buildPlatformAdminStandingRow({ + snapshot: platformAdminStandingSnapshot([entry()]), + previousSerialized: null, + declaresOrganizationId: false, + declaresActor: false, + }); + // Stamping a column the table lacks fails the INSERT outright — the + // reason every other sys_audit_log writer probes first. + expect(Object.keys(row)).not.toContain('organization_id'); + expect(Object.keys(row)).not.toContain('actor'); + }); + + it('an absent holder is stored as an explicit null, so present-vs-absent never reads as a change', () => { + const a = serializePlatformAdminStandingSnapshot( + platformAdminStandingSnapshot([entry({ userId: undefined })]), + ); + const b = serializePlatformAdminStandingSnapshot( + platformAdminStandingSnapshot([entry({ userId: undefined })]), + ); + expect(a).toBe(b); + expect(platformAdminStandingChanged(a, b)).toBe(false); + expect(JSON.parse(a)[0]).toHaveProperty('userId', null); + }); + + it('a ledger row with no readable snapshot answers null, which writes a baseline rather than silence', () => { + expect(readRecordedStandingSnapshot(undefined)).toBeNull(); + expect(readRecordedStandingSnapshot({ new_value: null })).toBeNull(); + expect(readRecordedStandingSnapshot({ new_value: '[]' })).toBe('[]'); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// The boot behaviour +// ─────────────────────────────────────────────────────────────────────────── +describe('boot records a CHANGE of standing, and only a change (#18412)', () => { + const walled = (owner: string) => { + process.env.OS_TENANCY_POSTURE = 'isolated'; + process.env.OS_PLATFORM_OWNER_EMAIL = owner; + }; + + it('first boot writes the BASELINE once — the record the config-derived migration dropped', async () => { + walled('operator@corp.example'); + const log = logger(); + const ql = makeQl({ + users: [user('u_owner', 'operator@corp.example', '2026-09-01T02:00:00Z', { email_verified: true })], + }); + const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: log }); + expect(r.reason).toBe('walled_config_derived'); + const rows = ql.auditRows(); + expect(rows).toHaveLength(1); + expect(rows[0].old_value).toBeNull(); + expect(rows[0].organization_id).toBeNull(); + expect(JSON.parse(rows[0].new_value)[0]).toMatchObject({ + email: 'operator@corp.example', + verified: true, + userId: 'u_owner', + }); + }); + + it('a second boot with the SAME standing writes NOTHING — a restarted rig is not a change of standing', async () => { + walled('operator@corp.example'); + const log = logger(); + const ql = makeQl({ + users: [user('u_owner', 'operator@corp.example', '2026-09-01T02:00:00Z', { email_verified: true })], + }); + await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: log }); + await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: log }); + await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: log }); + expect(ql.auditRows()).toHaveLength(1); + }); + + it('standing CHANGING writes a second row whose old_value is the first row\'s new_value', async () => { + walled('operator@corp.example'); + const log = logger(); + // Boot 1: declared, registered, NOT verified — nobody holds standing. + const ql = makeQl({ + users: [user('u_owner', 'operator@corp.example', '2026-09-01T02:00:00Z')], + }); + await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: log }); + expect(ql.auditRows()).toHaveLength(1); + + // Boot 2: the address verified. That IS a change of standing. + ql.tables.get('sys_user')![0].email_verified = true; + await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: log }); + const rows = ql.auditRows(); + expect(rows).toHaveLength(2); + expect(rows[1].old_value).toBe(rows[0].new_value); + expect(JSON.parse(rows[1].old_value)[0].verified).toBe(false); + expect(JSON.parse(rows[1].new_value)[0].verified).toBe(true); + expect(JSON.parse(rows[1].metadata).holders).toBe(1); + }); + + it('a REVOKED administrator is recorded — the revocation that used to leave no trace at all', async () => { + walled('operator@corp.example, second@corp.example'); + const log = logger(); + const ql = makeQl({ + users: [ + user('u_owner', 'operator@corp.example', '2026-09-01T02:00:00Z', { email_verified: true }), + user('u_two', 'second@corp.example', '2026-09-01T03:00:00Z', { email_verified: true }), + ], + }); + await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: log }); + // The operator drops one address from the config and restarts — the exact + // act that, before this record, left nothing on the data side at all. + process.env.OS_PLATFORM_OWNER_EMAIL = 'operator@corp.example'; + await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: log }); + const rows = ql.auditRows(); + expect(rows).toHaveLength(2); + expect(JSON.parse(rows[1].old_value).map((e: any) => e.email)).toEqual([ + 'operator@corp.example', + 'second@corp.example', + ]); + expect(JSON.parse(rows[1].new_value).map((e: any) => e.email)).toEqual(['operator@corp.example']); + }); + + it('a deployment that never mounted the audit plugin writes nothing and complains about nothing', async () => { + walled('operator@corp.example'); + const log = logger(); + const ql = makeQl({ + users: [user('u_owner', 'operator@corp.example', '2026-09-01T02:00:00Z', { email_verified: true })], + ledgerFields: null, + }); + const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: log }); + expect(r.reason).toBe('walled_config_derived'); + expect(ql.auditRows()).toHaveLength(0); + // An unmounted ledger is a COMPOSITION choice, not a degradation. + expect(log.error).not.toHaveBeenCalled(); + expect(log.warn).not.toHaveBeenCalled(); + }); + + it('⛔ a REFUSED read of the last snapshot writes nothing — "cannot tell" is not "first boot"', async () => { + walled('operator@corp.example'); + const log = logger(); + const ql = makeQl({ + users: [user('u_owner', 'operator@corp.example', '2026-09-01T02:00:00Z', { email_verified: true })], + findRefusesLedger: true, + }); + await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: log }); + // Reading the refusal as "no record exists" would file a fresh baseline on + // every boot of this rig — the per-boot noise the ruling rejected. + expect(ql.auditRows()).toHaveLength(0); + expect(String(log.error.mock.calls[0]?.[0])).toContain('refused the read'); + }); + + it('a mounted ledger whose INSERT fails reports a durability degradation and never fails the boot', async () => { + walled('operator@corp.example'); + const log = logger(); + const ql = makeQl({ + users: [user('u_owner', 'operator@corp.example', '2026-09-01T02:00:00Z', { email_verified: true })], + insertRefusesLedger: true, + }); + const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: log }); + expect(r.reason).toBe('walled_config_derived'); + expect(ql.auditRows()).toHaveLength(0); + expect(String(log.error.mock.calls[0]?.[0])).toContain('audit row was NOT written'); + }); + + it('an engine that carries no getSchema is not read as "no ledger" — the write is still attempted', async () => { + walled('operator@corp.example'); + const log = logger(); + const ql = makeQl({ + users: [user('u_owner', 'operator@corp.example', '2026-09-01T02:00:00Z', { email_verified: true })], + schema: false, + }); + await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: log }); + const rows = ql.auditRows(); + expect(rows).toHaveLength(1); + // Nothing could be probed, so neither conditional column is stamped. + expect(rows[0]).not.toHaveProperty('organization_id'); + expect(rows[0]).not.toHaveProperty('actor'); + }); + + it('the `single` posture is untouched — it still writes a grant row and records no standing entry', async () => { + process.env.OS_TENANCY_POSTURE = 'single'; + const log = logger(); + const ql = makeQl({ + users: [user('u_first', 'first@corp.example', '2026-09-01T02:00:00Z', { email_verified: true })], + }); + await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: log }); + // Standing under `single` still has a durable grant row of its own, which + // is the property this card exists to restore for WALLED rigs only. + expect(ql.auditRows()).toHaveLength(0); + }); +}); diff --git a/packages/plugins/plugin-security/src/platform-admin-standing-audit.ts b/packages/plugins/plugin-security/src/platform-admin-standing-audit.ts new file mode 100644 index 00000000000..38bd8cf5adc --- /dev/null +++ b/packages/plugins/plugin-security/src/platform-admin-standing-audit.ts @@ -0,0 +1,266 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#18412] `platform_admin_standing_change` — the durable record of WHO held + * platform-administrator standing on this deployment, and from when. + * + * ## What this exists for + * + * Platform-admin standing moved from a STORED GRANT ROW to CONFIG-DERIVED, + * request-time resolution (#11663 re-anchor, ADR-0131). The migration is right + * and it silently dropped a property the row had: the row carried its own + * history — who held standing, since when, and what a revocation looked like. + * Config carries none of that. After the migration, 「three months ago this + * account performed an admin action; what made them an administrator at that + * moment?」 is answerable only from the deployment's environment-variable + * history, which the product does not keep and an auditor cannot read. + * + * `sys_audit_log` already records the ACTIONS. What had no writer is the BASIS + * of the authority behind them. This module is that writer's row shape. + * + * ## One entry per CHANGE of standing, plus the first-boot baseline + * + * The boot already computes the answer — `resolvePlatformAdminStanding` builds + * the per-entry summary and the walled bootstrap logs it at `info`. A row per + * boot would be noise on a frequently restarted rig, so the boot compares the + * resolved standing against the last snapshot ALREADY on the ledger and writes + * only when the two differ. The first boot after this lands finds no prior row + * and writes the baseline once, which is what makes every later row a readable + * DELTA rather than an isolated assertion. + * + * The comparison is over the serialized snapshot rather than over a hand-picked + * subset of fields: a change in the declared spelling, in whether the account + * exists, in whether it is verified, or in WHICH account holds standing is a + * change of standing, and the snapshot is the only place all four are stated + * together. + * + * ## ⛔ Why this module is PURE, and the engine calls are not in it + * + * Every function here takes data and returns data. The ledger read and the + * ledger insert stay in `bootstrap-platform-admin.ts`, on that file's existing + * `tryFind` / `tryInsert` doors, for two reasons that are not style: + * + * - that file already states the ordering discipline a `limit`-ed read needs + * (an unordered read with a cap returns whichever rows the driver produced + * first, not the first N), and a second read door here would be a second + * copy of that lesson; + * - the row shape is the contract this card makes true, so it is built in one + * place a test can call directly, instead of being assembled inside the boot + * where a pin would have to reconstruct it by hand. + */ + +import type { PlatformAdminStandingEntry } from './platform-admin-service.js'; + +/** + * The `sys_audit_log.action` value this record carries. + * + * ⛔ Spelled `snake_case` and dotless, matching Prime Directive #3 (machine + * names) and every incumbent value on the enum (`config_change`, `import`) — + * the ruling's illustrative `platform_admin_standing.changed` carried a dot and + * was written 「e.g.」. + * + * Named here rather than inline at the insert, for the reason `READ_AUDIT_ACTION` + * states in `plugin-audit/src/read-audit.ts`: every `sys_audit_log` field is + * `readonly: true` and `validateRecord` skips readonly fields, so the `action` + * enum validates NOTHING in either direction. A misspelled action is accepted + * silently and no test watching for a throw can see it; a closed literal is the + * only structural protection available. + * + * ⚠️ The enum member itself lives in `@objectstack/plugin-audit` + * (`objects/sys-audit-log.object.ts`), which this package does NOT depend on — + * `plugin-audit` is OPTIONAL and neither package depends on the other. The two + * spellings are tied together by that package's + * `sys-audit-log-retired-actions.test.ts`, whose `ACTIONS_WITH_WRITERS` + * inventory names THIS file as the writer and fails if the enum and the + * inventory disagree. This is the same two-package shape `config_change` + * already has (`service-settings/src/config-change-audit.ts`). + */ +export const PLATFORM_ADMIN_STANDING_ACTION = 'platform_admin_standing_change'; + +/** + * The object this record is ABOUT. + * + * Standing is held by `sys_user` rows — a declared address confers it only once + * an account exists AND verifies — so `sys_user` is the object an auditor + * filters on. `record_id` is null: the row is RUN-LEVEL, covering every declared + * entry at once, the same shape `plugin-auth`'s `import` row and + * `service-settings`' `config_change` row already use. + */ +export const PLATFORM_ADMIN_STANDING_OBJECT_NAME = 'sys_user'; + +/** The platform audit ledger this record lands on. */ +export const PLATFORM_ADMIN_STANDING_LEDGER = 'sys_audit_log'; + +/** One entry of a recorded snapshot — the serialized form of one declared address. */ +export interface PlatformAdminStandingSnapshotEntry { + email: string; + declaredSpelling: string; + registered: boolean; + verified: boolean; + /** The account that holds standing, or `null` when none does. */ + userId: string | null; +} + +/** + * Project the resolved standing onto the shape that is STORED. + * + * Key order is fixed and `userId` is normalized to an explicit `null` rather + * than left absent: the change detector compares serialized snapshots, so a + * present-vs-absent key would read as a change of standing when nothing about + * standing moved. Declaration order is preserved — it is the operator's own + * order and it is what the log line already shows. + */ +export function platformAdminStandingSnapshot( + standing: readonly PlatformAdminStandingEntry[], +): PlatformAdminStandingSnapshotEntry[] { + return standing.map((s) => ({ + email: s.email, + declaredSpelling: s.declaredSpelling, + registered: s.registered, + verified: s.verified, + userId: s.userId ?? null, + })); +} + +/** Serialize a snapshot for storage and for comparison. One spelling for both. */ +export function serializePlatformAdminStandingSnapshot( + snapshot: readonly PlatformAdminStandingSnapshotEntry[], +): string { + try { + return JSON.stringify(snapshot); + } catch { + return String(snapshot); + } +} + +/** + * Did standing CHANGE against the last recorded snapshot? + * + * `previousSerialized` is the `new_value` of the most recent row already on the + * ledger, or `null` when there is none — the first-boot baseline case, which + * always writes. + * + * ⛔ Compared as the stored STRING, not by re-parsing: what the next boot must + * agree with is the bytes a previous boot wrote. Re-parsing would let a change + * in how this module serializes read as a change in who administers the + * deployment, which is the one direction this record must not be wrong in. + */ +export function platformAdminStandingChanged( + previousSerialized: string | null, + nextSerialized: string, +): boolean { + return previousSerialized !== nextSerialized; +} + +/** Inputs the row builder cannot derive on its own. */ +export interface PlatformAdminStandingRowInput { + /** The standing just resolved at boot. */ + snapshot: readonly PlatformAdminStandingSnapshotEntry[]; + /** The last recorded snapshot's stored string, or `null` on the baseline row. */ + previousSerialized: string | null; + /** Does the registered `sys_audit_log` schema declare `organization_id`? */ + declaresOrganizationId: boolean; + /** Does it declare `actor`? */ + declaresActor: boolean; +} + +/** + * Build the `sys_audit_log` row for one change of platform-admin standing. + * + * Exported rather than assembled inside the boot so a pin tests the WRITER's + * row instead of a hand-written copy of it — the same reason + * `buildConfigChangeAuditSink` is exported from `service-settings`. + */ +export function buildPlatformAdminStandingRow( + input: PlatformAdminStandingRowInput, +): Record { + const serialized = serializePlatformAdminStandingSnapshot(input.snapshot); + const baseline = input.previousSerialized === null; + const holders = input.snapshot.filter((e) => e.userId !== null).length; + + const row: Record = { + action: PLATFORM_ADMIN_STANDING_ACTION, + // ⛔ Both null, and neither is a placeholder. This row is written by the + // BOOT, before any request exists: no `sys_user` performed it and no + // service principal did either. `actor` is two-valued under ADR-0118 D1/D5 + // — a `sys_user` id, or null for the system — so null IS the system here. + user_id: null, + object_name: PLATFORM_ADMIN_STANDING_OBJECT_NAME, + // Run-level: the row covers every declared entry at once. See + // `PLATFORM_ADMIN_STANDING_OBJECT_NAME`. + record_id: null, + // The DELTA, stated as both sides. `old_value` is null on the baseline row + // and only there, so 「is this the first record on this deployment?」 is + // answerable from the row itself rather than from a scan of the ledger. + old_value: input.previousSerialized, + new_value: serialized, + // ⛔ NULL, deliberately — see the block on `organization_id` below. This is + // the schema-declared "tenant context" lookup and this fact has no tenant. + tenant_id: null, + metadata: serializePlatformAdminStandingMetadata({ + event: baseline + ? 'platform_admin_standing.baseline' + : 'platform_admin_standing.changed', + declared: input.snapshot.length, + holders, + }), + }; + + // ⭐ THE DECLARED EXCEPTION — ⛔ do not "repair" this to a non-NULL value. + // + // This row carries `organization_id: null`, and that is the RULED shape, not + // an oversight and not a gap waiting for an owner. + // + // ADR-0131 §1.5 「The rejected middle: a platform organization」 considered + // inventing an organization to own deployment-level rows and rejected it in + // its own words: 「it is the natural repair and the wrong one … exists only to + // give NULL a new name」. There is no platform organization on this tree, by + // that decision. Stamping some tenant's id instead would be a lie — this is a + // fact about the whole deployment, filed behind one tenant's wall — and one + // row per organization is the fan-out §1.5 names as wrong. The first-boot + // BASELINE settles it structurally: it is written before any `sys_organization` + // row exists, so at that instant there is no id in the world to stamp. + // + // The maintainer ruled this directly (#18412, director batch #153 item 2, + // 「其他同意」 2026-09-18): the earlier clause requiring a non-NULL + // organization was WITHDRAWN as an error, and this entry 「follows the tree's + // existing deployment-level writing (`organization` NULL), exactly as the four + // writers above do」 — `plugin-audit`'s `audit-writers.ts` and `read-audit.ts`, + // its `auth-event-audit.ts`, and `service-settings`' `config-change-audit.ts`, + // every one of which stamps `tenantId ?? null`. ADR-0131 D7 will later drop + // this column from `sys_audit_log` outright; NULL is how that shape is + // expressed until it does. + // + // Conditionally stamped for the same mechanical reason every other writer + // states: the SchemaRegistry injects `organization_id` only where the object + // and the posture admit it, and stamping a column the table lacks makes the + // INSERT fail outright. + if (input.declaresOrganizationId) row.organization_id = null; + if (input.declaresActor) row.actor = null; + + return row; +} + +/** `metadata` is a stored string on this object — one serializer, guarded. */ +function serializePlatformAdminStandingMetadata(value: unknown): string { + try { + return JSON.stringify(value) ?? String(value); + } catch { + return String(value); + } +} + +/** + * Read the stored snapshot off a ledger row, or `null` when there is none. + * + * Tolerates a row whose `new_value` is absent or not a string — a ledger row is + * stored data and this code must not throw on a shape it did not write. A row + * that cannot be read answers `null`, which means the next boot writes a + * BASELINE rather than skipping: a missing comparison must not silence the + * record. + */ +export function readRecordedStandingSnapshot(row: unknown): string | null { + if (!row || typeof row !== 'object') return null; + const value = (row as { new_value?: unknown }).new_value; + return typeof value === 'string' ? value : null; +} From a228d745233f7a1dfbe54e592e596b498e64baca Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 19 Sep 2026 10:01:22 +0000 Subject: [PATCH 2/3] test(security): pin the standing-audit double to the engine contract, and add the changeset The new double's `update()` now opens with `assertEngineUpdateDispatch` and its `where` matcher refuses the combinators it does not implement; the boot's unchanged-standing line drops the tracker id from the runtime string. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF --- .../18412-platform-admin-standing-audit.md | 17 +++++++++++++++++ .../src/bootstrap-platform-admin.ts | 5 ++++- .../src/platform-admin-standing-audit.test.ts | 17 +++++++++++++++-- scripts/engine-double-contract.pinned.json | 5 +++++ 4 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 .changeset/18412-platform-admin-standing-audit.md diff --git a/.changeset/18412-platform-admin-standing-audit.md b/.changeset/18412-platform-admin-standing-audit.md new file mode 100644 index 00000000000..e49e6ed14bd --- /dev/null +++ b/.changeset/18412-platform-admin-standing-audit.md @@ -0,0 +1,17 @@ +--- +"@objectstack/plugin-security": minor +"@objectstack/plugin-audit": minor +--- + +The walled boot records platform-admin standing on the existing audit ledger, so «who held administrator standing, and from when» survives the move off the stored grant row (#18412). + +Platform-admin standing moved from a **stored grant row** to **config-derived, request-time resolution** (#11663 re-anchor, ADR-0131). The row carried its own history; config carries none. After the migration the only trace of a grant or a revocation was a change to `OS_PLATFORM_OWNER_EMAIL` plus a restart — the product keeps no environment-variable history and an auditor cannot read one. `sys_audit_log` recorded the ACTIONS all along; what had no writer at all was the **basis** of the authority behind them. + +The answer was already being computed and thrown away: `resolvePlatformAdminStanding` builds the per-entry summary at every walled boot and the bootstrap logs it at `info`. + +- **`@objectstack/plugin-audit`** — `sys_audit_log.action` declares one new value, `platform_admin_standing_change`, WRITER-FIRST (the only way a value is allowed onto that enum). Its rows appear on the shipped, unfiltered `recent` and `all_events` views; ⛔ no new list view, ⛔ no new object, ⛔ no new configuration key. +- **`@objectstack/plugin-security`** — the walled bootstrap compares the resolved standing against the last snapshot already on the ledger and writes **one entry per CHANGE of standing**, plus the **first-boot baseline**. A restarted rig writes nothing. Each row carries, per declared entry, the declared spelling, whether an account exists, whether it is verified, and which user id holds standing; `old_value` and `new_value` state both sides of the delta, and `old_value` is null on the baseline row and only there. +- **The `single` posture is untouched.** It still promotes the first registrant and still writes a durable grant row, so the durability this restores is walled-posture-specific. +- ⭐ **`organization_id` is NULL on this row, deliberately and by maintainer ruling** (2026-09-18, director batch #153 item 2). The record is deployment-level by construction: ADR-0131 §1.5 rejects inventing a platform organization in its own words («it is the natural repair and the wrong one … exists only to give NULL a new name»), a tenant id would file a whole-deployment fact behind one tenant's wall, and the first-boot baseline is written before any `sys_organization` row exists at all. This follows the tree's four existing deployment-level audit writers, and is the shape ADR-0131 D7 will later make structural by dropping the column. The exception is recorded beside the write, on the card, and in a pin — ⛔ it is not a gap waiting to be repaired. +- **Nothing here widens who holds standing or what standing permits.** The derivation site is untouched; this adds a RECORD of authority, never a grant of it. +- **Best-effort, and never fatal to boot.** A deployment that never mounted the optional `@objectstack/plugin-audit` skips silently — an unmounted ledger is a composition choice, not a fault. A ledger read that is REFUSED writes nothing and says so: «cannot tell» is not «first boot», and reading it that way would file a fresh baseline on every restart. A mounted ledger whose insert fails reports a durability degradation on the `error` channel. diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts index 445b22fd165..99f9fdc6cb9 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts @@ -196,9 +196,12 @@ async function recordPlatformAdminStandingChange( const serialized = serializePlatformAdminStandingSnapshot(snapshot); const previousSerialized = readRecordedStandingSnapshot(previousRows[0]); if (!platformAdminStandingChanged(previousSerialized, serialized)) { + // ⛔ The card id stays in this comment and out of the STRING: a runtime + // line reaches operators, who have no tracker to resolve `#NNNN` against + // (#18412; `check:doc-authoring`). logger?.debug?.( '[security] platform-admin standing is unchanged since the last recorded entry — no ' + - 'audit row written. One entry per CHANGE of standing is the recorded shape (#18412).', + 'audit row written. One entry per CHANGE of standing is the recorded shape.', ); return; } diff --git a/packages/plugins/plugin-security/src/platform-admin-standing-audit.test.ts b/packages/plugins/plugin-security/src/platform-admin-standing-audit.test.ts index 5ee17a83916..7ce1fed6ea0 100644 --- a/packages/plugins/plugin-security/src/platform-admin-standing-audit.test.ts +++ b/packages/plugins/plugin-security/src/platform-admin-standing-audit.test.ts @@ -18,6 +18,7 @@ */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; import { buildPlatformAdminStandingRow, @@ -83,7 +84,14 @@ function makeQl( } const where = q?.where ?? {}; const rows = (tables.get(object) ?? []).filter((r) => - Object.entries(where).every(([k, v]) => r[k] === v), + Object.entries(where).every(([k, v]) => { + // ⛔ REFUSE what this double does not implement. A matcher that reads + // `$or` as a field name answers `[]` for a query it never ran, and on + // the standing read `[]` means "no record has ever been written" — + // it would pass a suite that cannot see the query it is pinning. + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + return r[k] === v; + }), ); // The read is ordered IN THE QUERY (`orderBy`), so the double honours it // rather than handing back insertion order — a double that ignored the @@ -107,7 +115,12 @@ function makeQl( tables.get(object)!.push({ ...data }); return { id: data.id }; }, - async update() { + // Opens with the PRODUCER's own dispatch predicate rather than a + // hand-mirrored guard (`check:engine-double-contract`): a fixture drifting + // to a call shape ObjectQL.update would refuse fails loudly here. Nothing + // in this file updates, and that is exactly why the refusal must be real. + async update(object: string, data: any, options?: any) { + assertEngineUpdateDispatch(data, options); return null; }, auditRows(): any[] { diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 12320bb28ec..dc0147a4bcb 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -2946,6 +2946,11 @@ "verb": "findOne", "pinned": 1 }, + { + "file": "packages/plugins/plugin-security/src/platform-admin-standing-audit.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/plugins/plugin-security/src/record-share-tenant-wall.test.ts", "verb": "update", From 0a04b88918cd13690b890105def3676b19c87cd3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 19 Sep 2026 10:29:59 +0000 Subject: [PATCH 3/3] fix(security): the standing-audit double's unread object parameter is named _object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tsconfig.test.json` runs with `noUnusedParameters`, and the engine-contract patch introduced `update(object, data, options)` whose first argument the double does not read — TS6133 in the test project only, which a src-only `tsc --noEmit` cannot see. The parameter keeps its POSITION, because the object name is argument one on every IDataEngine write door and dropping it would hand the dispatch predicate the wrong arguments. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF --- .../src/platform-admin-standing-audit.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/plugins/plugin-security/src/platform-admin-standing-audit.test.ts b/packages/plugins/plugin-security/src/platform-admin-standing-audit.test.ts index 7ce1fed6ea0..0795e59e2dc 100644 --- a/packages/plugins/plugin-security/src/platform-admin-standing-audit.test.ts +++ b/packages/plugins/plugin-security/src/platform-admin-standing-audit.test.ts @@ -119,7 +119,12 @@ function makeQl( // hand-mirrored guard (`check:engine-double-contract`): a fixture drifting // to a call shape ObjectQL.update would refuse fails loudly here. Nothing // in this file updates, and that is exactly why the refusal must be real. - async update(object: string, data: any, options?: any) { + // + // `_object` keeps the door's POSITION — the object name is argument one on + // every `IDataEngine` write — while telling `noUnusedParameters` that not + // reading it is deliberate. Dropping it would shift `data` and `options` + // one place left and hand the dispatch predicate the wrong arguments. + async update(_object: string, data: any, options?: any) { assertEngineUpdateDispatch(data, options); return null; },