Skip to content

fix(plugin-audit): report a lost audit row once per CAUSE, and name the real cause in the first line - #17450

Merged
os-sales merged 3 commits into
mainfrom
claude/issue-15166-audit-failure-cause-keyed-report
Sep 10, 2026
Merged

os-sales merged 3 commits into
mainfrom
claude/issue-15166-audit-failure-cause-keyed-report

Conversation

@claude

@claude claude Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Fixes #15166

Clause-②: no

(declaration line written by the domain:services review seat, not by the implementer — the Clause-②: line is the claim's, and Check Changeset reads it only as a line-initial literal in this body. Measured on THIS diff, not predicted from the card: 0 added lines containing export, 0 new ERROR_CODE_LEDGER / code: entries, 0 added throws. The change imports an existing predicate (isMissingTableError) and alters a log line's wording plus an in-process dedupe key — no accept set, no published symbol, no error code, no wire field. The patch level stands. ⛔ Do not delete this line; the gate reads the body, so removing it re-reds the PR.)

reportAuditWriteFailure — the best-effort catch around persistAuditTrailRow — deduped on one process-wide boolean. Two consequences, both measured on origin/main before writing a line:

  1. After the first failure of ANY cause, every later failure of every OTHER cause degraded to debug for the life of the process. persistAuditTrailRow is registered in DURABILITY_CRITICAL_CALLEES precisely because a lost audit row must be reported at error.
  2. The one error line it did print named the telemetry-datasource remedy unconditionally, never reading err — so the ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED refusal measured on A sys_audit_log write refused by the system-write organization rule is swallowed by plugin-audit's best-effort catch, so the audit row about a defective record is LOST silently — surfaced by #13636's admission #14927 answered "check the datasource", sending the operator to inspect something that was working.

What changed

The dedupe key is now the failure's identity — the error code (or its absence) plus the object — instead of one process-wide boolean. A repeat of an already-reported cause still degrades to debug, exactly as before; a new cause reports at error, once.

The first error line now leads with the underlying code and message. That information was already computed one line above the branch (const detail = …) and passed only into the debug payload; nothing new is plumbed in to reach it.

The ADR-0057 §3.6 datasource guidance is kept, not deleted — it is the correct remedy for the "no such table" cause it was written for — and is now printed only for that cause, decided by the shared isMissingTableError predicate rather than a hand-rolled second vocabulary. It is asked about both tables persistAuditTrailRow writes (sys_audit_log and its sys_activity mirror), since ADR-0057 §3.6 routes both.

The premise this hung on, and how it came out

The direction on this card was conditional on falsifying one premise first:

