Skip to content

fix(plugin-audit): the Audit write FAILED line names the refused table and the row it lost - #21383

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-21262-audit-failure-line
Oct 2, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-21262-audit-failure-line

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #21262
Clause-②: no

What changed

reportAuditWriteFailure in packages/plugins/plugin-audit/src/audit-writers.ts prints the error line for a refused audit-trail insert. The record writer stores the sys_audit_log row that records who did it, then, when activities are enabled and the write has one, its sys_activity timeline row.

  • Which table. persistAuditTrailRow now moves a progress marker forward before the activity insert, so after a throw the caller knows which insert was refused. The line opens Audit write FAILED on TABLE and names that table. The writer decides the table. The table is no longer picked from a list.
  • Which row is lost. A refused sys_activity insert says the ledger row landed and only the activity row is lost. Its header reads "the activity timeline is now INCOMPLETE". A refused sys_audit_log insert says the ledger row is lost. When the object writes an activity row, it says that row is lost too, because it is written only after the ledger row.
  • Which remedy. The missing-table question is asked about the refused table first, then about the other table the writer writes. A SQLSTATE such as 42P01 with no phrase naming a relation answers "missing" for any table name. Asked in list order, it would have named sys_audit_log for a refused sys_activity insert. For a missing table, the line no longer asserts the datasource split. It names both causes the evidence cannot tell apart, in the order to check them: (1) schema sync never created the table, so look for Schema sync FAILED for object 'TABLE' in the boot log; (2) otherwise the ADR-0057 §3.6 telemetry-datasource split, with OS_TELEMETRY_DB=0. Any other cause keeps the driver-fault remedy unchanged.
  • The once key. The key gains the refused table: (audited object, refused table, error code). The sentence now states that key in place of "this CAUSE is reported ONCE". The debug repeat and the error meta carry table.

auditFailureCauseKey takes an optional third argument for the table. The two single-table sinks (auth-event-audit.ts, read-audit.ts) do not pass it, and their keys are byte-identical to before. Neither sibling file is touched.

Measured on main, then on this branch

