Skip to content

Commit cb648cb

Browse files
claude[bot]claude
andauthored
fix(plugin-audit): key the lost auth-event row report per CAUSE, and name the real cause in its first line (#18246)
Fixes #17452 Clause-②: no ## What this is `packages/plugins/plugin-audit/src/auth-event-audit.ts` — the writer behind the `login` / `logout` rows of the compliance ledger — carried a second, independent copy of both defects that #15166 removed from `audit-writers.ts`: 1. **its own process-level `failureReported` boolean**, so after the first failure of ANY cause, every later failure of every OTHER cause degraded to `debug` for the life of the process; 2. **its own fixed message literal**, printing the ADR-0057 §3.6 / `OS_TELEMETRY_DB` datasource guidance unconditionally, regardless of what actually failed. `persistAuthEventAuditRow` is registered in `DURABILITY_CRITICAL_CALLEES` (`scripts/check-durability-degradation-log-level.mjs:361`), whose entire purpose is that durability loss is reported at `error`. That register is the declaration this restores — ⛔ it is not edited here. ## Separation re-verified before choosing the approach The dispatch asked whether the two writers have converged since #15166. Measured on `origin/main` at `b3b43b6ea` (this branch's base), they had **not**: | | `audit-writers.ts` | `auth-event-audit.ts` (before) | |:--|:--|:--| | reporter | `reportAuditWriteFailure` | `reportAuthEventWriteFailure` | | dedupe state | `reportedAuditFailureCauses` (a `Set`) | `failureReported` (a boolean) | | message | cause-led, conditional remedy | one fixed literal | | logger | `(engine as any).logger` | injected `AuthEventAuditLogger`, `error` OPTIONAL | | tables written | `sys_audit_log` + `sys_activity` | `sys_audit_log` only | So the fix lands in place rather than merging two structurally different reporters. What **is** shared is the part the card called a port: the cause-key helpers. ## What changed - `audit-writers.ts` — `auditFailureCauseKey` and `auditFailureCauseSummary` become module exports so the auth-event sink can use them. ⛔ Not added to `src/index.ts`: the sharing is internal to the package. This mirrors the existing `createFieldPresenceProbe` import that already crosses these two files. - `auth-event-audit.ts` — the boolean becomes `reportedAuthEventFailureCauses`, a `Set` keyed by `auditFailureCauseKey(SESSION_OBJECT, err)`. The first `error` line leads with `auditFailureCauseSummary(err, detail)` — the code and message that were already computed one line above the branch and passed only into the `debug` payload. The datasource guidance is kept and made conditional on `isMissingTableError(err, 'sys_audit_log')`, asked for the one table this writer writes. - `auth-event-audit.test.ts` — 7 new cases (see below). - A `patch` changeset. ⛔ Importing the helpers rather than re-spelling them is deliberate: a second copy of this key is how the defect reached this file, so a third spelling would be the same mistake again. ## Evidence ### Reproduce first — the new block against the unfixed source Commit `3985dd2bb` is the pin block alone, on top of unmodified source. `pnpm --filter @objectstack/plugin-audit exec vitest run src/auth-event-audit.test.ts`: ``` Tests 4 failed | 10 passed (14) FAIL reports a SECOND, DIFFERENT cause at error — a new cause is a new degradation AssertionError: expected [ { level: 'error', …(2) } ] to have a length of 2 but got 1 FAIL prints the datasource remedy for the cause it is the remedy FOR, and not for others AssertionError: expected 'Auth-event audit write FAILED — the c…' not to match /OS_TELEMETRY_DB/ Received: "… Fix: confirm `sys_audit_log` is reachable … Set `OS_TELEMETRY_DB=0` …" (the cause driven through the reporter was ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED) ``` Both defects the card names, reproduced through the real reporter: a second cause silenced to `debug`, and the datasource hint printed for a failure it is not the remedy for. ### After the fix `Tests 14 passed (14)`. Whole package: `Test Files 23 passed (23) · Tests 341 passed (341)`; `pnpm --filter @objectstack/plugin-audit typecheck` exit 0 (`check:test-typecheck: OK — 0 file(s) / 0 error(s)`). ### Reverse verification — three legs, mutated on disk, each restored byte-identically Every leg proves the mutation landed (`grep -c` on the anchor text and on the injected text, plus the on-disk blob hash moving), runs the suite, then restores with `git checkout HEAD -- path` and proves `git hash-object` equals the HEAD blob and `git diff HEAD` is empty. HEAD blob `ba07853195ca57faf56734d32e005a836cd0aae2` before and after all three. No build/`dist` leg is owed: the suite imports `./auth-event-audit.js` relatively and `vitest.config.ts` aliases `@objectstack/types` to source, so nothing on the tested path resolves through `dist`. | leg | mutation | anchor→inject | result | |:--|:--|:--|:--| | A | cause key collapsed to one constant bucket (= the old boolean) | 1→0 / 0→1, hash `31b8e4c…` | **2 failed** — `reports a SECOND, DIFFERENT cause at error`, `[#9657] the warn fallback is per-cause too` | | B | `missingTable` forced to `true` (= the old unconditional hint) | 1→0 / 0→1, hash `13edd5b…` | **1 failed** — `prints the datasource remedy for the cause it is the remedy FOR, and not for others` | | C | dedupe removed entirely (the **named falsifier**) | 1→0 / 0→1, hash `7d998a1…` | **4 failed** — the three anti-noise controls, plus the pre-existing #8144 `reported at ERROR, once` case | ### The discriminating control for a per-cause dedupe Leg C is the point. "A different cause now reports" is satisfied by simply deleting the boolean, which is the outcome AGENTS.md names as this rule's falsifier. Three cases hold the other half, and they stay **green** under leg A (12 of 14 passed there, and none of the three is in that leg's FAIL list) while going **red** under leg C: - `still degrades a REPEAT of an already-reported cause to debug` — 5 sign-ins, same cause ⇒ 1 `error` + 4 `debug`, and the `debug` lines carry the same `cause` key. - `keys on the error CODE, never its message, so a per-row fault cannot flood error` — 200 sign-ins, 200 distinct per-row messages, one code ⇒ 1 `error`. - `folds a fault carrying NO code into ONE bucket rather than growing one` — 200 sign-ins, no code at all ⇒ 1 `error`. ⇒ the delivered behaviour is *cause-keyed dedupe*, not *no dedupe*. The premise #15166's ruling hung on — that cause-keying does not reintroduce #4420's unreadable flood — holds on this seam too, and for a stronger reason: the object dimension is constant here (`sys_session`), so the key reduces to the driver's own closed code vocabulary. ### Clause-② re-derived from the DELIVERED diff `src/index.ts` is untouched (`git diff origin/main --stat` on it is empty), and `tsup` builds the single entry `src/index.ts`. - `.d.ts` export list of the published entry (`dist/index.d.ts`): 27 names, neither `auditFailureCauseKey` nor `auditFailureCauseSummary` among them. - runtime probe, `name in await import('dist/index.mjs')` (the path `exports["."]` names): ``` auditFailureCauseKey not reachable auditFailureCauseSummary not reachable createAuthEventAuditSink REACHABLE [POSITIVE CONTROL] installAuditWriters REACHABLE [POSITIVE CONTROL] thisSymbolDoesNotExist not reachable [NEGATIVE CONTROL] total runtime exports: 11 ``` ⇒ **`Clause-②: no`**, derived by reachability rather than by the word `export`. The changeset is graded `patch`. ### Gate denominator `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived **65** families at HEAD `d20bd8765`. All 65 were run with exit codes recorded before any pipe, and reconciled: ``` Run reconciliation — 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN. EXIT CODES — all 65 accounted famil(ies) carry one, so the NOT-MEASURED count above is DERIVED from them. ✓ dispatch-gates --ran: 65 derived famil(ies) accounted for — 65 run, 0 NOT-MEASURED (a DERIVED zero — all 65 recorded an exit code and none of them is 3). ``` Three first answered **exit 3, PREREQUISITE NOT MET** — `check:i18n`, `check:dual-build-cjs-loads`, `check:type-check-debt`. ⛔ Not read as green: their prerequisite closures were built (`turbo run build` over the i18n gate's named closure, then over `./packages/*` `./packages/*/*`) and all three re-run to exit 0 — `check-i18n-bundles: OK (9 package(s) — all bundles in sync…)` and `check-type-check-coverage --re-measure: OK — 5 ledger entr(ies) re-measured, 55 raw tsc error(s) total, none above its recorded number`. Outside the derived set and run anyway because it is the declaration this card restores: `pnpm check:durability-log-level` exit 0 — `✓ durability-degradation log levels: 36 durability-critical catch seam(s), all loud…`. ### eslint — a declared narrowing, with its three readings Repo-wide `pnpm lint` is CI's run. Narrowed here to the three source files this diff touches, at HEAD `d20bd8765`: ① Population read from eslint's own `eslint.config.mjs`, not guessed: the base block is `files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']` with further `packages/**/*.{ts,tsx,mts,cts}` blocks — all three files are inside both. ② Count read from `--format json`: **3 files linted, 0 errors, 0 warnings**, exit 0. ③ Invariance over untouched files: `eslint.config.mjs` states at its own line 328 that this repo "runs one `eslint.config.mjs`, which never enables type-aware linting (no `parserOptions.project`, no typed `@typescript-eslint` rules) for ANY file" — measured there with a positive control. With no cross-file type program, this diff cannot move the verdict on any file it does not touch. ## Acceptance notes Out of scope for this PR, ⛔ not fixed here, and ⛔ not filed either — the dispatch reserved filing to the PM: - **A THIRD copy of the same pair lives in `packages/plugins/plugin-audit/src/read-audit.ts` (lines 484 onward).** `reportReadAuditWriteFailure` has its own process-level `failureReported` boolean and its own fixed literal carrying the same unconditional ADR-0057 §3.6 / `OS_TELEMETRY_DB` guidance — and its callee `persistReadAuditRows` is registered in `DURABILITY_CRITICAL_CALLEES` (`scripts/check-durability-degradation-log-level.mjs:357`), exactly as the two already dealt with. Same declared invariant, same blast radius, on the record-view audit path. Now a cheap port: the helpers this PR exports are the whole shape it needs. Dedupe read: one targeted semantic search over this repo, 12 hits, no open duplicate — with #17452 and #15166 both returning as the firing control. Dedupe words for whoever files it: `read-audit.ts` · `reportReadAuditWriteFailure` · `persistReadAuditRows` · `failureReported` · third copy. - **Noted, not filed** — `packages/services/service-settings/src/config-change-audit.ts:157` carries the same process-wide `failureReported` shape, but it is **not** the same class: its callee is a bare `eng.insert` that no register names, its first line already carries `Cause: ` plus the real detail, and its remedy text is cause-agnostic (it explains that `plugin-audit` is optional). An observation, not a contract violation. Successor: whoever takes the `read-audit.ts` card above, as the same sweep. ## Notes for review - ⛔ `DURABILITY_CRITICAL_CALLEES` is untouched, per the card's fence. - `packages/spec` is untouched. - No test was skipped, disabled or weakened. The pre-existing #8144 case `a failed ledger write is reported at ERROR, once, and never breaks the caller` drives the **same** cause twice, so it stays green under the new key unchanged — and leg C shows it is load-bearing. - `@objectstack/types` and its `paths` / vitest-alias entries were already added by PR #17450, so no manifest or tsconfig change is owed for `isMissingTableError`. --- _Generated by [Claude Code](https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c81e7ff commit cb648cb

4 files changed

Lines changed: 295 additions & 20 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+
A lost auth-event row is reported once per failure CAUSE, not once per process, and the first line names the cause instead of a fixed remedy.
6+
7+
`auth-event-audit.ts` — the writer behind the `login` / `logout` rows in `sys_audit_log` — carried its own, independent copy of both defects the record-level audit writer was fixed for. `reportAuthEventWriteFailure` deduped on a single process-wide boolean, so after the first failure of any cause, every later failure of every *other* cause degraded to `debug` for the life of the process: a long-running server could keep losing sign-in and sign-out rows for hours to a second, unrelated fault, with one `error` line at the top of the log describing the first. `persistAuthEventAuditRow` is registered in the durability-degradation vocabulary precisely because a lost audit row must be reported at `error`.
8+
9+
The dedupe key is now the failure's identity — the error `code` (or its absence) together with the object the rows are about. A repeat of an already-reported cause still degrades to `debug`, exactly as before; a new cause reports at `error`, once. The key is built from the `code` and **never** the message: a driver names the offending row in its message, so a message-keyed dedupe would grow one `error` line per lost row. Keyed on the code, the reported-cause set is bounded by the driver's code vocabulary and does not grow with traffic — measured at one `error` line for 200 failed sign-ins carrying 200 distinct messages under one code, and the same one line for 200 carrying no code at all.
10+
11+
The first `error` line now leads with the underlying code and message, which were already computed at the call site and passed only into the `debug` payload. The ADR-0057 §3.6 telemetry-datasource guidance is kept — it is the correct remedy for the "no such table" cause it was written for — but is now printed only for that cause, decided by the shared `isMissingTableError` predicate for the one table this writer writes. Previously it was printed unconditionally, so an organization refusal was answered with "check the datasource", sending the operator to inspect something that was working.
12+
13+
The cause-key helpers are imported from the record-level writer in this same package rather than re-spelled here: a second copy of that key is how these defects reached this file, so a third spelling would repeat the mistake. No published export is added or changed.

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

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -726,8 +726,17 @@ function renderMilestoneSummary(
726726
* A code that is absent, or is not a scalar, collapses to ONE bucket rather
727727
* than growing one: an uncoded fault is "the uncoded fault on this object", and
728728
* a thousand of them is still one `error` line.
729+
*
730+
* ⚠️ [#17452] EXPORTED, for `auth-event-audit.ts`. That file carried a second,
731+
* independent copy of the process-wide boolean this key replaced, and it now
732+
* imports this one instead of re-spelling it — a second copy of the key is
733+
* precisely how the defect reached that file, so a third spelling would be the
734+
* same mistake again. Same seam as its `createFieldPresenceProbe` import.
735+
* ⛔ Deliberately NOT re-exported from the package barrel: the sharing is
736+
* internal to `@objectstack/plugin-audit` and the published surface is
737+
* unchanged by it.
729738
*/
730-
function auditFailureCauseKey(object: string, err: unknown): string {
739+
export function auditFailureCauseKey(object: string, err: unknown): string {
731740
const code = (err as { code?: unknown } | null | undefined)?.code;
732741
const bounded = typeof code === 'string' || typeof code === 'number' ? String(code) : '(no code)';
733742
// JSON rather than a separator character: an object name and a driver code
@@ -743,8 +752,11 @@ function auditFailureCauseKey(object: string, err: unknown): string {
743752
* the line printed a fixed remedy and never looked at `err`. The code is what
744753
* makes two failures the same failure (see {@link auditFailureCauseKey}), so it
745754
* leads; the message is what makes this one legible.
755+
*
756+
* ⚠️ [#17452] Exported alongside {@link auditFailureCauseKey} and for the same
757+
* reason — the two are one shape, and the auth-event sink needs both halves.
746758
*/
747-
function auditFailureCauseSummary(err: unknown, detail: string): string {
759+
export function auditFailureCauseSummary(err: unknown, detail: string): string {
748760
const code = (err as { code?: unknown } | null | undefined)?.code;
749761
return typeof code === 'string' || typeof code === 'number' ? `${String(code)}: ${detail}` : detail;
750762
}

packages/plugins/plugin-audit/src/auth-event-audit.test.ts

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,3 +350,203 @@ describe('[#8144] createAuthEventAuditSink writes a login row that names its act
350350
expect(meta).toMatchObject({ action: 'login' });
351351
});
352352
});
353+
354+
/**
355+
* [#17452] The dedupe key is the failure's CAUSE, not the process.
356+
*
357+
* The block above pins that a lost auth-event row is reported at `error`, and
358+
* that it is reported ONCE rather than once per failed sign-in. Both still
359+
* hold. What this block pins is the COUNTING UNIT of that "once", and the two
360+
* defects the process-wide version had — the SECOND, independent copy of the
361+
* pair `audit-writers.ts` carried before #15166:
362+
*
363+
* 1. after the first failure of ANY cause, every later failure of every OTHER
364+
* cause degraded to `debug` for the life of the process — a server could
365+
* keep losing sign-in rows for hours to a second fault with one `error`
366+
* line at the top of the log describing the first;
367+
* 2. that one line named the ADR-0057 §3.6 telemetry-datasource remedy
368+
* unconditionally, so a refusal that had nothing to do with datasource
369+
* routing sent its operator to check something that was not broken.
370+
*
371+
* ⚠️ The anti-noise choice these must not undo is AGENTS.md's, and #4420 is
372+
* what it was invented against: an unbounded per-event line nobody could read.
373+
* `keys on the error CODE, never its message` below is the pin that keeps
374+
* "once per cause" from decaying into it — ⛔ do not relax it to a message.
375+
*
376+
* The helpers are `audit-writers.ts`'s, imported rather than re-spelled: a
377+
* second copy of this key is how the defect got here in the first place.
378+
*/
379+
describe('auth-event audit — reported once per CAUSE, not once per process (#17452)', () => {
380+
interface LogLine {
381+
level: string;
382+
message: string;
383+
meta?: Record<string, any>;
384+
}
385+
386+
/** A sink whose ledger insert fails with a caller-chosen error each time. */
387+
function makeCauseSink(nextError: (n: number) => unknown) {
388+
const logs: LogLine[] = [];
389+
let n = 0;
390+
const broken: any = {
391+
getSchema: () => null,
392+
insert: async () => {
393+
throw nextError(n++);
394+
},
395+
};
396+
const logger = {
397+
error(message: string, _err?: Error, meta?: Record<string, any>) {
398+
logs.push({ level: 'error', message, meta });
399+
},
400+
warn(message: string, meta?: Record<string, any>) {
401+
logs.push({ level: 'warn', message, meta });
402+
},
403+
debug(message: string, meta?: Record<string, any>) {
404+
logs.push({ level: 'debug', message, meta });
405+
},
406+
};
407+
const sink = createAuthEventAuditSink({ getEngine: () => broken, logger });
408+
const signIn = (id: string) => sink.recordAuthEvent({ action: 'login', userId: id, sessionId: id });
409+
const at = (level: string) => logs.filter((l) => l.level === level);
410+
return { sink, signIn, at, logs };
411+
}
412+
413+
const driverError = (message: string, code?: string): Error => {
414+
const e = new Error(message) as Error & { code?: string };
415+
if (code !== undefined) e.code = code;
416+
return e;
417+
};
418+
419+
const NO_SUCH_TABLE = () => driverError('no such table: sys_audit_log', 'SQLITE_ERROR');
420+
const ORG_REQUIRED = () =>
421+
driverError('system write requires an organization', 'ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED');
422+
423+
it('reports a SECOND, DIFFERENT cause at error — a new cause is a new degradation', async () => {
424+
// THE DEFECT. On the process-wide boolean this was one `error` (the first
425+
// cause) and one `debug`; the organization refusal — a completely
426+
// different fault, with a different remedy — was never reported at all.
427+
let phase = 0;
428+
const { signIn, at } = makeCauseSink(() => (phase === 0 ? NO_SUCH_TABLE() : ORG_REQUIRED()));
429+
430+
await signIn('usr_1');
431+
phase = 1;
432+
await signIn('usr_2');
433+
434+
const errors = at('error');
435+
expect(errors).toHaveLength(2);
436+
expect(errors[0].message).toMatch(/no such table: sys_audit_log/);
437+
expect(errors[1].message).toMatch(/ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED/);
438+
expect(at('debug')).toHaveLength(0);
439+
expect(at('warn')).toEqual([]);
440+
});
441+
442+
it('still degrades a REPEAT of an already-reported cause to debug', async () => {
443+
// ⚠️ THE DISCRIMINATING CONTROL. Deleting the boolean outright would also
444+
// make the test above pass, and would be the falsifier AGENTS.md names
445+
// ("log every failure at `error`"). This is the half that must NOT change:
446+
// the same cause on the same object still says it once.
447+
const { signIn, at } = makeCauseSink(() => NO_SUCH_TABLE());
448+
449+
for (const id of ['usr_1', 'usr_2', 'usr_3', 'usr_4', 'usr_5']) await signIn(id);
450+
451+
expect(at('error')).toHaveLength(1);
452+
expect(at('debug')).toHaveLength(4);
453+
// The repeats name the cause they were folded into, so a `debug` sweep can
454+
// tell "the same fault, 4 more times" from "four different faults".
455+
expect(at('debug')[0].meta?.cause).toBe(at('debug')[3].meta?.cause);
456+
});
457+
458+
it('keys on the error CODE, never its message, so a per-row fault cannot flood `error`', async () => {
459+
// ⚠️ THE ANTI-NOISE PIN (AGENTS.md; #4420). A driver names the offending
460+
// ROW in its message, so a message-keyed dedupe would grow one `error`
461+
// line per lost sign-in row — #4420 again, wearing the word "cause". 200
462+
// sign-ins, 200 distinct messages, ONE code ⇒ one line.
463+
const EVENTS = 200;
464+
const { signIn, at } = makeCauseSink((i) =>
465+
driverError(`UNIQUE constraint failed: sys_audit_log.id (row aud_${i})`, 'SQLITE_CONSTRAINT_UNIQUE'),
466+
);
467+
468+
for (let i = 0; i < EVENTS; i += 1) await signIn(`usr_${i}`);
469+
470+
expect(at('error')).toHaveLength(1);
471+
expect(at('debug')).toHaveLength(EVENTS - 1);
472+
});
473+
474+
it('folds a fault carrying NO code into ONE bucket rather than growing one', async () => {
475+
// The other half of the bound: "the code, or its ABSENCE" is a single key
476+
// value, so an uncoded driver — the shape with nothing bounded to key on —
477+
// still says it once instead of once per sign-in.
478+
const EVENTS = 200;
479+
const { signIn, at } = makeCauseSink((i) => driverError(`insert failed for record aud_${i}`));
480+
481+
for (let i = 0; i < EVENTS; i += 1) await signIn(`usr_${i}`);
482+
483+
expect(at('error')).toHaveLength(1);
484+
expect(at('debug')).toHaveLength(EVENTS - 1);
485+
});
486+
487+
it('carries the underlying code and message in the first line it prints', async () => {
488+
// The information was computed one line above the branch and dropped on the
489+
// floor: the `error` path built a fixed string and never read `err`.
490+
const { signIn, at } = makeCauseSink(() => ORG_REQUIRED());
491+
492+
await signIn('usr_1');
493+
494+
const msg = at('error')[0].message;
495+
expect(msg).toMatch(/ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED/);
496+
expect(msg).toMatch(/system write requires an organization/);
497+
// The consequence half is unchanged — it is still owed, and still first.
498+
expect(msg).toMatch(/compliance trail is now INCOMPLETE/);
499+
});
500+
501+
it('prints the datasource remedy for the cause it is the remedy FOR, and not for others', async () => {
502+
// ⛔ Not a deletion: the ADR-0057 §3.6 routing text is genuinely correct for
503+
// the "no such table" cause it was written for, so it must still print
504+
// there. What is fixed is that it used to print for EVERY cause.
505+
const missing = makeCauseSink(() => NO_SUCH_TABLE());
506+
await missing.signIn('usr_1');
507+
const forMissingTable = missing.at('error')[0].message;
508+
expect(forMissingTable).toMatch(/telemetry/);
509+
expect(forMissingTable).toMatch(/OS_TELEMETRY_DB=0/);
510+
511+
// The misdirection: the cause is an organization refusal and the text said
512+
// "datasource".
513+
const refused = makeCauseSink(() => ORG_REQUIRED());
514+
await refused.signIn('usr_1');
515+
const forRefusal = refused.at('error')[0].message;
516+
expect(forRefusal).not.toMatch(/OS_TELEMETRY_DB/);
517+
expect(forRefusal).not.toMatch(/telemetry/i);
518+
// It still owes a fix — it just owes the RIGHT one.
519+
expect(forRefusal).toMatch(/Fix:/);
520+
});
521+
522+
it('[#9657] the `warn` fallback is per-cause too — a sink with no `error` hears the second fault', async () => {
523+
// `AuthEventAuditLogger.error` is OPTIONAL, so the degrade path is the only
524+
// channel a host without one ever gets. Fixing the dedupe on the `error`
525+
// branch alone would leave that host exactly where it started.
526+
const logs: Array<{ level: string; message: string }> = [];
527+
let phase = 0;
528+
const broken: any = {
529+
getSchema: () => null,
530+
insert: async () => {
531+
throw phase === 0 ? NO_SUCH_TABLE() : ORG_REQUIRED();
532+
},
533+
};
534+
const logger = {
535+
warn(message: string) {
536+
logs.push({ level: 'warn', message });
537+
},
538+
debug(message: string) {
539+
logs.push({ level: 'debug', message });
540+
},
541+
};
542+
const sink = createAuthEventAuditSink({ getEngine: () => broken, logger });
543+
544+
await sink.recordAuthEvent({ action: 'login', userId: 'usr_1' });
545+
phase = 1;
546+
await sink.recordAuthEvent({ action: 'logout', userId: 'usr_1' });
547+
548+
const warns = logs.filter((l) => l.level === 'warn');
549+
expect(warns).toHaveLength(2);
550+
expect(warns[1].message).toMatch(/ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED/);
551+
});
552+
});

0 commit comments

Comments
 (0)