Cause-keyed dedupe does not reintroduce the noise the once-per-process rule was invented against (#4420's unreadable warn).

The premise HOLDS. The named failure mode — a cause set that is unbounded or high-cardinality turning "one error per cause" back into a flood — did not materialise, because the key is built from the code and never the message. Measured against the real code path, driving installAuditWriters over the showcase reference install's audited object set (48 platform + 24 showcase objects declared, minus the 7 of SKIP_OBJECTS declared there = 65 audited objects):

stream failed writes distinct messages distinct codes error lines BEFORE error lines AFTER falsifier ("log every failure at error")
S — systemic, one cause, whole object set 6,500 1 1 1 65 6,500
H1 — per-ROW messages, one object, one code 5,000 5,000 1 1 1 5,000
H2 — per-ROW messages, NO code at all 5,000 5,000 absent 1 1 5,000
H3 — per-ROW messages across the whole object set 6,500 6,500 1 1 65 6,500
M — a second, DIFFERENT cause 2 2 2 1 error + 1 debug 2 errors 2

H1/H2/H3 are the named hazard — thousands of failures each carrying a distinct, row-naming message — and the error count did not move with them. The decisive reading is traffic-independence:

stream T failed writes error lines
1× volume 6,500 65
4× volume 26,000 65

Four times the traffic, identical cause set. The reported-cause set is bounded by two sets fixed at BOOT — the declared object registry and the driver's code vocabulary — and by nothing that grows with traffic. That is the property the anti-noise rule needs; #4420's shape was unbounded per-write growth, which the falsifier still is and this is not.

The residual, stated honestly: for ONE systemic cause spread across the whole audited object set, the operator now gets up to one line per audited object instead of one line total — measured 65 in the showcase reference install. Each is printed once and never again. This is the cost of the ruling's (code, object) granularity, and it is bounded; the falsifier would have printed 6,500 and kept going.

Ablation

The keying was reverted to the process-wide boolean on the committed tree, with the mutation proved on disk before the run (occurrence counts and git hash-object both moved), and the new pins went RED:

ON-DISK PROOF: reportedAuditFailureCauses 3 -> 0 ; auditFailureReported occurrences -> 3
ON-DISK PROOF: HEAD blob=379e7067ca053dff15eb50c56a16a817e05ed526 mutated blob=ebca6c716953fda4177f7f6657fe3e275805010a
MUTATED-RUN command-exit=1
     × reports a SECOND, DIFFERENT cause at error — a new cause is a new degradation
     × separates causes per OBJECT as well as per code, and stays bounded by both
AssertionError: expected [ { level: 'error', …(2) } ] to have a length of 2 but got 1
AssertionError: expected [ { level: 'error', …(2) } ] to have a length of 3 but got 1
 Tests  2 failed | 73 passed (75)

Restoration proved BY STATE, not by exit code: git diff HEAD empty, git status --porcelain empty, and git hash-object back to 379e7067ca053dff15eb50c56a16a817e05ed526 — the HEAD blob.

Verification

Measured at 8f683e278.

  • pnpm --filter @objectstack/plugin-audit test334 passed / 23 files, exit 0 (67 pre-existing cases in audit-writers.test.ts pass unchanged; 8 new).
  • pnpm --filter @objectstack/plugin-audit typecheck — exit 0.
  • pnpm check:durability-log-level — exit 0; its own verdict line: ✓ durability-degradation log levels: 29 durability-critical catch seam(s), all loud, rethrowing or propagating to the caller. ⛔ DURABILITY_CRITICAL_CALLEES is untouched.
  • pnpm --filter @objectstack/types exec vitest run src/driver-error-classification.callers.test.ts — exit 0. The new isMissingTableError call joins that repo-wide scan's population and names the object it read, as Nothing stops an in-repo caller from calling isMissingTableError without its read-table argument — and the silent result is the wide verdict #13324 just removed #13440 requires.
  • Gate families derived from the actual diff (dispatch-gates.mjs, re-derived after the change set moved): 74 derived, 71 run green, 3 NOT MEASURED, 0 unrun.
  • pnpm lint (eslint . --no-inline-config, whole repo) — exit 0.

NOT MEASURED: check:dual-build-cjs-loads, check:i18n, check:type-check-debt — all three exited 3 (PREREQUISITE NOT MET), one cause: they read built output across the whole workspace and no full dist/ closure exists locally. That is a farm-wide build, and it belongs to CI. ⛔ Recorded as not measured, never as a pass.

Deviations from the dispatched file surface

Two files outside the declared surface (audit-writers.ts + test siblings), both inside this package, both forced rather than chosen:

  • packages/plugins/plugin-audit/package.json — adds @objectstack/types (workspace), for isMissingTableError. AGENTS.md is explicit that a hand-rolled err.code === '42P01' is the wrong route and the shared predicate is the right one; the predicate is not reachable from this package's existing dependencies and neither @objectstack/core nor @objectstack/objectql re-exports it. No transitive edge is added — core already depends on types.
  • packages/plugins/plugin-audit/tsconfig.json — a paths rule for that dependency. pnpm check:type-source-resolution went RED on the new import (exit 1) and prescribed exactly this: without it, this package's typecheck would be a verdict about types/dist build state. Same entry, spelling and reasoning plugin-security records for the identical import. The gate is green after it.

auth-event-audit.ts was not touched — see the sibling finding below.

Sibling finding — not fixed here

auth-event-audit.ts carries a second, independent copy of this shape, not a shared reporter: its own let failureReported = false and its own reportAuthEventWriteFailure, with its own message literal. Its docblock says so in as many words — "Same discipline, and the same reason, as reportAuditWriteFailure in audit-writers.ts". Both defects this PR fixes are present there verbatim: a process-level boolean, and an unconditional datasource remedy. Widening this PR to cover it was out of scope per the dispatch, so it is reported rather than fixed.

Out of scope, unchanged

The tenancy refusal path for sys_audit_log (ADR-0131 D7; #14927 closed); any change to DURABILITY_CRITICAL_CALLEES; and "log every failure at error", the named falsifier, which this is not.


Generated by Claude Code


Generated by Claude Code

…he real cause in the first line

`reportAuditWriteFailure` deduped on one 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, and the one `error` line it did print named
the telemetry-datasource remedy unconditionally — the measured
`ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` refusal sent its operator to check a
datasource that was working.

The dedupe key is now the failure's identity — the error `code` (or its
absence) plus the object. A repeat of an already-reported cause still degrades
to `debug`; a new cause reports at `error`, once. The key is built from the
`code` and never the message, which is what keeps the cause set bounded by the
boot-declared object registry and the driver's code vocabulary rather than by
traffic.

The ADR-0057 §3.6 datasource guidance is kept and made conditional on the
missing-table cause it is the remedy for, asked through the shared
`isMissingTableError` predicate for both tables `persistAuditTrailRow` writes.

Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW
Co-authored-by: Claude <noreply@anthropic.com>
`pnpm check:type-source-resolution` reds on the new dependency: without a
`paths` rule this package's typecheck would be a verdict about
`types/dist` build state rather than about the checkout. Same entry, same
spelling, same reasoning `plugin-security` records for the identical import.

Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Sep 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-audit, touching 5 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/plugins/plugin-audit/package.json, packages/plugins/plugin-audit/tsconfig.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/deployment/production-readiness.mdx (via sys_audit_log (literal, a string literal in installAuditWriters))
  • content/docs/kernel/runtime-services/audit-service.mdx (via sys_activity (literal, a string literal in installAuditWriters), sys_audit_log (literal, a string literal in installAuditWriters))
  • content/docs/permissions/record-view-auditing.mdx (via sys_audit_log (literal, a string literal in installAuditWriters))
  • content/docs/plugins/packages.mdx (via sys_activity (literal, a string literal in installAuditWriters), sys_audit_log (literal, a string literal in installAuditWriters))
  • content/docs/protocol/kernel/config-resolution.mdx (via sys_audit_log (literal, a string literal in installAuditWriters))
  • content/docs/ui/setup-app.mdx (via sys_activity (literal, a string literal in installAuditWriters), sys_audit_log (literal, a string literal in installAuditWriters))

5 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/index.mdx (via sys_audit_log (literal, a string literal in installAuditWriters))
  • content/docs/releases/v14.mdx (via sys_activity (literal, a string literal in installAuditWriters), sys_audit_log (literal, a string literal in installAuditWriters))
  • content/docs/releases/v16.mdx (via sys_activity (literal, a string literal in installAuditWriters))
  • content/docs/releases/v17/17-0.mdx (via sys_audit_log (literal, a string literal in installAuditWriters))
  • content/docs/releases/v17/17-1.mdx (via sys_audit_log (literal, a string literal in installAuditWriters))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/plugins/plugin-audit/package.json, packages/plugins/plugin-audit/tsconfig.json) — pages documenting those are invisible to this run
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 8 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json ab56ea3a1ea6fde6161781c93a013fd494d39193packageMentionDocs.