Fixture: a MySQL-shaped refusal (ER_NO_SUCH_TABLE, errno 1146, Table 'objectstack.TABLE' doesn't exist) thrown by that table's own create. Four audited objects, three writes each. The fixture was a temporary test file, run and then deleted, never committed.

On main at f39760864, refused insert into sys_activity: 4 error lines, 8 debug lines, and 12 sys_audit_log rows landed. The first line:

Audit write FAILED (ER_NO_SUCH_TABLE: Table 'objectstack.sys_activity' doesn't exist) — the compliance trail is now INCOMPLETE. The audited write itself SUCCEEDED and is on disk, so the API returned success and nothing downstream looks broken; only the `sys_audit_log` row that records who did it never landed, and nothing retries it. Every subsequent audited write failing THIS WAY is losing its row the same way (this CAUSE is reported ONCE — raise the log level to `debug` to see the rest; a DIFFERENT cause gets its own `error` line). Fix: confirm `sys_audit_log` is reachable from the connection this write ran on. Its ADR-0057 §3.6 lifecycle class routes it to the dedicated `telemetry` datasource whenever one is registered (`os dev` provisions one by default as a SIBLING SQLite file), so a "no such table" here usually means the write executed against a DIFFERENT datasource than the one the table was created in — see framework#5226. Set `OS_TELEMETRY_DB=0` to keep every lifecycle-classed object on the primary datasource.

On main, refused insert into sys_audit_log: the same text with only the quoted driver message changed. 4 error lines, 8 debug lines, and no rows landed in either table.

On this branch, refused insert into sys_activity: still 4 error lines and 8 debug lines, and 12 sys_audit_log rows landed. The first line:

Audit write FAILED on `sys_activity` (ER_NO_SUCH_TABLE: Table 'objectstack.sys_activity' doesn't exist) — the activity timeline is now INCOMPLETE. The audited write itself SUCCEEDED and is on disk, and so did its `sys_audit_log` row that records who did it, so the API returned success and the compliance ledger is whole; only the `sys_activity` row — the entry the record's activity timeline and the recent-activity feed show — never landed, and nothing retries it. Every later audited write of 'crm_lead' that `sys_activity` refuses with this same code loses its `sys_activity` row the same way. This line is printed ONCE per audited object, refused table and error code: the same fault on another audited object prints its own line, and so does a different code. Raise the log level to `debug` to see every repeat. Fix: `sys_activity` does not exist on the connection this write reached, and this line cannot tell which of two causes that is, so check them in this order. (1) Schema sync never created it: the boot log then carries `Schema sync FAILED for object 'sys_activity'` with the driver's refusal of its DDL; fix that error and restart, and the table is created (a deployment that runs `OS_SKIP_SCHEMA_SYNC` creates it out-of-band instead). (2) Otherwise it was created on a DIFFERENT datasource than the one this write reached: its ADR-0057 §3.6 lifecycle class routes it to the dedicated `telemetry` datasource whenever one is registered (`os dev` provisions one by default as a SIBLING SQLite file) — see framework#5226. Set `OS_TELEMETRY_DB=0` to keep every lifecycle-classed object on the primary datasource.

On this branch, refused insert into sys_audit_log: the line opens Audit write FAILED on sys_audit_log, keeps "the compliance trail is now INCOMPLETE", and says that the sys_audit_log row never landed, "and neither did its sys_activity timeline row, which is written only after it". The remedy names sys_audit_log in both causes.

The once key: kept per audited object, with the table added

Why the missing-table remedy names two causes

The writer holds the error and nothing else. The boot reports a refused DDL through objectql's schema-sync report (Schema sync FAILED for object 'X'), but nothing records it where this writer can read it. The engine's datasource binding resolver is private. Telling the two causes apart would need a new seam in @objectstack/objectql, which is outside this card's file surface. So the line names both. The boot-log check comes first because it settles the question whenever that line is present.

Pins

These are in audit-writers.test.ts, block the line names the refused table and the lost row (#21262), with 9 cases. The engine refuses one table's insert from that table's own create.

  1. Refused sys_activity: the line names sys_activity, says the ledger row landed, and never says the sys_audit_log row was lost. It also checks the rows that actually landed.
  2. Refused sys_audit_log: the line names it and says the activity row due after it was lost too.
  3. An object with enable.activities: false: no activity row is claimed as lost.
  4. Missing table: both causes appear, each naming the refused table, with the boot-log check first.
  5. Any other refusal (a NOT NULL constraint): the driver-fault remedy appears, with neither missing-table cause.
  6. Code-only 42P01 on a refused sys_activity insert: sys_activity is named. This guards against picking the table by list order.
  7. Four objects with three writes each give 4 error lines and 8 debug lines, and the line states its key.
  8. The same object and the same code, with the two tables refusing in turn, give two lines, one per table.
  9. No value stored in the audited row appears in any log line or its meta. The line is operator text only.

The #5226 and #15166 blocks are byte-unchanged. The test diff is +232 / -0, and all of their pins pass.

Ablations (one-time proof, not kept)

Each one was run through scripts/ablation-replace.mjs in wrap mode, under the verify lock, on the committed fix. The test imports ./audit-writers.js, which resolves straight to src/audit-writers.ts. No package exports or dist/ is in the path, so no rebuild leg and no dist preflight was owed.

Mutation On-disk evidence Red
Refused table hard-coded: table: progress.writing, replaced by table: 'sys_audit_log' as AuditTrailTable, anchor 1 to 0, blob 437819f06fb9 to 0e5e65f028f2 4 of 87: pins 1, 5, 6 and 8 (pin 1 is the sys_activity line)
Key reverted: auditFailureCauseKey(object, err, table) replaced by auditFailureCauseKey(object, err) anchor 1 to 0, blob 437819f06fb9 to b89d418cd559 1 of 87: pin 8 (the count per table)
List order: the refused table is no longer asked first (sys_audit_log is asked first) anchor 1 to 0, blob 437819f06fb9 to 119abca80eea 1 of 87: pin 6

Every restore was proven the same way: the blob after restore equals the blob at HEAD (437819f06fb9), and git diff HEAD is empty.

Verification, at 743fc4e5e

  • pnpm --filter @objectstack/plugin-audit test: 36 files, 581 tests passed.
  • pnpm --filter @objectstack/plugin-audit typecheck: tsc --noEmit for src and for tsconfig.scripts.json, plus check:test-typecheck over tsconfig.test.json, which compiles the test files. All green.
  • packages/types driver-error-classification.callers.test.ts reads every isMissingTableError call under packages/, so it reads this file: 7 of 7 passed.
  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 65 commands, and all 65 exited 0. check:dual-build-cjs-loads and check:i18n first answered PREREQUISITE NOT MET (exit 3, nothing measured). After the turbo builds they name (all cached), both re-ran with exit 0. Reconciling with --ran gives 65 derived, 65 run, 0 NOT-MEASURED. That zero is derived from the recorded exit codes.
  • Also run, outside the derivation, all exit 0: check:durability-log-level (39 durability catch seams, all loud), check-changeset-fixed, check:authz-resolver, check:error-code-casing, check:filter-alias-parity, check:startup-registry-verdict.
  • check:doc-authoring: the runtime string keeps its one framework#5226 id, so the sibling-package prose-id ledger holds, with no growth and no burn-down.
  • ESLint, narrowed to the 2 changed TypeScript files with --no-inline-config --format json: 2 files linted, 0 errors, 0 warnings. Both files resolve a non-empty config under --print-config, so they are in eslint's own population. The config never enables type-aware linting (parserOptions.project and projectService are null for both), so this diff cannot move a verdict on any untouched file. The repo-wide pnpm lint is left to CI.
  • Left to CI: the path-scheduled CI jobs (Test Core, Temporal Conformance, Dogfood, Build Core) and the workspace type-check lanes.

Acceptance notes (not filed)

  • The same remedy in two sibling sinks. read-audit.ts (Read-audit write FAILED) and auth-event-audit.ts (Auth-event audit write FAILED) write only sys_audit_log, so the table they name is right. For a missing table, their remedy still says the datasource split is the usual cause, and a refused DDL gets the same misdirection there. Nobody has measured a missing sys_audit_log table through either seam, so this is a note, not a card. content/docs/kernel/runtime-services/audit-service.mdx ("The most common cause of a failing insert is a datasource split") describes the auth-event seam and carries the same claim. This PR makes none of those sentences false, so they stay as they are. Carrier: none.
  • The split may no longer be reachable by its original route. #5226 was a ledger insert riding a primary-datasource transaction. The engine's enforceTransactionOrigin now runs system-ledger writes inside a transaction outside it, on their own connection. That route to "no such table" looks closed, but this is inference from reading the code. The line keeps the split as cause (2) because the writer cannot rule it out.
  • The tracker id in the runtime string. The remedy still ends cause (2) with see framework#5226. Removing it would shrink scripts/doc-authoring-prose-id.baseline.json by one, and that file is outside this card's file surface.

Generated by Claude Code

claude added 3 commits October 2, 2026 08:59
…e and the row it lost

The line read the refused table from a list and always said the sys_audit_log
row was lost, even when the refused insert was sys_activity and every ledger
row landed. It now names the table persistAuditTrailRow had in flight when it
threw and the row that is lost. The missing-table remedy names both causes the
evidence cannot tell apart (schema sync never created the table, or the
datasource split) in the order to check them. The once key gains the refused
table, and the sentence states the key: once per audited object, refused
table and error code.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…er cause and the once key

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 2, 2026
@github-actions

github-actions Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-audit, touching 7 documentable anchor(s).

7 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 AuditTrailTable; a string literal in auditWriteFailureLine; a string literal in installAuditWriters))
  • content/docs/kernel/runtime-services/audit-service.mdx (via sys_activity (literal, a string literal in AuditTrailTable; a string literal in installAuditWriters), sys_audit_log (literal, a string literal in AuditTrailTable; a string literal in auditWriteFailureLine; a string literal in installAuditWriters))
  • content/docs/permissions/record-view-auditing.mdx (via sys_audit_log (literal, a string literal in AuditTrailTable; a string literal in auditWriteFailureLine; a string literal in installAuditWriters))
  • content/docs/permissions/system-context.mdx (via sys_activity (literal, a string literal in AuditTrailTable; a string literal in installAuditWriters), sys_audit_log (literal, a string literal in AuditTrailTable; a string literal in auditWriteFailureLine; a string literal in installAuditWriters))
  • content/docs/plugins/packages.mdx (via sys_activity (literal, a string literal in AuditTrailTable; a string literal in installAuditWriters), sys_audit_log (literal, a string literal in AuditTrailTable; a string literal in auditWriteFailureLine; a string literal in installAuditWriters))
  • content/docs/protocol/kernel/config-resolution.mdx (via sys_audit_log (literal, a string literal in AuditTrailTable; a string literal in auditWriteFailureLine; a string literal in installAuditWriters))
  • content/docs/ui/setup-app.mdx (via sys_activity (literal, a string literal in AuditTrailTable; a string literal in installAuditWriters), sys_audit_log (literal, a string literal in AuditTrailTable; a string literal in auditWriteFailureLine; a string literal in installAuditWriters))

⛔ 7 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 AuditTrailTable; a string literal in auditWriteFailureLine; a string literal in installAuditWriters))
  • content/docs/releases/v14.mdx (via sys_activity (literal, a string literal in AuditTrailTable; a string literal in installAuditWriters), sys_audit_log (literal, a string literal in AuditTrailTable; a string literal in auditWriteFailureLine; a string literal in installAuditWriters))
  • content/docs/releases/v16.mdx (via sys_activity (literal, a string literal in AuditTrailTable; a string literal in installAuditWriters))
  • content/docs/releases/v17/17-0.mdx (via sys_audit_log (literal, a string literal in AuditTrailTable; a string literal in auditWriteFailureLine; a string literal in installAuditWriters))
  • content/docs/releases/v17/17-1.mdx (via sys_audit_log (literal, a string literal in AuditTrailTable; a string literal in auditWriteFailureLine; a string literal in installAuditWriters))
  • content/docs/releases/v17/17-5.mdx (via sys_audit_log (literal, a string literal in AuditTrailTable; a string literal in auditWriteFailureLine; a string literal in installAuditWriters))
  • content/docs/releases/v17/17-6.mdx (via sys_activity (literal, a string literal in AuditTrailTable; a string literal in installAuditWriters), sys_audit_log (literal, a string literal in AuditTrailTable; a string literal in auditWriteFailureLine; 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
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 9 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 ee75aae1a8ebc2625e57074527d446151535634b → packageMentionDocs.

Which tree this was computed on

This run read content/docs from cc336cc723665ca92becf487c48d9ac0ab813a02 — the merge of head ff4678856ff5aa91e57884a5790c81f2de349829 into base ee75aae1a8ebc2625e57074527d446151535634b, 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 cc336cc723665ca92becf487c48d9ac0ab813a02 && git checkout cc336cc723665ca92becf487c48d9ac0ab813a02
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ee75aae1a8ebc2625e57074527d446151535634b ff4678856ff5aa91e57884a5790c81f2de349829 && git checkout -B drift-repro ee75aae1a8ebc2625e57074527d446151535634b && git merge --no-ff ff4678856ff5aa91e57884a5790c81f2de349829

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

⚠️ 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 ee75aae1a8ebc2625e57074527d446151535634b → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 2, 2026 10:21
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 2, 2026 10:21
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 2, 2026
Merged via the queue into main with commit 69a12a0 Oct 2, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21262-audit-failure-line branch October 2, 2026 10:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants