fix(rest): refuse when auditMetaItem is absent instead of answering "this item has no audit trail" - #9637
Conversation
…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
📓 Docs Drift CheckNothing 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
Coarse fallback — 13 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
|
… 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
Fixes #9426.
The defect
GET /api/v1/meta/:type/:name/auditfeature-detectsauditMetaItemon theresolved protocol. When the method was absent, the route answered:
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.tsanswering a capability gap with awell-formed empty collection. Five siblings already refused; #9326 / PR #9425
landed the
findReferencesToMetatwin, and this PR mirrors it.The change
The ADR-0112 nested envelope #7035 converged the sibling
/meta501 refusalsonto, so
body.error.codeis readable by the same one line of consumer code thatalready reads the others. Message wording follows the converted siblings
(
protocol.NAME() is not available in this kernel).Deliberately not the shape
/layersuses: that is the sibling-codedialect,counted by both shrink-only dialect ratchets
check:route-envelopeholds onthis 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, insideObjectStackProtocolImplementation.auditMetaItem(thecatchclosing its read):That path requires the method to exist and to be called. The route's
branch returns before the call, on a
typeofprobe 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/restsuitealready exercises the downstream path, logging
no such table: sys_metadata_auditfromSqlDriver.createwhile every audit case stays green.The client-side JSDoc that documents the same promise
(
packages/client/src/index.ts,getAudit: "{ events: [] }where the audittable is not provisioned") therefore stays accurate and is untouched.
2. Ratchet arithmetic — measured by running the gate, both before and after
stringErrorsiblingCodeNeither 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).The pin
packages/rest/src/meta-audit-capability-gap.test.tsasserts the distinction inboth directions, because a route that refused unconditionally would satisfy
the first half and destroy the feature:
auditMetaItemgets the refusal, pinned byerror.codeand HTTP status together (the ADR-0112 pair, at the nestedposition) — never a bare
toThrow, which could not separate "answered with thewrong body" from "did not refuse at all", and the wrong body is the defect;
(
{ events: [] }, which is also exactly what an unprovisioned table producesdownstream) 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:
expected 200 not to be 200is the defect stating itself. The fix was committedbefore the ablation and restored with
git checkoutfrom the branch, thenproved 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
200with an empty list, now501.No in-repo assembly produces such a protocol today:
ObjectStackProtocolImplementationis the only implementation registered underthe
protocolservice and it defines the method unconditionally. The branch isreachable rather than dead because
auditMetaItemis not a member ofRestProtocol(= DataProtocol & MetadataProtocol) and is not declared inpackages/specat all — an ADR-0076 D9 server-only extension reached through aruntime 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/speccontract decision, not a route one — and would reject the partialprotocol doubles that legitimately exist today.
Fences honoured
A wire-level
coveragediscriminator stays fenced topackages/specand isnot introduced. No
docs/adr/**edit. No ratchet raised.NOT_IMPLEMENTEDisalready a registered code with canonical status 501, so no error code is minted.
Declared file surface honoured:
packages/rest/src/rest-server.ts(the auditlimb only), its new test, and
.changeset/**.Verification — all at
60691bfb2, the final commitGates re-derived from the actual changed paths with
node scripts/pm/dispatch-gates.mjs, which added several beyond the dispatchedcheck:route-envelope(the changeset family, the docs-drift family, and theconvention-triggered family a new test file pulls in). All pass:
Ratchet family at the final head, after the full closure build
(
pnpm exec turbo run build --filter=./packages/* --filter=./packages/*/*, 70/70 successful):Consumer sweep
The 17 downstream packages were enumerated with the prefix filter
pnpm --filter '...@objectstack/rest'(downstream consumers — the suffix form isupstream 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
fetchand never reads theserver's answer — a scan whose control query (
getMetaItem, 146 hits in the samecorpus) proves the search was working.
All 17 ran green — no consumer fixture depended on the
200this PR replaces:the first sweep filtered on
@objectstack/hono-adapter, which matched nothingand exited 0 — indistinguishable from a pass. The package is named
@objectstack/hono. Every package name above was re-derived from its ownpackage.json, and every line here is an echoed suite result, never a bare exitcode.
Generated by Claude Code