Repository navigation
fix(plugin-audit): key the lost auth-event row report per CAUSE, and name the real cause in its first line - #18246
Conversation
…roduction, currently RED Drives two failures of DIFFERENT causes through `reportAuthEventWriteFailure` and shows the second degrades to `debug`, and shows the ADR-0057 §3.6 / `OS_TELEMETRY_DB` datasource hint printing for an organization refusal it is not the remedy for. 4 of 14 fail on the unfixed source. The three that pass — repeat-of-same-cause dedupe, code-keyed (never message-keyed) bounding, and the no-code fold — are the discriminating controls: a process-wide boolean satisfies them too, so they constrain the fix rather than demonstrate the defect. Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
…name the real cause in the first line `auth-event-audit.ts` carried a second, independent copy of both defects #15166 fixed in `audit-writers.ts`: 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; and its own fixed message literal, printing the ADR-0057 §3.6 / `OS_TELEMETRY_DB` datasource guidance unconditionally, regardless of what actually failed. The dedupe key is now the failure's identity — the error `code` (or its absence) plus the object — and the helpers are `audit-writers.ts`'s, imported rather than re-spelled: a second copy of the key is how the defect reached this file, so a third spelling would be the same mistake again. A repeat of an already-reported cause still degrades to `debug`; a new cause reports at `error`, once. The datasource guidance is kept and made conditional on the missing-table cause it is the remedy for, asked through the shared `isMissingTableError` predicate for the one table `persistAuthEventAuditRow` writes. Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 0c7232a313ed46bc8a53ceb304d4eb29bd966f74 && git checkout 0c7232a313ed46bc8a53ceb304d4eb29bd966f74
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b3b43b6ea0c30f35d861c9cc0fedc9c085027b7b d20bd87650185735e45342e52a63686bf7f94118 && git checkout -B drift-repro b3b43b6ea0c30f35d861c9cc0fedc9c085027b7b && git merge --no-ff d20bd87650185735e45342e52a63686bf7f94118
node scripts/docs-audit/affected-docs.mjs --json b3b43b6ea0c30f35d861c9cc0fedc9c085027b7b
|
…ocess (objectstack-ai#18595) Fixes objectstack-ai#18247 `reportReadAuditWriteFailure` (`packages/plugins/plugin-audit/src/read-audit.ts`) carried the THIRD independent copy of one defect pair — the pair objectstack-ai#15166 fixed in `audit-writers.ts` and objectstack-ai#17452 fixed in `auth-event-audit.ts`. This PR **removes the duplicate**; it does not write a fourth implementation. The two helpers objectstack-ai#18246 exported for exactly this purpose are imported. Clause-②: no — no export is added, no error code is added, and nothing is relaxed. Two already-exported helpers are imported and one existing message literal becomes conditional; `git diff` adds no `export` in `packages/`. ## The two defects, both live on a seam the repo already declared durability-critical 1. **Its own process-level `failureReported` boolean.** The first failure of ANY cause silenced every later failure of every OTHER cause for the life of the process. Record-view rows are written from a BUFFER off the request path, so there is no in-flight request left to notice, and the shipped `record_views` list view answers "who viewed this record" with a confident, wrong, SHORT list. 2. **Its own fixed message literal**, printing the ADR-0057 §3.6 / `OS_TELEMETRY_DB` datasource guidance unconditionally — so a fault with nothing to do with datasource routing (an `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` refusal, say) sent the operator to check something that was working. Its callee `persistReadAuditRows` is registered in `DURABILITY_CRITICAL_CALLEES` (`scripts/check-durability-degradation-log-level.mjs`), so the repo has already declared this write durability-critical. A reporter that switches itself off after one cause is exactly the failure that declaration cannot afford. ## What changed - The dedupe key is now `auditFailureCauseKey`, **imported** from `audit-writers.ts` rather than re-spelled — a second copy of the key is how this defect reached the second file. The counting unit is a DEGRADATION, and a second cause is a second degradation. - The object dimension of the shared key is the **ledger** (`sys_audit_log`), not the viewed object. One flush is ONE write carrying rows about MANY audited objects, so there is no single viewed object to name, and picking whichever landed first in the batch would make the key depend on traffic — the one property `auditFailureCauseKey` exists to deny. The key therefore reduces to the driver's code vocabulary: bounded by construction. (`audit-writers.ts` passes `ctx.object` and `auth-event-audit.ts` its constant `sys_session` because on those two seams the audited object IS single-valued per write.) - The first line now leads with `auditFailureCauseSummary(err, detail)`, so the driver's code and message reach the operator instead of being computed and dropped. - The ADR-0057 §3.6 remedy is asked for through the shared `isMissingTableError` predicate and printed for exactly the missing-table cause it was written for. `persistReadAuditRows` writes ONE table, so the question is asked about that one — unlike `persistAuditTrailRow`, which writes the ledger row and its `sys_activity` mirror and asks about both. Every other cause now gets the driver's own verdict plus the fix that matches it. ## What deliberately did NOT change - The once-per-degradation anti-noise rule itself. A repeat of an already-reported cause still degrades to `debug`, and AGENTS.md names "log every failure at `error`" as this rule's falsifier. - The `error`-then-`warn` sink fallback (objectstack-ai#9657) — and its dedupe is per-cause too, so a host that injected a logger without `error` hears the second fault as well. - `audit-writers.ts` is untouched: it is the source imported FROM. - The rule that an audit failure never reaches the read. ## Evidence ### Tests — 7 new pins, `packages/plugins/plugin-audit/src/read-audit.test.ts` Four pin the defects, three are the discriminating controls that must NOT move: ``` pnpm --filter @objectstack/plugin-audit test -> exit 0 24 files / 353 tests passed pnpm --filter @objectstack/plugin-audit typecheck -> exit 0 (tsc, tsconfig.scripts.json, check:test-typecheck: 0 files / 0 errors) ``` ### Ablation — the pins are capable of failing Both defects put back (`git show` of the pre-fix blob onto the path, proven on disk: `reportedReadAuditFailureCauses` 3 to 0, `missingTable` 2 to 0, `let failureReported = false;` 0 to 1, `SHORT answer. Fix: confirm` 0 to 1; mutated blob `f202954d` vs HEAD blob `ae1e1b9a`), then: ``` Tests 4 failed | 29 passed (33) x reports a SECOND, DIFFERENT cause at error - a new cause is a new degradation AssertionError: expected [ { level: 'error', ... } ] to have a length of 2 but got 1 x carries the underlying code and message in the first line it prints AssertionError: expected 'Read-audit write FAILED - 1 record-vi...' to match /ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED/ x prints the datasource remedy for the cause it is the remedy FOR, and not for others AssertionError: expected 'Read-audit write FAILED - 1 record-vi...' not to match /OS_TELEMETRY_DB/ x [objectstack-ai#9657] the `warn` fallback is per-cause too - a sink with no `error` hears the second fault AssertionError: expected [ { level: 'warn', ... } ] to have a length of 2 but got 1 ``` The three controls stayed GREEN on both sides, which is the half that matters: "still degrades a REPEAT of an already-reported cause to debug", "keys on the error CODE, never its message" (200 batches, 200 distinct messages, one code, one line) and "folds a fault carrying NO code into ONE bucket". Deleting the dedupe outright would redden those three. Restore leg: `git checkout HEAD -- PATH` (naming HEAD, never a bare checkout), blob back to `ae1e1b9a`, `git diff HEAD` for the path empty, `git status --porcelain` clean. No ablation artifact is left in the tree. ### Gates — `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`, reconciled with `--ran` 63 derived families, all run with the exit code captured to disk before reading; reconciliation reports `63 derived, 60 run, 3 NOT-MEASURED, 0 UNRUN`. - **60 green.** - **3 NOT MEASURED** — `check:dual-build-cjs-loads`, `check:i18n`, `check:type-check-debt` all exited **3**, the code these gates use for PREREQUISITE NOT MET: each needs a full `pnpm build` (57 packages have no `dist/` in this worktree, which built only plugin-audit's dependency closure). CI builds, so CI measures them. Not read as green and not as red. - **`pnpm check:durability-log-level` — run although this card's derivation does not name it**, because the diff sits in a `catch` guarding a registered durability-critical callee. Green: `36 durability-critical catch seam(s), all loud, rethrowing or propagating to the caller`. The gate has no objection to this change. - **`pnpm check:cross-package-test-inputs` exited 1, and the finding is not this diff's.** It names `packages/cli/test/init-created-files-summary.e2e.test.ts` descending `packages/spec/dist/` — no path of mine. Mechanism: `coversDirectory`/the walked-root radius answer with `readdirSync` against the real filesystem, and `packages/spec/dist/` is a gitignored build artifact that exists here only because the dependency-closure build created it. Control: the same gate on a checkout with no `packages/spec/dist` exits 0 (`OK: 29 package(s) read outside themselves, all declared`). Reported below rather than ridden in. ### Lint — the whole population, not a narrowing ``` node --stack-size=4000 node_modules/eslint/bin/eslint.js . --no-inline-config --format json -> exit 0 files in eslint population: 6803 files with findings: 0 ``` Run at `6195b00` (the final commit), on a clean tree. ## Acceptance notes - **`reportOverflow` in the same file was examined and is NOT this class.** Its report has no cause dimension at all — the buffer overflowing is one condition, it takes no `err`, and its remedy text is already cause-agnostic. A cause key there would key on nothing. Noted, not filed; successor: whoever next touches this batcher. - **`packages/services/service-settings/src/config-change-audit.ts:157` stays out, and my reading agrees with the card's.** 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 already cause-agnostic. An observation, not a contract violation. - **`check:cross-package-test-inputs` reverses its verdict on a gitignored build artifact** (see Gates above) — reported to the PM with dedupe words for the filing seat, not filed from here and not fixed here. --- _Generated by [Claude Code](https://claude.ai/code/session_01WmBwEiWPff9JZPd5BSGNeH)_ Co-authored-by: Claude <noreply@anthropic.com>
Fixes #17452
Clause-②: no
What this is
packages/plugins/plugin-audit/src/auth-event-audit.ts— the writer behind thelogin/logoutrows of the compliance ledger — carried a second, independent copy of both defects that #15166 removed fromaudit-writers.ts:failureReportedboolean, so after the first failure of ANY cause, every later failure of every OTHER cause degraded todebugfor the life of the process;OS_TELEMETRY_DBdatasource guidance unconditionally, regardless of what actually failed.persistAuthEventAuditRowis registered inDURABILITY_CRITICAL_CALLEES(scripts/check-durability-degradation-log-level.mjs:361), whose entire purpose is that durability loss is reported aterror. 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/mainatb3b43b6ea(this branch's base), they had not:audit-writers.tsauth-event-audit.ts(before)reportAuditWriteFailurereportAuthEventWriteFailurereportedAuditFailureCauses(aSet)failureReported(a boolean)(engine as any).loggerAuthEventAuditLogger,errorOPTIONALsys_audit_log+sys_activitysys_audit_logonlySo 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—auditFailureCauseKeyandauditFailureCauseSummarybecome module exports so the auth-event sink can use them. ⛔ Not added tosrc/index.ts: the sharing is internal to the package. This mirrors the existingcreateFieldPresenceProbeimport that already crosses these two files.auth-event-audit.ts— the boolean becomesreportedAuthEventFailureCauses, aSetkeyed byauditFailureCauseKey(SESSION_OBJECT, err). The firsterrorline leads withauditFailureCauseSummary(err, detail)— the code and message that were already computed one line above the branch and passed only into thedebugpayload. The datasource guidance is kept and made conditional onisMissingTableError(err, 'sys_audit_log'), asked for the one table this writer writes.auth-event-audit.test.ts— 7 new cases (see below).patchchangeset.⛔ 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
3985dd2bbis the pin block alone, on top of unmodified source.pnpm --filter @objectstack/plugin-audit exec vitest run src/auth-event-audit.test.ts: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 typecheckexit 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 -con the anchor text and on the injected text, plus the on-disk blob hash moving), runs the suite, then restores withgit checkout HEAD -- pathand provesgit hash-objectequals the HEAD blob andgit diff HEADis empty. HEAD blobba07853195ca57faf56734d32e005a836cd0aae2before and after all three. No build/distleg is owed: the suite imports./auth-event-audit.jsrelatively andvitest.config.tsaliases@objectstack/typesto source, so nothing on the tested path resolves throughdist.31b8e4c…reports a SECOND, DIFFERENT cause at error,[#9657] the warn fallback is per-cause toomissingTableforced totrue(= the old unconditional hint)13edd5b…prints the datasource remedy for the cause it is the remedy FOR, and not for others7d998a1…reported at ERROR, oncecaseThe 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 ⇒ 1error+ 4debug, and thedebuglines carry the samecausekey.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 ⇒ 1error.folds a fault carrying NO code into ONE bucket rather than growing one— 200 sign-ins, no code at all ⇒ 1error.⇒ 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.tsis untouched (git diff origin/main --staton it is empty), andtsupbuilds the single entrysrc/index.ts..d.tsexport list of the published entry (dist/index.d.ts): 27 names, neitherauditFailureCauseKeynorauditFailureCauseSummaryamong them.name in await import('dist/index.mjs')(the pathexports["."]names):⇒
Clause-②: no, derived by reachability rather than by the wordexport. The changeset is gradedpatch.Gate denominator
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 65 families at HEADd20bd8765. All 65 were run with exit codes recorded before any pipe, and reconciled: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 buildover 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…)andcheck-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-levelexit 0 —✓ durability-degradation log levels: 36 durability-critical catch seam(s), all loud….eslint — a declared narrowing, with its three readings
Repo-wide
pnpm lintis CI's run. Narrowed here to the three source files this diff touches, at HEADd20bd8765:① Population read from eslint's own
eslint.config.mjs, not guessed: the base block isfiles: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']with furtherpackages/**/*.{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.mjsstates at its own line 328 that this repo "runs oneeslint.config.mjs, which never enables type-aware linting (noparserOptions.project, no typed@typescript-eslintrules) 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:
packages/plugins/plugin-audit/src/read-audit.ts(lines 484 onward).reportReadAuditWriteFailurehas its own process-levelfailureReportedboolean and its own fixed literal carrying the same unconditional ADR-0057 §3.6 /OS_TELEMETRY_DBguidance — and its calleepersistReadAuditRowsis registered inDURABILITY_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 [finding] plugin-audit:auth-event-audit.tscarries a SECOND copy of both #15166 defects — its own process-level dedupe boolean and its own unconditional datasource hint #17452 and finding(plugin-audit):reportAuditWriteFailurereports once per process then degrades todebug, and its first line blames the telemetry datasource for every repeated cause — an operator is misdirected on the second loss of any kind #15166 both returning as the firing control. Dedupe words for whoever files it:read-audit.ts·reportReadAuditWriteFailure·persistReadAuditRows·failureReported· third copy.packages/services/service-settings/src/config-change-audit.ts:157carries the same process-widefailureReportedshape, but it is not the same class: its callee is a bareeng.insertthat no register names, its first line already carriesCause:plus the real detail, and its remedy text is cause-agnostic (it explains thatplugin-auditis optional). An observation, not a contract violation. Successor: whoever takes theread-audit.tscard above, as the same sweep.Notes for review
DURABILITY_CRITICAL_CALLEESis untouched, per the card's fence.packages/specis untouched.login/logoutwriters on the auth session hooks, and attribute the unattributedlast_login_atupdate row #8144 casea failed ledger write is reported at ERROR, once, and never breaks the callerdrives the same cause twice, so it stays green under the new key unchanged — and leg C shows it is load-bearing.@objectstack/typesand itspaths/ vitest-alias entries were already added by PR fix(plugin-audit): report a lost audit row once per CAUSE, and name the real cause in the first line #17450, so no manifest or tsconfig change is owed forisMissingTableError.Generated by Claude Code