Skip to content

Commit e18e3da

Browse files
os-zhuangclaude
andauthored
fix(plugin-audit): a lost audit row is an error, said once (#5226) (#5350)
An audit-write failure is a durability / data-consistency degradation, not a functional one: the audited write itself succeeds, its row is on disk and the API returns 200, so nothing looks broken from the outside while the `sys_audit_log` entry recording WHO did it never landed and nothing retries it. AGENTS.md "Degradation log levels" puts that at `error`; it was at `warn`. The error names both things such a line owes: the consequence (the compliance trail is now incomplete, and the system will keep looking healthy) and the fix (ADR-0057 lifecycle-class routing sends sys_audit_log to the `telemetry` datasource when one is registered, so "no such table" here means the write ran against a different datasource than the one holding the table; OS_TELEMETRY_DB=0 collapses the split). Reported ONCE per process, not once per failed write — an audit write runs on every mutation, and one error per write is what trained everyone to skim the channel in #4420. Subsequent failures degrade to `debug`. The write is extracted as a named `persistAuditTrailRow` callee and registered in DURABILITY_CRITICAL_CALLEES so `pnpm check:durability-log-level` holds the level; verified by reverting it to `warn` and watching the gate go red. NOTE: this does NOT fix the missing-table symptom #5226 reports. That premise was disproven on a real `dev --fresh` boot — the table IS created (in dev.telemetry.db, 50 rows) — and the real defect is an ambient transaction leaking across datasources in the engine. See the PR body. Claude-Session: https://claude.ai/code/session_01FTszibd6C8sUCCZnM4VcrL Co-authored-by: Claude <noreply@anthropic.com>
1 parent b857356 commit e18e3da

4 files changed

Lines changed: 214 additions & 5 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/plugin-audit': patch
3+
---
4+
5+
审计行写失败改为 `error` 级,并只报一次
6+
7+
按 AGENTS.md「Degradation log levels」的判据,审计写失败属 **durability / data-consistency** 类而非 functional 类:被审计的那次写入本身成功、数据已落库、接口返回 200,从外面看一切正常,只有记录「谁做的」的 `sys_audit_log` 行没有落地,而且没有任何重试。这正是 #4420 在合规账本上的同一形状,因此原先的 `WARN Audit write failed` 升级为 `error`
8+
9+
这条 `error` 同时给出**后果****修复方向**:审计轨迹已不完整;`sys_audit_log` 受 ADR-0057 §3.6 生命周期分流,注册了 `telemetry` 数据源时会被路由过去(`os dev` 默认以兄弟 SQLite 文件形式提供一个),所以出现 "no such table" 通常意味着该次写入执行在了与建表处**不同**的数据源连接上;`OS_TELEMETRY_DB=0` 可让所有 lifecycle-classed 对象留在主数据源。
10+
11+
审计写发生在**每一次**数据变更上,因此该 `error` 全进程**只报一次**(后续失败降为 `debug`,细节仍可通过提高日志级别取回)—— 每次失败都报一遍会训练所有人略过 `error`,而这正是当初让 #4420`warn` 无人阅读的反射。
12+
13+
写入点提取为具名的 `persistAuditTrailRow`,并登记进 `scripts/check-durability-degradation-log-level.mjs``DURABILITY_CRITICAL_CALLEES`,由 `pnpm check:durability-log-level` 守住该级别,防止日后被悄悄改回 `warn`

packages/plugins/plugin-audit/src/audit-writers.test.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1033,3 +1033,130 @@ describe('audit writers — localized activity summaries (framework#3039)', () =
10331033
expect(emits.find((e) => e.topic === 'collab.assignment')).toBeUndefined();
10341034
});
10351035
});
1036+
1037+
/**
1038+
* #5226 — a lost audit row is a DURABILITY degradation, so it is reported at
1039+
* `error`, not `warn`.
1040+
*
1041+
* The `warn` this replaces is the exact #4420 shape one table over: the audited
1042+
* write itself succeeds and returns 200, its row is on disk, and every counter
1043+
* reads clean — only the compliance ledger entry that records WHO did it never
1044+
* landed, and nothing retries it. By AGENTS.md's one question ("does the system
1045+
* still look normal from the outside, while something it claims is persisted
1046+
* has not actually landed?") that is `error`, and `pnpm check:durability-log-level`
1047+
* now holds the level there (`persistAuditTrailRow` is in its vocabulary).
1048+
*
1049+
* The failure these tests inject is the real one from the #5226 repro: on a
1050+
* `os dev --fresh` stack, ADR-0057 §3.6 routes `sys_audit_log` to the dedicated
1051+
* `telemetry` datasource, so an audited write running inside a transaction on
1052+
* the PRIMARY datasource reaches a connection where that table does not exist.
1053+
* 50 of 52 audit inserts in that boot succeeded; the 2 that ran inside a
1054+
* transaction raised exactly this SqliteError.
1055+
*/
1056+
describe('audit writers — a lost audit row is reported at error (#5226)', () => {
1057+
interface LogLine { level: string; message: string; meta?: any }
1058+
1059+
/** Engine whose `sys_audit_log` insert always fails, capturing every log line. */
1060+
function makeFailingEngine(failWith = 'no such table: sys_audit_log') {
1061+
const hooks = new Map<string, Array<(ctx: any) => any>>();
1062+
const logs: LogLine[] = [];
1063+
const sudoApi = {
1064+
object(name: string) {
1065+
return {
1066+
async create(_row: Record<string, any>) {
1067+
if (name === 'sys_audit_log') throw new Error(failWith);
1068+
return { id: 'generated-id' };
1069+
},
1070+
};
1071+
},
1072+
};
1073+
const api = { sudo: () => sudoApi };
1074+
const engine = {
1075+
getSchema(name: string) {
1076+
const fields = (SINGLE_TENANT as Record<string, string[]>)[name];
1077+
if (!fields) return undefined;
1078+
return { name, fields: Object.fromEntries(fields.map((f) => [f, { type: 'text' }])) };
1079+
},
1080+
registerHook(event: string, fn: (ctx: any) => any) {
1081+
const list = hooks.get(event) ?? [];
1082+
list.push(fn);
1083+
hooks.set(event, list);
1084+
},
1085+
unregisterHooksByPackage() { /* no-op */ },
1086+
logger: {
1087+
error(message: string, _err?: unknown, meta?: any) { logs.push({ level: 'error', message, meta }); },
1088+
warn(message: string, meta?: any) { logs.push({ level: 'warn', message, meta }); },
1089+
debug(message: string, meta?: any) { logs.push({ level: 'debug', message, meta }); },
1090+
info() { /* unused */ },
1091+
},
1092+
};
1093+
async function fire(event: string, ctx: any) {
1094+
for (const fn of hooks.get(event) ?? []) await fn({ ...ctx, event, api });
1095+
}
1096+
return { engine, fire, logs };
1097+
}
1098+
1099+
const aWrite = (id: string) => ({
1100+
object: 'crm_lead',
1101+
input: { id },
1102+
result: { id, name: 'Acme' },
1103+
session: { tenantId: 'org-1', userId: 'user-1' },
1104+
});
1105+
1106+
it('logs at error — never warn — when the audit row cannot be written', async () => {
1107+
const { engine, fire, logs } = makeFailingEngine();
1108+
installAuditWriters(engine as any);
1109+
1110+
await fire('afterInsert', aWrite('l-1'));
1111+
1112+
// The whole point of the change: this used to be the ONLY line, at `warn`.
1113+
expect(logs.filter((l) => l.level === 'warn')).toEqual([]);
1114+
const errors = logs.filter((l) => l.level === 'error');
1115+
expect(errors).toHaveLength(1);
1116+
expect(errors[0].meta).toMatchObject({ object: 'crm_lead', action: 'create' });
1117+
});
1118+
1119+
it('names both the CONSEQUENCE and the FIX in the first line it prints', async () => {
1120+
const { engine, fire, logs } = makeFailingEngine();
1121+
installAuditWriters(engine as any);
1122+
1123+
await fire('afterInsert', aWrite('l-1'));
1124+
1125+
const msg = logs.find((l) => l.level === 'error')!.message;
1126+
// Consequence: the audited write SUCCEEDED, so nothing looks broken, while
1127+
// the ledger entry is missing and nothing retries it.
1128+
expect(msg).toMatch(/compliance trail is now INCOMPLETE/);
1129+
expect(msg).toMatch(/SUCCEEDED/);
1130+
expect(msg).toMatch(/nothing retries it/);
1131+
// Fix: where the table actually lives, and the opt-out that collapses the split.
1132+
expect(msg).toMatch(/telemetry/);
1133+
expect(msg).toMatch(/OS_TELEMETRY_DB=0/);
1134+
});
1135+
1136+
it('says it ONCE, not once per failed write (AGENTS.md)', async () => {
1137+
const { engine, fire, logs } = makeFailingEngine();
1138+
installAuditWriters(engine as any);
1139+
1140+
// An audit write runs on EVERY mutation, so a systemic cause would emit one
1141+
// `error` per write and train everyone to skim the channel — the reflex
1142+
// that made #4420's warn unreadable in the first place.
1143+
for (const id of ['l-1', 'l-2', 'l-3', 'l-4', 'l-5']) {
1144+
await fire('afterInsert', aWrite(id));
1145+
}
1146+
1147+
expect(logs.filter((l) => l.level === 'error')).toHaveLength(1);
1148+
// The rest stay recoverable at a higher log level rather than vanishing.
1149+
expect(logs.filter((l) => l.level === 'debug')).toHaveLength(4);
1150+
expect(logs.filter((l) => l.level === 'warn')).toEqual([]);
1151+
});
1152+
1153+
it('never lets a logging failure break the audited write', async () => {
1154+
const { engine, fire } = makeFailingEngine();
1155+
// A logger that throws must not turn a swallowed audit failure into a
1156+
// user-facing 500 — the audited write already succeeded.
1157+
(engine as any).logger = { error() { throw new Error('logger exploded'); } };
1158+
installAuditWriters(engine as any);
1159+
1160+
await expect(fire('afterInsert', aWrite('l-1'))).resolves.toBeUndefined();
1161+
});
1162+
});

packages/plugins/plugin-audit/src/audit-writers.ts

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,68 @@ export function installAuditWriters(
315315
const getMessaging = opts.getMessaging ?? (() => undefined);
316316
const getI18n = opts.getI18n ?? (() => undefined);
317317

318+
/**
319+
* Write the compliance ledger row (+ its activity mirror).
320+
*
321+
* Extracted as a NAMED callee so `pnpm check:durability-log-level` can anchor
322+
* on it: the gate matches a declared vocabulary of durability-critical calls,
323+
* and the bare `.create()` this used to be is far too generic a name to put
324+
* in that vocabulary. Registered in `DURABILITY_CRITICAL_CALLEES`
325+
* (`scripts/check-durability-degradation-log-level.mjs`) in the same PR, per
326+
* the AGENTS.md rule — so a future edit cannot quietly walk the level back
327+
* down to `warn`.
328+
*/
329+
const persistAuditTrailRow = async (
330+
api: any,
331+
auditRow: Record<string, any>,
332+
activityRow: Record<string, any> | undefined,
333+
): Promise<void> => {
334+
const sys = api.sudo();
335+
await sys.object('sys_audit_log').create(auditRow);
336+
if (activityRow) await sys.object('sys_activity').create(activityRow);
337+
};
338+
339+
/**
340+
* Report a lost audit row — once per process, not once per failed write.
341+
*
342+
* AGENTS.md: "Say it once, at the first degradation, not once per failed
343+
* write." An audit write runs on EVERY mutation, so a per-write `error` on a
344+
* systemic cause (the table is unreachable from this connection) would emit
345+
* one line per write and train everyone to skim `error` — the exact reflex
346+
* that made #4420's `warn` unreadable. The first failure carries the full
347+
* consequence + fix text; subsequent ones degrade to `debug` so the detail is
348+
* still recoverable at a higher log level without drowning the channel.
349+
*/
350+
let auditFailureReported = false;
351+
const reportAuditWriteFailure = (object: string, action: string, err: unknown): void => {
352+
const detail = String((err as any)?.message ?? err);
353+
const logger = (engine as any).logger;
354+
try {
355+
if (auditFailureReported) {
356+
logger?.debug?.('Audit write failed (already reported)', { object, action, err: detail });
357+
return;
358+
}
359+
auditFailureReported = true;
360+
// The two things an `error` here owes, both in the first line it prints:
361+
// the CONSEQUENCE, concretely, and the FIX.
362+
logger?.error?.(
363+
'Audit write FAILED — the compliance trail is now INCOMPLETE. The audited write itself SUCCEEDED and is on ' +
364+
'disk, so the API returned success and nothing downstream looks broken; only the `sys_audit_log` row that ' +
365+
'records who did it never landed, and nothing retries it. Every subsequent audited write is likely losing ' +
366+
'its row the same way (this is reported ONCE — raise the log level to `debug` to see the rest). ' +
367+
'Fix: confirm `sys_audit_log` is reachable from the connection this write ran on. Its ADR-0057 §3.6 ' +
368+
"lifecycle class routes it to the dedicated `telemetry` datasource whenever one is registered (`os dev` " +
369+
'provisions one by default as a SIBLING SQLite file), so a "no such table" here usually means the write ' +
370+
'executed against a DIFFERENT datasource than the one the table was created in — see framework#5226. ' +
371+
'Set `OS_TELEMETRY_DB=0` to keep every lifecycle-classed object on the primary datasource.',
372+
err instanceof Error ? err : new Error(detail),
373+
{ object, action },
374+
);
375+
} catch {
376+
/* logging must never break the audited write */
377+
}
378+
};
379+
318380
// Workspace locale changes rarely, but writeAudit runs on every CRUD write —
319381
// memoize the settings lookup per principal scope with a short TTL so audit
320382
// logging doesn't add a settings query to every mutation's hot path.
@@ -652,9 +714,6 @@ export function installAuditWriters(
652714
const activitiesEnabled = getObjectDef(ctx.object)?.enable?.activities !== false;
653715

654716
try {
655-
const sys = api.sudo();
656-
await sys.object('sys_audit_log').create(auditRow);
657-
if (activitiesEnabled) await sys.object('sys_activity').create(activityRow);
658717
// Assignment notifications are NOT emitted here (framework#3403). Deciding
659718
// that an owner/assignee change warrants a bell is a business policy, not a
660719
// platform default — the kernel version guessed "who is the assignee" from
@@ -666,9 +725,15 @@ export function installAuditWriters(
666725
// (Comment @mention notifications remain a platform behavior — they are
667726
// handled separately by the sys_comment hook below, since SKIP_OBJECTS
668727
// excludes it from this writer.)
728+
await persistAuditTrailRow(api, auditRow, activitiesEnabled ? activityRow : undefined);
669729
} catch (err) {
670-
// Log via engine logger if available, but never throw.
671-
try { (engine as any).logger?.warn?.('Audit write failed', { object: ctx.object, action, err: String((err as any)?.message ?? err) }); } catch {}
730+
// #5226 — DURABILITY degradation, not a functional one, so it is reported
731+
// at `error` (AGENTS.md "Degradation log levels"): the audited write
732+
// itself returned 200 and its row is on disk, so the system looks
733+
// completely normal from the outside, while the compliance ledger entry
734+
// that claims to record it never landed. Nothing retries it, and the gap
735+
// surfaces — if ever — to an auditor who cannot connect it to this line.
736+
reportAuditWriteFailure(ctx.object, action, err);
672737
}
673738
};
674739

scripts/check-durability-degradation-log-level.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,10 @@ const DURABILITY_CRITICAL_CALLEES = new Map([
144144
'saveMetaItem',
145145
'The metadata definition was never written to the authoritative store — the runtime looks completely normal because the in-memory registry already has it, and the definition simply vanishes on the next provision/restart (#4754, from #4669).',
146146
],
147+
[
148+
'persistAuditTrailRow',
149+
'The compliance audit row was never written — the audited write itself succeeded and returned 200, so the API, the data and every counter read clean, while the `sys_audit_log` entry that records WHO did it is simply absent and nothing retries it. The gap surfaces, if ever, to an auditor who cannot connect it back to the write (#5226, the #4420 shape on the compliance ledger).',
150+
],
147151
[
148152
'deleteMetaItemFromLoader',
149153
'The metadata definition was never deleted from the authoritative store — `unregister()` still resolves and still announces `deleted`, the in-memory registry entry is gone, and the surviving row is read straight back out of storage by the very next `list()`/`get()`, so the "deleted" item reappears and survives every restart. Nothing retries it (#5259).',

0 commit comments

Comments
 (0)