Which tree this was computed on

This run read content/docs from b901b4d3392ca36f0434c5336c20291301cae3f1 — the merge of head 8f683e2784b016f20326c0e2fa50b7a87ee8e3ee into base ab56ea3a1ea6fde6161781c93a013fd494d39193, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b901b4d3392ca36f0434c5336c20291301cae3f1 && git checkout b901b4d3392ca36f0434c5336c20291301cae3f1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ab56ea3a1ea6fde6161781c93a013fd494d39193 8f683e2784b016f20326c0e2fa50b7a87ee8e3ee && git checkout -B drift-repro ab56ea3a1ea6fde6161781c93a013fd494d39193 && git merge --no-ff 8f683e2784b016f20326c0e2fa50b7a87ee8e3ee

node scripts/docs-audit/affected-docs.mjs --json ab56ea3a1ea6fde6161781c93a013fd494d39193

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs ab56ea3a1ea6fde6161781c93a013fd494d39193 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-sales
os-sales marked this pull request as ready for review September 10, 2026 15:47
@os-sales
os-sales enabled auto-merge September 10, 2026 15:47
@os-sales
os-sales added this pull request to the merge queue Sep 10, 2026
Merged via the queue into main with commit ab48938 Sep 10, 2026
41 of 42 checks passed
@os-sales
os-sales deleted the claude/issue-15166-audit-failure-cause-keyed-report branch September 10, 2026 16:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants