Skip to content

fix(metadata-protocol): auditMetaItem propagates a failed audit read instead of reporting an empty trail - #9786

Merged
os-elon merged 2 commits into
mainfrom
claude/issue-9638-audit-read-catch-narrowing
Aug 19, 2026
Merged

fix(metadata-protocol): auditMetaItem propagates a failed audit read instead of reporting an empty trail#9786
os-elon merged 2 commits into
mainfrom
claude/issue-9638-audit-read-catch-narrowing

Conversation

@os-elon

Copy link
Copy Markdown
Collaborator

Fixes#9638.

The defect

packages/metadata-protocol/src/protocol.ts, the catch closing the read inside
ObjectStackProtocolImplementation.auditMetaItem (located by symbol — the file moves):

}catch(err: any){// Table not provisioned (legacy env) or driver doesn't// expose `find` — return empty rather than 500ing the tab.console.warn(...);return{events: []};}

The comment names two benign causes. The clause was unqualified and took every
other one with them — a connection drop, a permission denial, a malformed row, a query
bug, a timeout. All of them reached the caller as the well-formed statement "this item
has no audit entries"
.

ADR-0110 D3: a miss and a fault are different facts. This is the compliance surface —
auditMetaItem is the read behind GET /api/v1/meta/:type/:name/audit, which exists so
Studio's audit-log tab can show who tried what and whether a lock blocked it. An empty
answer there reads as nobody touched this item. It is the same collapse #9426 / PR #9637
fixed at the route one layer up, and worse in one respect: the route's condition was a
static capability gap, while this is a transient read failure, so the same item can
report a full trail one minute and a clean one the next.

The measurement that shaped the fix

The dispatch flagged an assumption worth testing: the benign set may be two distinct
errors, not one
. It is, and they are not the same kind of thing.

pathwhat it actually raisesisMissingTableError
table not provisioned, sqliteno such table: sys_metadata_audittrue
… sqlite, driver-prefixedSQLITE_ERROR: no such table: …true
… postgresrelation "sys_metadata_audit" does not existtrue
… mysqlTable 'db.sys_metadata_audit' doesn't existtrue
host engine exposes no findTypeError: p.engine.find is not a functionfalse
connection dropconnect ECONNREFUSED 127.0.0.1:5432false
permission denialpermission denied for table sys_metadata_auditfalse
timeoutquery timeoutfalse

So a predicate written only against isMissingTableError would have been a fail-closed
regression
on the second benign cause — it would have started 503-ing the documented
"metadata-only store" deployment shape.

The change

Two limbs, because measurement says the two benign causes are different kinds of fact.

1. The capability limb is a precondition, not an error shape.

if(typeof(this.engineas{find?: unknown}).find!=='function'){return{events: []};}

Asked before the try, because it cannot be asked soundly inside the catch. A missing
method raises TypeError: … is not a function, and the only signal separating that from a
genuine TypeError raised inside a real driver's find — a null deref on a malformed
row, an actual fault — is the V8 message text. Sniffing that text would re-open exactly the
fail-open this card closes, one error class narrower. A typeof probe is a fact about the
engine, not a guess about an error, so it cannot misclassify a fault as a capability gap.

This is the same shape as the limb one layer up: the /audit route's own capability probe
(typeof p.auditMetaItem !== 'function', #9426) likewise decides before the call
rather than classifying its failure.

2. The catch now carries exactly one benign cause, which makes it byte-for-byte the
shape of the already-reviewed sibling listCommits (#5980) in this same file:

this.rethrowUnlessMetadataStoreUnprovisioned(err);console.warn();return{events: []};

rethrowUnlessMetadataStoreUnprovisioned is this file's existing, declared spelling for
the propagating half — it asks the shared isMissingTableError predicate that
DatabaseLoader (#5108) and SysMetadataRepository (#4867) ask, and otherwise throws
metadataStoreUnavailableError: 503 / SERVICE_UNAVAILABLE carrying the driver error as
cause. The route already wraps this call in handleRouteError, which reads
error.status, so the honest 5xx needs no change in packages/rest.

The console.warn now sits after the rethrow, so it fires only on the benign path.

On the propagating half's vocabulary (#8901)

The dispatch asked me to check whether #8901 governs the spelling. It has not settled
one
— it is pm:on-hold, and it is about giving the gate's read-seam rule its own
declared FAILURE_PROPAGATION_* vocabulary, not about how a seam should spell its throw.
Per the dispatch ("if it has not, say so and choose"), I chose the spelling this file
already uses on its sibling reads, adding no new vocabulary.

Scope — Option 2 not built, and no fork

Option 2 (a third wire state distinguishing "read failed") is not commissioned and is
not here. No response schema is widened; no packages/spec file is touched. The narrow-catch
can express the fix without a response-shape or contract change, so the fork clause does
not bind: { events: [] } still means exactly what it documented, and the only change is
that a fault stops being spelled as one.

The pin — both halves

packages/metadata-protocol/src/protocol.audit-read-failure-propagation.test.ts, 13 cases.
Both directions, because a method that raised unconditionally would satisfy the first half
and destroy the documented feature:

  • propagating — three non-benign flavours (connection drop / permission denial /
    timeout) each raise, asserted on the ADR-0112 paircode + status
    (SERVICE_UNAVAILABLE + 503) rather than a bare toThrow, which could not separate
    "answered with the wrong body" from "did not raise at all" — and the wrong body is the
    defect. Plus: the driver error rides as cause, the status sits in the 5xx band
    handleRouteError acts on, and a TypeError from a driver that does have find is a
    fault, not a capability gap;
  • benign — the unprovisioned table still answers { events: [] } in all four driver
    phrasings, and a host engine with no find still answers { events: [] }.

Anti-vacuity

An "it propagates" assertion is worthless if the assertions cannot tell a populated trail
from an empty one — the body.item.fields vs body.data.item.fields shape this repo has
been bitten by. So the file carries a positive control: a real row is read all the way
through the mapping and its fields asserted (actor, outcome, lockState,
lockOverridden, requestId, note), plus an explicit equivalence pin that a genuine
zero-row read and a fault are no longer the same answer.

Reverse verification — direction predicted in writing before running

Predicted: reverting protocol.ts turns 7 of 13 red — the three propagation flavours,
the cause pin, the 5xx-band pin, the driver-TypeError pin and the equivalence pin —
while the six benign/positive-control cases stay green (the ablation touches neither
the success path nor either benign answer), and protocol.audit-org-scope.test.ts (#8747,
same method) stays fully green because every engine it supplies has find and resolves.

Observed, exactly that — 7 red, 6 green, org-scope 7/7 green:

 × ⭐ THE PIN — a connection drop propagates as 503 SERVICE_UNAVAILABLE instead of `{ events: [] }`
× ⭐ THE PIN — a permission denial propagates as 503 SERVICE_UNAVAILABLE instead of `{ events: [] }`
× ⭐ THE PIN — a timeout propagates as 503 SERVICE_UNAVAILABLE instead of `{ events: [] }`
× the driver error rides as `cause`, so the operator still sees what actually broke
× 503 is a status `handleRouteError` turns into a 5xx — not a 2xx and not a client error
× the missing-`find` answer is decided BEFORE the read, not by classifying a TypeError
× ⭐ a genuine zero-row read and a FAULT are no longer the same answer
Test Files 1 failed | 1 passed (2)
Tests 7 failed | 13 passed (20)
Error: expected the read failure to propagate, but it RESOLVED with {"events":[]}
— the defect: a fault disguised as an empty audit trail

The fix was committed before the ablation and restored with git checkout HEAD --,
then proved byte-identical (git diff --exit-code, exit 0, clean tree). The tests import
./protocol.js relative in-package, so vitest resolves src/ directly — no dist is
involved and the red proves the ablation reached the code under test.

Read-coupling with #9657 — measured post-merge, not pre-merge

check:durability-log-level is the gate that judges exactly this catch, and devx card
#9657 changed its matcher (PR #9750) while this was in flight. origin/main was merged
into this branch at e9534a4ac and the gate re-run after the merge, so the reading
below is against the new callee-reading matcher, not the one this branch forked from:

✓ durability-degradation log levels: 29 durability-critical catch seam(s), all loud,
rethrowing or propagating to the caller (5 propagating, declared).
✓ read-seam invention (#5186 + #6451, 3 package roots): 66 read seam(s), none invents an
unreported answer (7 answer on a type-discriminated benign branch)
(1 pass an input through, reported) (1 baselined).

Counts unmoved in both directions, and no baseline was raised or added.

⚠️ Worth recording, because it is the honest reading rather than a claim of gate coverage:
this gate was green before this fix too. The old catch was loud — it called
console.warn — and the read-seam rule asks whether a catch invents a silent answer, not
whether the answer it invents is correct. So the gate could not have caught this defect,
which is precisely the expressiveness gap #8901 exists to record. It neither helped nor
fought this change.

Verification — all at f28e67651, the final commit (post-merge)

pnpm --filter '@objectstack/metadata-protocol^...' build closure built first
pnpm --filter @objectstack/metadata-protocol test Test Files 123 passed | 2 skipped (125)
Tests 1697 passed | 10 skipped (1707)

Suite arithmetic, stated because the file count does not move on its own: the pre-change
baseline was 124 files / 1684 tests, and this branch adds one file of 13 cases → 125 / 1697.

⚠️@objectstack/metadata-protocol declares no typecheck script — it is one of the 13
packages on the DEBT ledger, so pnpm --filter … typecheck exits 1 with
ERR_PNPM_RECURSIVE_RUN_NO_SCRIPT rather than silently passing. Type coverage for this
change is therefore carried by the ledger ratchet below and by the package's tsup DTS build
(exit 0), not by a per-package tsc --noEmit.

Gates re-derived from the actual changed paths with node scripts/pm/dispatch-gates.mjs
(no paths passed — the script derives its own change set from the merge base). All five
dispatched gates matched; the derivation added ten beyond them — the changeset family
(check:changeset-gate-self-tests, check:objectui-changeset,
check-adr-0087-registration.mjs, check-changeset-no-major.mjs,
check-empty-changeset.mjs) and the convention-triggered family a new test file pulls in
(check:query-options-erasure, check:type-check-coverage, check:type-check-debt,
check:engine-double-contract, check:where-matcher). All run post-merge, all green:

check:changeset-gate-self-tests OK check:cross-package-test-inputs OK
check:durability-log-level OK check:filter-alias-parity OK
check:objectui-changeset OK check:query-options-erasure OK (ratchet holds: 67 unswept non-test sites, none new)
check:type-check-coverage OK check:engine-double-contract OK
check:where-matcher OK (255 matchers, 152 refuse)
check:nul-bytes OK
check-adr-0087-registration.mjs OK check-changeset-no-major.mjs OK
check-cross-package-test-inputs.mjs OK check-empty-changeset.mjs OK
check-affected-docs.mjs OK

Ratchet family at the final head, after the full closure build
(pnpm exec turbo run build --filter=./packages/* --filter=./packages/*/*, 70/70 successful):

check:type-check-debt (--re-measure): OK — 33 ledger entr(ies) re-measured in 310.1s,
1926 raw tsc error(s) total, none above its recorded number.
surplus: none — every entry sits exactly at its measurement, so any new error is red.

No baseline was raised or added anywhere in this PR.


Generated by Claude Code

os-elonand others added 2 commits August 18, 2026 22:41
…instead of reporting an empty trail (#9638)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 6 documentable anchor(s).

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

  • content/docs/api/client-sdk.mdx(via getAudit (sdk), meta.getAudit (sdk))
  • content/docs/concepts/metadata-lifecycle.mdx(via ObjectStackProtocolImplementation (symbol))

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

  • content/docs/releases/v16.mdx(via ObjectStackProtocolImplementation (symbol))
  • content/docs/releases/v17.mdx(via ObjectStackProtocolImplementation (symbol))

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

Coarse fallback — 7 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 985a9cd2dbbad0bec9edce107f35d20791c9ac5cpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 351bc491e1ac25276f3f97cd8221a6b9e1fa758f — the merge of head f28e676510b2a4bb452711207a34f4eccbe9baef into base 985a9cd2dbbad0bec9edce107f35d20791c9ac5c, 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 351bc491e1ac25276f3f97cd8221a6b9e1fa758f && git checkout 351bc491e1ac25276f3f97cd8221a6b9e1fa758f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 985a9cd2dbbad0bec9edce107f35d20791c9ac5c f28e676510b2a4bb452711207a34f4eccbe9baef && git checkout -B drift-repro 985a9cd2dbbad0bec9edce107f35d20791c9ac5c && git merge --no-ff f28e676510b2a4bb452711207a34f4eccbe9baef
node scripts/docs-audit/affected-docs.mjs --json 985a9cd2dbbad0bec9edce107f35d20791c9ac5c

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

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

auditMetaItem's unqualified catch reports ANY failed audit read as {events: []} — the compliance trail says "no entries" when the read broke

2 participants

@os-elon@claude