Skip to content

fix(rest): refuse when auditMetaItem is absent instead of answering "this item has no audit trail" - #9637

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-9426-audit-route-capability-refusal
Aug 18, 2026
Merged

fix(rest): refuse when auditMetaItem is absent instead of answering "this item has no audit trail"#9637
os-zhuang merged 2 commits into
mainfrom
claude/issue-9426-audit-route-capability-refusal

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #9426.

The defect

GET /api/v1/meta/:type/:name/audit feature-detects auditMetaItem on the
resolved protocol. When the method was absent, the route answered:

res.json({ events: [] })

ADR-0110 D3 collapsed — a miss and a fault are different facts. The two merged
here are "the audit trail was read and this item has no entries" and "this
deployment cannot read an audit trail at all"
, and they reached the wire as the
same 200 { events: [] }.

This is a compliance surface. The route's own comment says it 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
precisely the claim a compliance reader must not be given on false pretenses.

This was the last limb in rest-server.ts answering a capability gap with a
well-formed empty collection. Five siblings already refused; #9326 / PR #9425
landed the findReferencesToMeta twin, and this PR mirrors it.

The change

501  { error: { code: 'NOT_IMPLEMENTED',
                message: 'protocol.auditMetaItem() is not available in this kernel' } }

The ADR-0112 nested envelope #7035 converged the sibling /meta 501 refusals
onto, so body.error.code is readable by the same one line of consumer code that
already reads the others. Message wording follows the converted siblings
(protocol.NAME() is not available in this kernel).

Deliberately not the shape /layers uses: that is the sibling-code dialect,
counted by both shrink-only dialect ratchets check:route-envelope holds on
this file, so copying it would have pushed a ratchet up.

Zone 2 — both items measured, not assumed

1. Is the empty answer load-bearing for an unprovisioned table? No — separate paths, not conflated. No fork.

The route's header comment promises "Empty array on environments where the table
is not yet provisioned". That condition is real, and it lives one layer down:

packages/metadata-protocol/src/protocol.ts, inside
ObjectStackProtocolImplementation.auditMetaItem (the catch closing its read):

} 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: [] };
}

That path requires the method to exist and to be called. The route's
branch returns before the call, on a typeof probe of the protocol object.
Different files, different packages, different stack frames, and no control flow
joins them — so refusing at the route leaves the unprovisioned-table answer
byte-for-byte as it was. Two states here, not three; no fork report needed.

Corroborated at runtime rather than only by reading: the packages/rest suite
already exercises the downstream path, logging no such table: sys_metadata_audit from SqlDriver.create while every audit case stays green.

The client-side JSDoc that documents the same promise
(packages/client/src/index.ts, getAudit: "{ events: [] } where the audit
table is not provisioned") therefore stays accurate and is untouched.

2. Ratchet arithmetic — measured by running the gate, both before and after

counter before after
stringError 44 44
siblingCode 69 69

Neither counter moved, confirming empirically that the nested shape is counted
by neither. Nothing ticked up; nothing ticked down (the removed
res.json({ events: [] }) was not an error body, so no counted site disappeared).

⚠ ratchet #9559: packages/rest/src/rest-server.ts
  (dialect facts only — stringError 44, siblingCode 69; ticks down only)

The pin

packages/rest/src/meta-audit-capability-gap.test.ts asserts the distinction in
both directions, because a route that refused unconditionally would satisfy
the first half and destroy the feature:

  • a protocol without auditMetaItem gets the refusal, pinned by
    error.code and HTTP status together (the ADR-0112 pair, at the nested
    position) — never a bare toThrow, which could not separate "answered with the
    wrong body" from "did not refuse at all", and the wrong body is the defect;
  • a protocol with it is untouched — the genuinely-empty case
    ({ events: [] }, which is also exactly what an unprovisioned table produces
    downstream) and a populated case both still pass through verbatim as 200.

Reverse verification — direction predicted in writing before running

Predicted: restoring the empty-body limb turns exactly three of the four new
cases red — the 501 assertion, the equivalence pin, and the machine-readable case
— while "a protocol that CAN answer is untouched" stays green (the ablation
does not touch the success path), and rest-server-audit-org-scope.test.ts
(#8747, same route) stays fully green because every one of its cases supplies a
protocol that has the method.

Observed, exactly that:

 × an absent capability answers 501 NOT_IMPLEMENTED at the ADR-0112 nested position
   AssertionError: expected 200 to be 501
 × ⭐ THE PIN — the capability gap and a genuine zero are not the same answer
   AssertionError: expected 200 not to be 200
 × the refusal is machine-readable by the SAME read as its `/meta` 501 siblings
   AssertionError: expected undefined to be 'NOT_IMPLEMENTED'
 Test Files  1 failed | 1 passed (2)
      Tests  3 failed | 7 passed (10)

expected 200 not to be 200 is the defect stating itself. The fix was committed
before the ablation and restored with git checkout from the branch, then
proved byte-identical to the committed state (git diff --exit-code, exit 0,
clean tree).

Does any caller's observed response change?

Yes, on one deployment shape, and only there. A protocol that has the method is
untouched. What changes is the answer when the protocol has no such method —
previously 200 with an empty list, now 501.

No in-repo assembly produces such a protocol today:
ObjectStackProtocolImplementation is the only implementation registered under
the protocol service and it defines the method unconditionally. The branch is
reachable rather than dead because auditMetaItem is not a member of
RestProtocol (= DataProtocol & MetadataProtocol) and is not declared in
packages/spec at all — an ADR-0076 D9 server-only extension reached through a
runtime cast. A host implementing the declared contract exactly is a conforming
deployment that lands here with no type error.

Refusing at the route rather than asserting at assembly is deliberate: a boot-time
assertion would promote an undeclared optional extension into a required one — a
packages/spec contract decision, not a route one — and would reject the partial
protocol doubles that legitimately exist today.

Fences honoured

A wire-level coverage discriminator stays fenced to packages/spec and is
not introduced. No docs/adr/** edit. No ratchet raised. NOT_IMPLEMENTED is
already a registered code with canonical status 501, so no error code is minted.
Declared file surface honoured: packages/rest/src/rest-server.ts (the audit
limb only), its new test, and .changeset/**.

Verification — all at 60691bfb2, the final commit

pnpm --filter '@objectstack/rest^...' build     closure built first
pnpm --filter @objectstack/rest test            Test Files 126 passed (126)
                                                     Tests 2062 passed (2062)
pnpm --filter @objectstack/rest typecheck       tsc --noEmit, exit 0

Gates re-derived from the actual changed paths with
node scripts/pm/dispatch-gates.mjs, which added several beyond the dispatched
check:route-envelope (the changeset family, the docs-drift family, and the
convention-triggered family a new test file pulls in). All pass:

check:authz-resolver                  OK
check:changeset-gate-self-tests       OK
check:cross-package-test-inputs       OK
check:dispatcher-error-vocabulary     OK
check:objectui-changeset              OK
check:route-envelope                  OK   (rest-server.ts dialect ratchets unmoved: 44 / 69)
check:query-options-erasure           OK   (ratchet holds: 67 unswept non-test sites, none new)
check:type-check-coverage             OK   (64/77 packages, 13 DEBT, 1 exempt)
check:engine-double-contract          OK
check:where-matcher                   OK   (255 matchers, 152 refuse)
check-adr-0087-registration.mjs       OK
check-changeset-no-major.mjs          OK
check-cross-package-test-inputs.mjs   OK   (12 packages declared)
check-empty-changeset.mjs             OK   (1 declaring changeset added)
check-affected-docs.mjs               OK   (242 self-test cases)
check-nul-bytes.mjs                   OK   (6165 text files, no raw control bytes)

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 361.8s,
  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.

Consumer sweep

The 17 downstream packages were enumerated with the prefix filter
pnpm --filter '...@objectstack/rest' (downstream consumers — the suffix form is
upstream dependencies and would have been the wrong direction), and their suites
run. The only consumer that names this route is
packages/client/src/client.test.ts (meta.getAudit pins GET /meta/:type/:name/audit),
which asserts the request URL against a mocked fetch and never reads the
server's answer — a scan whose control query (getMetaItem, 146 hits in the same
corpus) proves the search was working.

All 17 ran green — no consumer fixture depended on the 200 this PR replaces:

packages/rest                 126 files / 2062 tests    packages/runtime               169 / 2515
packages/cli                  132 / 1418                packages/drivers/driver-turso   39 / 1003
packages/plugins/plugin-auth   55 / 1261                packages/qa/dogfood            112 / 830 (1+3 skipped)
examples/app-showcase          21 / 334                 packages/client                 23 / 310
packages/cloud-connection      23 / 191                 examples/app-todo                4 / 106
packages/adapters/hono          2 / 73                  packages/qa/http-conformance     4 / 72
packages/services/service-sms   5 / 70                  packages/plugins/plugin-dev      5 / 54
examples/app-crm                4 / 42                  packages/client-react            3 / 34
packages/verify                 7 / 32

⚠️ One methodology note worth recording, because it nearly produced a false green:
the first sweep filtered on @objectstack/hono-adapter, which matched nothing
and exited 0 — indistinguishable from a pass. The package is named
@objectstack/hono. Every package name above was re-derived from its own
package.json, and every line here is an echoed suite result, never a bare exit
code.


Generated by Claude Code

…this item has no audit trail" (#9426)

`GET /meta/:type/:name/audit` answered a MISSING `auditMetaItem` capability
with `200 { events: [] }` — a capability gap rendered as a well-formed empty
answer on a compliance surface, where it reads as "nobody touched this item".

ADR-0110 D3: a miss and a fault are different facts. The branch now refuses with
`501 { error: { code: 'NOT_IMPLEMENTED', message: ... } }`, the ADR-0112 nested
envelope the sibling `/meta` 501 refusals converged on (#7035) — deliberately
not the sibling-`code` dialect `/layers` uses, which `check:route-envelope`
counts shrink-only on this file.

The unprovisioned-table answer is untouched: that condition is handled one layer
down, in `ObjectStackProtocolImplementation.auditMetaItem`, whose `catch`
returns `{ events: [] }` after a `console.warn` — a path that requires the
method to exist and to be called. This branch returns before the call.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WKSnqAaoqtW3QX7SSf1Vk
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Aug 18, 2026
@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/rest/src/rest-server.ts) — pages documenting those are invisible to this run

Coarse fallback — 13 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 1cf043c486b99fbd21c7c9419ba7664ffa25f86bpackageMentionDocs.

Copy link
Copy Markdown
Contributor Author

Lint & Repo Gates red — diagnosed, and it is not the production change

Stood auto-merge back down (my arming was a decision about 60691bfb2; it should not carry to whatever lands next). One of the six required checks failed, run 32146063662:

✗ lowercase error-code literal(s) in a code position (ADR-0112 D1):
  packages/rest/src/meta-audit-capability-gap.test.ts:162  'metadata_locked'  (emission)

rest-server.ts is untouched by this. It is the new test's A_DENIED_SAVE fixture — a sys_metadata_audit row, not an error body. Two separate defects sit on that one line.

1. The value does not exist

git grep metadata_locked origin/mainzero hits repo-wide (control: lockOverridden, hits packages/metadata-protocol/src/protocol.ts — the search works). The column's documented denied vocabulary, at packages/metadata-core/src/objects/sys-metadata-audit.object.ts:134-139, is closed:

'not_overridable' | 'not_creatable' | 'item_locked' | 'invalid_metadata' | 'destructive_change' | 'metadata_conflict' | 'batch_aborted' | 'namespace_prefix' | 'stored_type_not_canonical'

metadata_locked is not a member. For a save denied at lockState: 'locked' the value is item_locked. The fixture was pinning a row the system cannot produce.

2. The gate hit is a D6b false positive, and the repo already has the remedy

This column is deliberately outside the ADR-0112 vocabulary — the field's own doc comment says so:

"Deliberately lowercase, and deliberately NOT the error.code vocabulary (ADR-0112) … This column is persisted audit history … the protocol's thrown code is SCREAMING and the two intentionally diverge."

That is exactly the D6b category the gate's own remedy text names. The sanctioned mechanism is the per-line marker, honoured at scripts/check-error-code-casing.mjs:165 on the same line or the line above, and it must carry a reason — the script's own self-test (:188) proves a bare adr0112-ok: still counts as a violation. protocol.ts:3414-3417 already uses it verbatim on its two lowercase literals.

Not EXEMPT_FILES — that is whole-file and would blind the gate to a real error.code regression in this test later. ⛔ Not a gate edit. The marker is the gate working as designed, on one line.

Why this got through the dev's local run

The dev re-derived its gates from the changed paths with dispatch-gates.mjs and ran 16 families green. check:error-code-casing was not among them — the standing lesson holds: dispatch-gates.mjs names which jobs are in play; it is never the union itself. The convention that a new *.test.ts under packages/rest/src pulls this family in is not path-derivable today.

Fix dispatched: two tokens in the fixture, no production change, no gate change.


Generated by Claude Code

… code

`check:error-code-casing` (ADR-0112 D1) flagged `code: 'metadata_locked'` in
the `A_DENIED_SAVE` audit-row fixture at meta-audit-capability-gap.test.ts:162
as a lowercase literal in a code position. It wasn't just a casing miss: the
value isn't a member of the closed `denied` vocabulary documented on
`sys_metadata_audit.code` (sys-metadata-audit.object.ts:134-139) — the system
cannot produce `metadata_locked`. Given the fixture's own
`lockState: 'locked'`, `lockOverridden: false`, the correct member is
`item_locked`.

The column is ADR-0112 D6b — a persisted audit column with its own
intentionally-lowercase vocabulary — so the fix is the per-line `adr0112-ok`
marker (with reason, matching the existing D6b markers in
metadata-protocol/src/protocol.ts), not a whole-file EXEMPT_FILES entry.

The renamed value has one other reference in this file (line 223), via the
same `A_DENIED_SAVE` object identity rather than a separately hardcoded
string, so no other assertion needed updating.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WKSnqAaoqtW3QX7SSf1Vk
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

Development

Successfully merging this pull request may close these issues.

The REST audit route answers a MISSING auditMetaItem capability with {events: []} — a compliance surface reporting "this item has no audit trail"

2 participants