From d7f63029fb6e06552e820c049d4b1b307ef27d39 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 15:24:40 +0000 Subject: [PATCH 1/2] fix(service-settings): an unmounted audit ledger is a configuration, not a fault MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `buildConfigChangeAuditSink` attempted the `sys_audit_log` `config_change` insert unconditionally and reported the throw it got back as a degradation. A host that never mounted the OPTIONAL `@objectstack/plugin-audit` has no ledger to write to, so that report described a deployment behaving exactly as composed — and the insert it never should have attempted reached `ObjectQL.insert`, whose own terminal catch logs `Insert operation failed` once per settings write. The sink now probes the engine registry for `sys_audit_log` before the write. Absent, it skips at `debug` and attempts nothing. The probe records nothing and is re-taken per call, so a ledger registered later in the same boot starts recording; an engine that cannot be asked (no `getSchema`) still gets the write attempted, which is the pre-change behaviour. What is left in the catch is a mounted ledger whose insert genuinely failed — a write that claims to be audited and is not — so it reports on the `error` channel, with `warn` kept as the receiver-safe fallback for a sink with no `error`. Both arms are pinned, plus the probe's non-memoization, the unanswerable-engine path and the `warn` fallback. Claude-Session: https://claude.ai/code/session_01QGMBhvUoyD8t5zY8xHQhnP Co-authored-by: Claude --- ...unmounted-audit-ledger-is-configuration.md | 25 ++ .../src/config-change-audit.test.ts | 215 +++++++++++++++++- .../src/config-change-audit.ts | 148 +++++++++++- 3 files changed, 372 insertions(+), 16 deletions(-) create mode 100644 .changeset/unmounted-audit-ledger-is-configuration.md diff --git a/.changeset/unmounted-audit-ledger-is-configuration.md b/.changeset/unmounted-audit-ledger-is-configuration.md new file mode 100644 index 00000000000..d04be9c3256 --- /dev/null +++ b/.changeset/unmounted-audit-ledger-is-configuration.md @@ -0,0 +1,25 @@ +--- +'@objectstack/service-settings': patch +--- + +Stop reporting an unmounted `sys_audit_log` as a failed audit write. + +`buildConfigChangeAuditSink` wrote the `config_change` compliance row blind and reported +the throw it got back. On a deployment that never mounted the OPTIONAL +`@objectstack/plugin-audit` — `objectstack serve --preset minimal`, an EE host that mounts +no `audit`, a hosted tenant kernel — there is no ledger to write to, so every tenant +settings write produced a durability complaint about a deployment behaving exactly as +composed, plus an `Insert operation failed` line per write from the engine one frame down. + +The sink now probes the engine registry for `sys_audit_log` before the write and skips at +`debug` when it is absent, so no insert is attempted and neither channel says anything. The +probe records nothing and is re-taken per write, so a ledger mounted later in the same boot +starts recording. An engine that cannot answer the probe still gets the write attempted. + +No API change: the exported signature, the row shape, `CONFIG_CHANGE_ACTION` and +`CONFIG_CHANGE_OBJECT_NAME` are all unchanged. The remaining fault arm — a ledger that IS +mounted whose insert genuinely fails — now reports on the `error` channel rather than +`warn`, which is AGENTS.md's durability-degradation level for a write that claims to be +audited and is not. + +Clause-②: no 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 6897e2f3352..d04bb394f1e 100644 --- a/packages/services/service-settings/src/config-change-audit.test.ts +++ b/packages/services/service-settings/src/config-change-audit.test.ts @@ -80,6 +80,33 @@ const manifest: SettingsManifest = { ], }; +/** + * [#18368] The `sys_audit_log` MOUNT POINT — a stand-in declaration, not the + * object. + * + * `sys_audit_log` belongs to `@objectstack/plugin-audit` and must not be + * imported here (see the file header). That was free while the sink wrote + * blind; it is not free now that the sink ASKS the registry whether the ledger + * is mounted before writing, because a fixture registering nothing is a fixture + * of the UNMOUNTED deployment — every case below would go green on a sink that + * never writes at all. + * + * So the registry gets a declaration under that name and nothing more. The ROW + * is still asserted at the engine seam, where the real object's absence does not + * matter; what this buys is that `getSchema('sys_audit_log')` answers, which is + * the only thing the mount probe reads. It deliberately declares NO + * `organization_id`, so `makeFieldProbe`'s answer — and therefore every row + * asserted in this file — is byte-for-byte what it was before this existed. + */ +const LEDGER_MOUNT_STANDIN = { + name: 'sys_audit_log', + label: 'Audit Log (mount stand-in)', + fields: { + id: { type: 'text', label: 'Id' }, + action: { type: 'text', label: 'Action' }, + }, +}; + type Store = Map>>; /** A driver over plain Maps — enough of `IDataDriver` for the settings write path. */ @@ -174,6 +201,12 @@ class MockHttp implements IHttpServer { interface BootOptions { /** Make every `sys_audit_log` insert fail, to exercise the best-effort path. */ ledgerThrows?: boolean; + /** + * [#18368] Whether this deployment MOUNTS the platform audit ledger. + * Defaults to `true` — the shape every case written before #18368 assumed. + * `false` is the `--preset minimal` / no-`plugin-audit` host. + */ + ledgerMounted?: boolean; /** Identity the write runs under. */ userId?: string; tenantId?: string; @@ -195,6 +228,10 @@ async function bootPlugin(opts: BootOptions = {}) { await engine.init(); engine.registry.registerObject(SysSetting as any, OWNER_PACKAGE); engine.registry.registerObject(SysSettingAudit as any, OWNER_PACKAGE); + // [#18368] The mount point — see `LEDGER_MOUNT_STANDIN`. + if (opts.ledgerMounted !== false) { + engine.registry.registerObject(LEDGER_MOUNT_STANDIN as any, OWNER_PACKAGE); + } // `sys_audit_log` is plugin-audit's object and is deliberately not resolvable // from this package (see the file header), so its insert is answered at the @@ -211,11 +248,20 @@ async function bootPlugin(opts: BootOptions = {}) { }; const logged: string[] = []; + // [#18368] The same lines, carrying the CHANNEL they arrived on. `logged` + // stays level-blind so every pre-#18368 assertion keeps its meaning; the + // level is what the two arms of #18368 are about, and a level-blind capture + // cannot tell a suppressed expectation from a suppressed fault. + const loggedAt: Array<{ level: string; msg: string }> = []; + const at = (level: string) => (m: string) => { + loggedAt.push({ level, msg: m }); + if (level !== 'debug' && level !== 'info') logged.push(m); + }; const logger = { - info: () => {}, - warn: (m: string) => { logged.push(m); }, - error: (m: string) => { logged.push(m); }, - debug: () => {}, + info: at('info'), + warn: at('warn'), + error: at('error'), + debug: at('debug'), }; const http = new MockHttp(); @@ -259,6 +305,10 @@ async function bootPlugin(opts: BootOptions = {}) { /** REAL `sys_setting` rows. */ settingRows: () => [...rowsOf('sys_setting').values()], logged, + /** [#18368] Every captured line with the channel it arrived on. */ + loggedAt, + /** [#18368] So a case can mount the ledger mid-process and write again. */ + engine, http, }; } @@ -528,6 +578,9 @@ describe('#8145 — a refused write emits NO config_change row', () => { await engine.init(); engine.registry.registerObject(SysSetting as any, OWNER_PACKAGE); engine.registry.registerObject(SysSettingAudit as any, OWNER_PACKAGE); + // [#18368] This case asserts the sink writes on its NON-VACUITY leg, so the + // ledger has to be mounted — see `LEDGER_MOUNT_STANDIN`. + engine.registry.registerObject(LEDGER_MOUNT_STANDIN as any, OWNER_PACKAGE); const ledgerRows: Array> = []; const realInsert = engine.insert.bind(engine); @@ -580,7 +633,10 @@ describe('#8145 — a refused write emits NO config_change row', () => { describe('#8145 — the config_change write is best-effort', () => { it('a failing sys_audit_log insert leaves the settings write landed and reported', async () => { - // The shape of a deployment WITHOUT plugin-audit: the table does not exist. + // [#18368] The ledger IS mounted (the `bootPlugin` default) and its insert + // fails anyway — a provisioned-but-unreachable table. ⛔ This is no longer + // "the shape of a deployment WITHOUT plugin-audit": that shape is skipped + // before the insert now and is pinned in its own section below. const boot = await bootPlugin({ ledgerThrows: true, userId: 'usr_admin' }); const out = await boot.service.setMany( @@ -596,7 +652,7 @@ describe('#8145 — the config_change write is best-effort', () => { // …and the operator is told, once, with the consequence and the cause. const reported = boot.logged.filter((l) => l.includes('config_change audit row NOT written')); expect(reported).toHaveLength(1); - expect(reported[0]).toContain('plugin-audit'); + expect(reported[0]).toContain('schema sync'); }); it('reports ONCE per process, not once per write', async () => { @@ -609,3 +665,150 @@ describe('#8145 — the config_change write is best-effort', () => { expect(boot.settingAuditRows()).toHaveLength(3); }); }); + + +// --------------------------------------------------------------------------- +// 6. [#18368] An UNMOUNTED ledger is a CONFIGURATION, not a fault +// --------------------------------------------------------------------------- + +/** + * Both arms, and NEITHER is optional. + * + * The quiet arm alone would pass just as well on a sink whose log line had + * simply been deleted — or on one that stopped writing the ledger entirely — + * so every case here carries its opposite: the control arm proves a MOUNTED + * ledger whose insert genuinely fails is still reported, and on the durability + * channel. What #18368 suppresses is an EXPECTATION; suppressing a fault would + * be the same defect one layer down. + */ +describe('#18368 — an unmounted `sys_audit_log` is a configuration, not a fault', () => { + const NOT_WRITTEN = 'config_change audit row NOT written'; + + it('QUIET ARM: no ledger mounted — the write lands, no insert is attempted, no ERROR', async () => { + const boot = await bootPlugin({ ledgerMounted: false, userId: 'usr_admin', tenantId: 'org_1' }); + + const out = await boot.service.setMany( + 'branding_test', + { workspace_name: 'ObjectStack' }, + boot.writeCtx, + ); + + // The settings write is untouched — the same two facts the cloud seat + // measured on the hosted plane before filing. + expect(out.workspace_name.value).toBe('ObjectStack'); + expect(boot.settingRows()).toHaveLength(1); + expect(boot.settingAuditRows()).toHaveLength(1); + + // ⭐ The insert is never ATTEMPTED. Asserting only on the log would leave + // `ObjectQL.insert`'s own per-write `Insert operation failed` line — the + // half that actually fires once per settings write — completely unpinned, + // because that one is logged a frame below this fixture. + expect(boot.ledgerRows()).toHaveLength(0); + + // Nothing on `error`, nothing on `warn`, from this sink or any other. + expect(boot.loggedAt.filter((l) => l.level === 'error')).toEqual([]); + expect(boot.logged.filter((l) => l.includes(NOT_WRITTEN))).toEqual([]); + + // …and it is not silent-by-accident: the configuration reading went to + // `debug`, naming the remedy once. + const debugLines = boot.loggedAt.filter((l) => l.level === 'debug' && l.msg.includes('sys_audit_log')); + expect(debugLines).toHaveLength(1); + expect(debugLines[0].msg).toContain('SKIPPED, not failed'); + expect(debugLines[0].msg).toContain('@objectstack/plugin-audit'); + }); + + it('CONTROL ARM: ledger MOUNTED and the insert genuinely fails — the ERROR still fires', async () => { + // Same fixture, same failing insert, ONE difference: the ledger is mounted. + const boot = await bootPlugin({ ledgerMounted: true, ledgerThrows: true, userId: 'usr_admin' }); + + await boot.service.setMany('branding_test', { workspace_name: 'ObjectStack' }, boot.writeCtx); + + // The insert WAS attempted here — which is what makes this a fault and not + // an expectation. + expect(boot.ledgerRows()).toHaveLength(1); + + const errors = boot.loggedAt.filter((l) => l.level === 'error' && l.msg.includes(NOT_WRITTEN)); + expect(errors).toHaveLength(1); + // AGENTS.md → Degradation log levels: an `error` here owes the CONSEQUENCE + // and the FIX, both in the line it prints. + expect(errors[0].msg).toContain('SUCCEEDED and is on disk'); + expect(errors[0].msg).toContain('schema sync'); + // …and it did NOT land on the configuration channel. + expect(boot.loggedAt.filter((l) => l.level === 'debug' && l.msg.includes(NOT_WRITTEN))).toEqual([]); + // The settings write still landed, on both the row and the settings trail. + expect(boot.settingRows()).toHaveLength(1); + expect(boot.settingAuditRows()).toHaveLength(1); + }); + + it('the probe records NOTHING: a ledger mounted mid-process starts recording', async () => { + // ⛔ The memoized spelling would pass every case above and fail only this + // one — a verdict the same boot can still contradict (AGENTS.md → Startup + // registry reads). `plugin-audit` registers its objects from its own + // lifecycle, which may run after the settings service binds its engine. + const boot = await bootPlugin({ ledgerMounted: false, userId: 'usr_admin' }); + + await boot.service.setMany('branding_test', { workspace_name: 'before' }, boot.writeCtx); + expect(boot.ledgerRows()).toHaveLength(0); + + boot.engine.registry.registerObject(LEDGER_MOUNT_STANDIN as any, OWNER_PACKAGE); + + await boot.service.setMany('branding_test', { workspace_name: 'after' }, boot.writeCtx); + const rows = boot.ledgerRows(); + expect(rows).toHaveLength(1); + expect(rows[0].action).toBe(CONFIG_CHANGE_ACTION); + expect(metaOf(rows[0]).key).toBe('workspace_name'); + }); + + it('an engine that cannot be ASKED still attempts the write, and still reports its failure', async () => { + // `getSchema` is an ObjectQL member, not an `IDataEngine` one. ⛔ An + // unanswerable probe is not an answer of "absent": read that way, this + // sink would go permanently silent on every lean host engine, which is the + // #18368 defect inverted. + const attempted: Array> = []; + const engineWithoutGetSchema: any = { + insert: async (_object: string, row: Record) => { + attempted.push(row); + throw new Error('ledger unreachable'); + }, + }; + const lines: Array<{ level: string; msg: string }> = []; + const sink = buildConfigChangeAuditSink(engineWithoutGetSchema, { + debug: (m: string) => lines.push({ level: 'debug', msg: m }), + warn: (m: string) => lines.push({ level: 'warn', msg: m }), + error: (m: string) => lines.push({ level: 'error', msg: m }), + } as any); + + await sink.record({ + namespace: 'branding_test', + key: 'workspace_name', + scope: 'global', + action: 'set', + valueDigest: 'sha256:abc', + encrypted: false, + }); + + expect(attempted).toHaveLength(1); + expect(attempted[0].action).toBe(CONFIG_CHANGE_ACTION); + expect(lines.filter((l) => l.level === 'error' && l.msg.includes(NOT_WRITTEN))).toHaveLength(1); + expect(lines.filter((l) => l.level === 'debug')).toEqual([]); + }); + + it('falls back to `warn` for a sink that declares no `error` channel', async () => { + // The receiver-safe fallback, pinned so the level flip cannot silently + // become a DROP on a host logger that only carries `warn`. + const lines: string[] = []; + const sink = buildConfigChangeAuditSink( + { insert: async () => { throw new Error('ledger unreachable'); } } as any, + { warn: (m: string) => lines.push(m) } as any, + ); + await sink.record({ + namespace: 'branding_test', + key: 'workspace_name', + scope: 'global', + action: 'set', + valueDigest: 'sha256:abc', + encrypted: false, + }); + expect(lines.filter((l) => l.includes(NOT_WRITTEN))).toHaveLength(1); + }); +}); diff --git a/packages/services/service-settings/src/config-change-audit.ts b/packages/services/service-settings/src/config-change-audit.ts index af6b0bb26a3..b8499120de4 100644 --- a/packages/services/service-settings/src/config-change-audit.ts +++ b/packages/services/service-settings/src/config-change-audit.ts @@ -53,6 +53,41 @@ * `sys_setting_audit`, and the same one `plugin-auth` takes for its own explicit * `sys_audit_log` rows. * + * ## [#18368] An UNMOUNTED ledger is a CONFIGURATION, not a fault + * + * Swallowing and reporting was the whole answer, and it made the two cases + * indistinguishable. A host that never mounted `plugin-audit` — + * `objectstack serve --preset minimal`, an EE host that mounts no `audit`, a + * hosted tenant kernel — has not FAILED to write the ledger row; it declined to + * have a ledger. Reporting that as a degradation put a durability complaint on + * a deployment behaving exactly as composed, and the cost was not one line: the + * insert this sink never should have attempted reaches `ObjectQL.insert`, whose + * own terminal `catch` logs `Insert operation failed` — once per settings + * write, forever, because nothing there is reported-once. + * + * So the sink ASKS FIRST. `ledgerMountPoint` probes the engine's registry for + * `sys_audit_log` before the write; absent, the sink skips at `debug` and the + * insert is never attempted, so neither channel says anything. Three properties + * are load-bearing and each is pinned: + * + * - **The probe records nothing and is re-taken on every call.** A memoized + * "not mounted" would be a verdict the boot can still contradict + * (AGENTS.md → *Startup registry reads*): `plugin-audit` may register + * `sys_audit_log` after the settings service binds its engine, and nothing + * would ever revisit a cached no. + * - **"Cannot tell" is not "absent".** `getSchema` is an ObjectQL member, not + * an `IDataEngine` one, so a lean engine double may not carry it at all. + * Only an engine that ANSWERS, and answers `undefined`, licenses the skip; + * an engine that cannot be asked gets the write attempted, which is this + * file's pre-#18368 behaviour. + * - **The fault arm got LOUDER, not quieter.** What is left in the `catch` + * once the expectation is gone is a mounted ledger whose insert genuinely + * failed — the settings write is on disk and claims to be audited while the + * compliance row is not, which is AGENTS.md's *durability degradation* to + * the letter, so it reports on the `error` channel (falling back to `warn` + * only for a sink that has none). Suppressing an expectation is the point; + * suppressing a fault would be the same defect one layer down. + * * ## No plaintext, ever * * The sink receives a DIGEST, never a value — `SettingsService` masks an @@ -76,6 +111,19 @@ export const CONFIG_CHANGE_OBJECT_NAME = 'sys_setting'; */ export const CONFIG_CHANGE_ACTION = 'config_change'; +/** + * The platform audit ledger this sink writes to. + * + * Module-local on purpose. `CONFIG_CHANGE_ACTION` and + * `CONFIG_CHANGE_OBJECT_NAME` are published because a host that binds its own + * engine has to be able to reproduce the ROW; the ledger's own name is not part + * of that row and publishing it would widen this module's exported surface for + * nothing. Named rather than spelled inline because three places now depend on + * meaning the same table: the mount probe, the `organization_id` field probe and + * the insert. + */ +const AUDIT_LEDGER_OBJECT_NAME = 'sys_audit_log'; + /** * Execution context the ledger row is written under. * @@ -87,7 +135,22 @@ export const CONFIG_CHANGE_ACTION = 'config_change'; const SYSTEM_CTX = Object.freeze({ isSystem: true }); /** Structural minimum of the logger this module reports through. */ -type ConfigChangeLogger = SettingsDiagnosticsLogger & { warn?: (message: string) => void }; +type ConfigChangeLogger = SettingsDiagnosticsLogger & { + warn?: (message: string) => void; + /** + * [#18368] The channel an UNMOUNTED ledger is reported on — a configuration + * reading, not a degradation, so it must not reach `warn`/`error`. + * + * Local to this module and deliberately NOT added to the exported + * `SettingsDiagnosticsLogger`: that interface is the contract a HOST + * implements, and this sink is handed `ctx.logger` (the kernel `Logger`, + * which has `debug`). Optional for the same reason the two above are — a + * one-line spy stays assignable, and a sink without it simply skips silently, + * which is the correct outcome for a deployment that never mounted the + * ledger. + */ + debug?: (message: string) => void; +}; /** * Whether the registered `sys_audit_log` schema declares `field`. @@ -116,7 +179,7 @@ function makeFieldProbe(engine: IDataEngine): (field: string) => boolean { // `getSchema` is not on `IDataEngine`; it is an ObjectQL member every // real engine carries. Guarded rather than declared, so a lean engine // double stays assignable. - const schema: any = (engine as any).getSchema?.('sys_audit_log'); + const schema: any = (engine as any).getSchema?.(AUDIT_LEDGER_OBJECT_NAME); const declared = schema?.fields; if (declared && typeof declared === 'object' && !Array.isArray(declared)) { fields = new Set(Object.keys(declared)); @@ -131,6 +194,43 @@ function makeFieldProbe(engine: IDataEngine): (field: string) => boolean { }; } +/** + * [#18368] Is the platform audit ledger MOUNTED on this deployment? + * + * Three answers, and the third is the one a two-valued predicate would have + * lost: + * + * - `true` — the engine answered and `sys_audit_log` is registered. Write. + * - `false` — the engine answered and it is not. This host declined to have a + * ledger; skip, and say so on `debug` only. + * - `undefined` — the engine could not be ASKED. `getSchema` is an ObjectQL + * member, not an `IDataEngine` one (the same guarded reach + * {@link makeFieldProbe} documents), so a lean engine double may + * not carry it, and a host engine may throw out of it. ⛔ Never + * read as "absent": an unanswerable probe must leave the write + * attempted, which is this file's pre-#18368 behaviour, so a fault + * on such an engine stays reported exactly as it was. + * + * ⛔ Records nothing, and is re-taken on EVERY call. A memo would turn "not + * registered yet" into a verdict the same boot can contradict — `plugin-audit` + * registers `sys_audit_log` from its own lifecycle, which may run after the + * settings service binds its engine, and nothing would ever revisit a cached + * no (AGENTS.md → *Startup registry reads*). A settings write is an admin-rate + * operation, so the per-call registry lookup costs nothing worth memoizing. + */ +function ledgerIsMounted(engine: IDataEngine): boolean | undefined { + const getSchema = (engine as any)?.getSchema; + if (typeof getSchema !== 'function') return undefined; + try { + // Property-access call, so the engine stays the receiver. + return (engine as any).getSchema(AUDIT_LEDGER_OBJECT_NAME) != null; + } catch { + // An engine that throws out of its own registry has not told us the ledger + // is absent — it has told us nothing. + return undefined; + } +} + function safeStringify(value: unknown): string { try { return JSON.stringify(value) ?? String(value); @@ -158,6 +258,27 @@ export function buildConfigChangeAuditSink( return { record: async (entry) => { + // [#18368] Ask before writing. An unmounted ledger is a CONFIGURATION: + // attempting the insert would throw, and the throw would be reported as a + // degradation here AND logged per-write by `ObjectQL.insert`'s own + // terminal catch one frame down. Only an engine that ANSWERS "not + // registered" licenses the skip — see `ledgerIsMounted`. + if (ledgerIsMounted(engine) === false) { + try { + logger?.debug?.( + 'SettingsServicePlugin: no `sys_audit_log` on this deployment — the `config_change` ' + + 'ledger row was SKIPPED, not failed. The platform audit ledger is owned by the ' + + 'OPTIONAL `@objectstack/plugin-audit`; a host that does not mount it has no ' + + 'compliance ledger to write to, which is a composition choice and not a fault. ' + + 'The settings write itself landed, and `sys_setting_audit` still carries the ' + + 'settings-specific trail. Mount `@objectstack/plugin-audit` to populate the ' + + '`config_changes` list view.', + ); + } catch { + /* logging must never break the audited write */ + } + return; + } try { const actor = entry.actor ?? entry.userId ?? null; const isReset = entry.action === 'reset'; @@ -196,11 +317,12 @@ export function buildConfigChangeAuditSink( }; if (declares('organization_id')) row.organization_id = entry.tenantId ?? null; - await eng.insert('sys_audit_log', row, { context: SYSTEM_CTX }); + await eng.insert(AUDIT_LEDGER_OBJECT_NAME, row, { context: SYSTEM_CTX }); } catch (err: any) { // Reported once per process, not once per settings write: a failure here - // is systemic (plugin-audit not installed, table unreachable), so a line - // per write would train a reader to skim the channel. + // is systemic (the ledger is mounted but unreachable — DDL never ran, + // the connection is down, the row was refused), so a line per write + // would train a reader to skim the channel. if (failureReported) return; failureReported = true; const detail = String(err?.message ?? err); @@ -210,12 +332,18 @@ export function buildConfigChangeAuditSink( 'The `config_changes` list view and any `action: "config_change"` filter will under-report ' + 'until this is fixed (reported once per process). Cause: ' + detail + - '. Fix: confirm `sys_audit_log` is reachable — it is owned by the OPTIONAL ' + - '`@objectstack/plugin-audit`, so on a deployment without that plugin the table does not ' + - 'exist and this is expected. `sys_setting_audit` still carries the settings-specific trail.'; + '. Fix: `sys_audit_log` IS registered on this deployment but the insert failed, so this is ' + + 'not the un-mounted case (#18368) — confirm the table was provisioned (schema sync) and the ' + + 'ledger datasource is reachable. `sys_setting_audit` still carries the settings-specific trail.'; try { - if (logger?.warn) logger.warn(message); - else if (logger?.error) logger.error(message); + // [#18368] The durability channel. The expectation — a host that + // never mounted the ledger — is skipped above and never reaches here, + // so what is left is AGENTS.md's durability degradation: a write that + // claims to be audited is not, and the system keeps looking healthy. + // ⛔ Not `(logger.error ?? logger.warn)(…)`: that spelling calls the + // bare function, losing the receiver a class-based `Logger` needs. + if (logger?.error) logger.error(message); + else if (logger?.warn) logger.warn(message); } catch { /* logging must never break the audited write */ } From 5ec4b174135bb4e2a14336f13cbbefc6234b5846 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 15:37:32 +0000 Subject: [PATCH 2/2] fix(service-settings): keep the ledger object name inline at its two counted sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gate findings from the derived roster, both on the new code: `check:doc-authoring` — the tracker id had been spelled inside the operator-facing failure string. A runtime string reaches authors and operators, none of whom can resolve `#NNNN`; the id stays in the adjacent source comments, where the reader who can resolve it is already looking. `check:tenant-audit-census` — folding the pre-existing `getSchema` and `insert` spellings onto the new module constant moved a corpus-scale ratchet (inline 109 to 108, const 40 to 41) and a hand-written prose count in `content/docs/permissions/`, a tree this change has no business in. Both spellings are restored verbatim; the constant is used only by the new mount probe, and says why. Claude-Session: https://claude.ai/code/session_01QGMBhvUoyD8t5zY8xHQhnP Co-authored-by: Claude --- .../src/config-change-audit.ts | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/services/service-settings/src/config-change-audit.ts b/packages/services/service-settings/src/config-change-audit.ts index b8499120de4..2f9535bb6c7 100644 --- a/packages/services/service-settings/src/config-change-audit.ts +++ b/packages/services/service-settings/src/config-change-audit.ts @@ -112,15 +112,21 @@ export const CONFIG_CHANGE_OBJECT_NAME = 'sys_setting'; export const CONFIG_CHANGE_ACTION = 'config_change'; /** - * The platform audit ledger this sink writes to. + * The platform audit ledger the mount probe asks about. * * Module-local on purpose. `CONFIG_CHANGE_ACTION` and * `CONFIG_CHANGE_OBJECT_NAME` are published because a host that binds its own * engine has to be able to reproduce the ROW; the ledger's own name is not part - * of that row and publishing it would widen this module's exported surface for - * nothing. Named rather than spelled inline because three places now depend on - * meaning the same table: the mount probe, the `organization_id` field probe and - * the insert. + * of that row, and publishing it would widen this module's exported surface for + * nothing. + * + * ⛔ The two pre-existing spellings below — the `organization_id` field probe + * and the insert itself — are deliberately left INLINE rather than folded onto + * this constant. They are counted sites in `content/docs/permissions/tenant-audit-census.mdx` + * ("object name spelled inline" vs "named through a const"), so folding them + * moves a corpus-scale ratchet and a hand-written prose count in a docs tree + * this change has no business in. The consolidation is worth doing; it is not + * worth doing here. */ const AUDIT_LEDGER_OBJECT_NAME = 'sys_audit_log'; @@ -179,7 +185,7 @@ function makeFieldProbe(engine: IDataEngine): (field: string) => boolean { // `getSchema` is not on `IDataEngine`; it is an ObjectQL member every // real engine carries. Guarded rather than declared, so a lean engine // double stays assignable. - const schema: any = (engine as any).getSchema?.(AUDIT_LEDGER_OBJECT_NAME); + const schema: any = (engine as any).getSchema?.('sys_audit_log'); const declared = schema?.fields; if (declared && typeof declared === 'object' && !Array.isArray(declared)) { fields = new Set(Object.keys(declared)); @@ -317,7 +323,7 @@ export function buildConfigChangeAuditSink( }; if (declares('organization_id')) row.organization_id = entry.tenantId ?? null; - await eng.insert(AUDIT_LEDGER_OBJECT_NAME, row, { context: SYSTEM_CTX }); + await eng.insert('sys_audit_log', row, { context: SYSTEM_CTX }); } catch (err: any) { // Reported once per process, not once per settings write: a failure here // is systemic (the ledger is mounted but unreachable — DDL never ran, @@ -333,8 +339,8 @@ export function buildConfigChangeAuditSink( 'until this is fixed (reported once per process). Cause: ' + detail + '. Fix: `sys_audit_log` IS registered on this deployment but the insert failed, so this is ' + - 'not the un-mounted case (#18368) — confirm the table was provisioned (schema sync) and the ' + - 'ledger datasource is reachable. `sys_setting_audit` still carries the settings-specific trail.'; + 'not the un-mounted case — confirm the table was provisioned (schema sync) and the ledger ' + + 'datasource is reachable. `sys_setting_audit` still carries the settings-specific trail.'; try { // [#18368] The durability channel. The expectation — a host that // never mounted the ledger — is skipped above and never reaches here,