Skip to content

Commit 52fbba6

Browse files
os-elonclaude
andauthored
fix(metadata-protocol): auditMetaItem propagates a failed audit read instead of reporting an empty trail (#9638) (#9786)
Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 5457500 commit 52fbba6

3 files changed

Lines changed: 312 additions & 2 deletions

File tree

.changeset/tidy-pandas-repeat.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
`auditMetaItem` no longer reports a failed audit read as an empty audit trail
6+
7+
The `catch` closing the audit read in `ObjectStackProtocolImplementation.auditMetaItem`
8+
was unqualified. Its comment named two benign causes — the `sys_metadata_audit` table not
9+
being provisioned (legacy environments) and a host engine that exposes no `find` — but the
10+
clause took every other cause with them: a connection drop, a permission denial, a
11+
timeout, a malformed row, a query bug. Each was reported to the caller as the well-formed
12+
statement `{ events: [] }`, i.e. "this item has no audit entries".
13+
14+
This is the compliance surface behind `GET /api/v1/meta/:type/:name/audit`, which exists
15+
so Studio's audit-log tab can show who tried what and whether a lock blocked it, so an
16+
empty answer reads as *nobody touched this item*. Because the swallowed failures are
17+
transient, the same item could report a full trail one minute and a clean one the next.
18+
19+
Both benign causes still answer `{ events: [] }` exactly as documented. Every other read
20+
failure now raises `SERVICE_UNAVAILABLE` / 503 carrying the driver error as `cause`, which
21+
the route's existing error handler turns into an honest 5xx — the same treatment the
22+
sibling `listCommits` and `getMetaItem` reads in this package already give (ADR-0110 D3: a
23+
miss and a fault are different facts).
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #9638 — `auditMetaItem`'s unqualified `catch` reported ANY failed audit read
4+
// as `{ events: [] }`.
5+
//
6+
// The catch named two benign causes in its comment ("table not provisioned
7+
// (legacy env) or driver doesn't expose `find`") and then took every OTHER
8+
// cause with them: a connection drop, a permission denial, a malformed row, a
9+
// query bug, a timeout. All of them reached the caller as the well-formed
10+
// statement "this item has no audit entries".
11+
//
12+
// ADR-0110 D3 — a miss and a fault are different facts. This is the compliance
13+
// surface: `auditMetaItem` is the read behind
14+
// `GET /api/v1/meta/:type/:name/audit`, which exists so Studio's 审计日志 tab
15+
// can show who tried what and whether a lock blocked it. An empty answer there
16+
// reads as *nobody touched this item*.
17+
//
18+
// This file pins BOTH directions, because a method that raised unconditionally
19+
// would satisfy the first half and destroy the documented feature:
20+
//
21+
// • a NON-benign failure now propagates as 503 / SERVICE_UNAVAILABLE, which
22+
// the `/audit` route's existing `handleRouteError` turns into an honest
23+
// 5xx (it reads `error.status`, `packages/rest/src/error-response.ts`);
24+
// • BOTH benign causes still answer `{ events: [] }`, verbatim.
25+
//
26+
// ⚠️ Anti-vacuity. An "it propagates" assertion is worthless if the assertions
27+
// cannot see the difference between a populated trail and an empty one in the
28+
// first place — this repo has been bitten by exactly that shape
29+
// (`body.item.fields` vs `body.data.item.fields`). The POSITIVE CONTROL below
30+
// reads a real row all the way through the mapping and asserts its fields, so
31+
// every "empty" assertion in this file is known to be a measurement rather
32+
// than a shape that could never have been non-empty.
33+
34+
import { describe, it, expect, vi } from 'vitest';
35+
import { ObjectStackProtocolImplementation } from './protocol.js';
36+
37+
/** A protocol whose engine read fails with `error`. */
38+
function protocolWhoseReadFails(error: unknown) {
39+
const find = vi.fn(async () => { throw error; });
40+
const engine = { registry: { getObject: () => undefined }, find };
41+
return { p: new ObjectStackProtocolImplementation(engine as any), find };
42+
}
43+
44+
/** A protocol whose engine read succeeds, returning `rows`. */
45+
function protocolReading(rows: any[]) {
46+
const find = vi.fn(async () => rows);
47+
const engine = { registry: { getObject: () => undefined }, find };
48+
return { p: new ObjectStackProtocolImplementation(engine as any), find };
49+
}
50+
51+
const ITEM = { type: 'views', name: 'shared_grid' } as const;
52+
53+
/**
54+
* Capture the rejection, or fail loudly naming what was RESOLVED instead.
55+
*
56+
* Deliberately not a bare `.rejects.toThrow()`: that cannot separate "answered
57+
* with the wrong body" from "did not raise at all", and the wrong body — a
58+
* well-formed empty trail — *is* the defect. It also would not print the
59+
* `{ events: [] }` that makes a failure here self-explanatory.
60+
*/
61+
async function rejectionOf(promise: Promise<unknown>): Promise<any> {
62+
let resolved: unknown;
63+
try {
64+
resolved = await promise;
65+
} catch (error) {
66+
return error;
67+
}
68+
throw new Error(
69+
`expected the read failure to propagate, but it RESOLVED with `
70+
+ `${JSON.stringify(resolved)} — the defect: a fault disguised as an empty audit trail`,
71+
);
72+
}
73+
74+
describe('#9638 auditMetaItem: a failed audit read is a fault, not an empty trail', () => {
75+
// ── The propagating half ────────────────────────────────────────────────
76+
//
77+
// Three flavours, because the old catch was unqualified and each of these
78+
// means "the rows may well exist and simply were not seen".
79+
const nonBenign: Array<[string, Error]> = [
80+
['a connection drop', new Error('connect ECONNREFUSED 127.0.0.1:5432')],
81+
['a permission denial', new Error('permission denied for table sys_metadata_audit')],
82+
['a timeout', new Error('query timeout after 30000ms')],
83+
];
84+
85+
it.each(nonBenign)(
86+
'⭐ THE PIN — %s propagates as 503 SERVICE_UNAVAILABLE instead of `{ events: [] }`',
87+
async (_label, driverError) => {
88+
const { p } = protocolWhoseReadFails(driverError);
89+
90+
const error = await rejectionOf(p.auditMetaItem({ ...ITEM }));
91+
92+
// ADR-0112: `code` AND `status` together. `status` alone would pass
93+
// for any 5xx and `code` alone carries no HTTP verdict, and it is
94+
// the PAIR the REST boundary reads.
95+
expect(error.code).toBe('SERVICE_UNAVAILABLE');
96+
expect(error.status).toBe(503);
97+
},
98+
);
99+
100+
it('the driver error rides as `cause`, so the operator still sees what actually broke', async () => {
101+
const driverError = new Error('connect ECONNREFUSED 127.0.0.1:5432');
102+
const { p } = protocolWhoseReadFails(driverError);
103+
104+
const error = await rejectionOf(p.auditMetaItem({ ...ITEM }));
105+
106+
// Not the driver error itself: unwrapped it has no `status`, so the
107+
// REST boundary would have to guess from message text — and
108+
// `mapDataError` guesses `no such table` back into a 404 miss.
109+
expect(error.cause).toBe(driverError);
110+
});
111+
112+
it('503 is a status `handleRouteError` turns into a 5xx — not a 2xx and not a client error', async () => {
113+
const { p } = protocolWhoseReadFails(new Error('query timeout after 30000ms'));
114+
115+
const error = await rejectionOf(p.auditMetaItem({ ...ITEM }));
116+
117+
// The route's catch passes this straight to `handleRouteError`, which
118+
// reads `error.status` in the 400-599 band. Pinning the band is what
119+
// makes "an honest 5xx" a checkable claim at this layer.
120+
expect(error.status).toBeGreaterThanOrEqual(500);
121+
expect(error.status).toBeLessThan(600);
122+
});
123+
124+
// ── The benign half — both causes the old comment named ─────────────────
125+
126+
it.each([
127+
['sqlite', 'no such table: sys_metadata_audit'],
128+
['sqlite, driver-prefixed', 'SQLITE_ERROR: no such table: sys_metadata_audit'],
129+
['postgres', 'relation "sys_metadata_audit" does not exist'],
130+
['mysql', "Table 'db.sys_metadata_audit' doesn't exist"],
131+
])(
132+
'BENIGN 1/2 — an unprovisioned table (%s) still answers `{ events: [] }`',
133+
async (_dialect, message) => {
134+
const { p } = protocolWhoseReadFails(new Error(message));
135+
136+
// The documented promise, kept exactly as documented: a legacy
137+
// install prior to ADR-0010 has genuinely no rows, so the empty
138+
// answer IS the truth and a first boot must not explode.
139+
await expect(p.auditMetaItem({ ...ITEM })).resolves.toEqual({ events: [] });
140+
},
141+
);
142+
143+
it('BENIGN 2/2 — a host engine exposing no `find` still answers `{ events: [] }`', async () => {
144+
// `MetadataHostEngine` carries `[key: string]: any`, so a metadata-only
145+
// store or a partial double with no `find` satisfies the type.
146+
const engine = { registry: { getObject: () => undefined } };
147+
const p = new ObjectStackProtocolImplementation(engine as any);
148+
149+
await expect(p.auditMetaItem({ ...ITEM })).resolves.toEqual({ events: [] });
150+
});
151+
152+
it('the missing-`find` answer is decided BEFORE the read, not by classifying a TypeError', async () => {
153+
// Why this matters: a missing method raises `TypeError: … is not a
154+
// function`, and the ONLY thing separating that from a genuine
155+
// TypeError raised INSIDE a real driver's `find` (a null deref on a
156+
// malformed row — an actual fault) is the V8 message text. Classifying
157+
// it in the catch would re-open the fail-open this card closes. So the
158+
// capability is asked as a precondition, and a driver that DOES have
159+
// `find` and throws a TypeError is a fault.
160+
const { p } = protocolWhoseReadFails(
161+
new TypeError("Cannot read properties of undefined (reading 'occurred_at')"),
162+
);
163+
164+
const error = await rejectionOf(p.auditMetaItem({ ...ITEM }));
165+
166+
expect(error.code).toBe('SERVICE_UNAVAILABLE');
167+
expect(error.status).toBe(503);
168+
});
169+
170+
// ── Anti-vacuity ────────────────────────────────────────────────────────
171+
172+
it('POSITIVE CONTROL — a real row maps through, so "empty" above is a measurement', async () => {
173+
const { p, find } = protocolReading([{
174+
id: 'evt_1',
175+
occurred_at: '2026-08-18T10:00:00.000Z',
176+
actor: 'alice',
177+
source: 'studio',
178+
operation: 'save',
179+
outcome: 'denied',
180+
code: 'METADATA_LOCKED',
181+
lock_state: 'locked',
182+
lock_overridden: false,
183+
request_id: 'req_7',
184+
note: 'blocked by package lock',
185+
}]);
186+
187+
const result = await p.auditMetaItem({ ...ITEM });
188+
189+
// If this file's assertions could not tell a populated trail from an
190+
// empty one, THIS is the case that would fail — which is exactly why
191+
// it is here rather than assumed.
192+
expect(result.events).toHaveLength(1);
193+
expect(result.events[0]).toMatchObject({
194+
id: 'evt_1',
195+
actor: 'alice',
196+
operation: 'save',
197+
outcome: 'denied',
198+
code: 'METADATA_LOCKED',
199+
lockState: 'locked',
200+
lockOverridden: false,
201+
requestId: 'req_7',
202+
note: 'blocked by package lock',
203+
});
204+
expect(find).toHaveBeenCalledTimes(1);
205+
});
206+
207+
it('⭐ a genuine zero-row read and a FAULT are no longer the same answer', async () => {
208+
// The equivalence the defect created, stated as one assertion. A read
209+
// that succeeded and found nothing is the empty trail; a read that
210+
// failed is not an answer at all.
211+
const { p: readEmpty } = protocolReading([]);
212+
await expect(readEmpty.auditMetaItem({ ...ITEM })).resolves.toEqual({ events: [] });
213+
214+
const { p: readBroke } = protocolWhoseReadFails(
215+
new Error('connect ECONNREFUSED 127.0.0.1:5432'),
216+
);
217+
const error = await rejectionOf(readBroke.auditMetaItem({ ...ITEM }));
218+
expect(error.status).toBe(503);
219+
});
220+
});

packages/metadata-protocol/src/protocol.ts

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6342,6 +6342,21 @@ export class ObjectStackProtocolImplementation implements
63426342
* prior to ADR-0010) the call returns `{ events: [] }` instead of
63436343
* raising, keeping the Studio tab harmless.
63446344
*
6345+
* [#9638] `{ events: [] }` means "the audit trail was read and this item
6346+
* has no entries" and NOTHING else. Exactly two causes answer it without a
6347+
* read: the unprovisioned table above, and a host engine that exposes no
6348+
* `find` (the `typeof` probe before the read). Every other failure — a
6349+
* connection drop, a permission denial, a timeout, a malformed row, a query
6350+
* bug — RAISES, because the rows may well exist and simply were not seen,
6351+
* and a compliance reader must never be handed "nobody touched this item"
6352+
* on those terms (ADR-0110 D3). The unqualified `catch` that used to report
6353+
* all of them as an empty trail is what this closes.
6354+
*
6355+
* @throws {@link metadataStoreUnavailableError} — a 503 carrying the driver
6356+
* error as `cause`, for every read failure that is not an
6357+
* unprovisioned table. The `/audit` route's existing
6358+
* `handleRouteError` turns it into an honest 5xx.
6359+
*
63456360
* `organizationId` SCOPES the read and is enforced in the query below:
63466361
* rows for that organization plus env-wide (`organization_id IS NULL`)
63476362
* rows, and nothing else. Omitted (or `null`) reads the env-wide rows
@@ -6445,6 +6460,35 @@ export class ObjectStackProtocolImplementation implements
64456460
// organization reads exactly the env-wide rows an org-less write
64466461
// produces. Fail-closed, and symmetric with the write path.
64476462
const organizationId = request.organizationId ?? null;
6463+
// [#9638] The FIRST of the two benign causes the catch below used to
6464+
// name, asked as a PRECONDITION rather than as an error shape.
6465+
//
6466+
// `MetadataHostEngine` carries `[key: string]: any`, so a metadata-only
6467+
// store or a partial test double with no `find` satisfies the type and
6468+
// reaches here. That is a real, documented deployment shape and it must
6469+
// keep answering `{ events: [] }`.
6470+
//
6471+
// ⚠️ Asked HERE, before the `try`, because it cannot be asked soundly
6472+
// INSIDE the catch. Measured: a missing method raises
6473+
// `TypeError: this.engine.find is not a function`, which
6474+
// `isMissingTableError` correctly reports as NOT benign — but the only
6475+
// signal separating it from a genuine `TypeError` raised *inside* a
6476+
// real driver's `find` (a malformed row, a null deref — actual faults)
6477+
// is the V8 message text. Sniffing that text would re-open exactly the
6478+
// fail-open this card closes, one error class narrower. A `typeof`
6479+
// probe is a fact about the engine, not a guess about an error, so it
6480+
// cannot misclassify a fault as a capability gap.
6481+
//
6482+
// Same shape as the sibling limb one layer up: the `/audit` route's own
6483+
// capability probe (`typeof p.auditMetaItem !== 'function'`, #9426)
6484+
// likewise decides BEFORE the call rather than classifying its failure.
6485+
if (typeof (this.engine as { find?: unknown }).find !== 'function') {
6486+
console.warn(
6487+
`[Protocol] auditMetaItem: host engine exposes no \`find\`; `
6488+
+ `reporting no audit entries for ${request.type}/${request.name}`,
6489+
);
6490+
return { events: [] };
6491+
}
64486492
try {
64496493
// Org-scoped lookup: include rows for the specific org AND
64506494
// env-wide (organization_id IS NULL) rows so the editor
@@ -6504,8 +6548,31 @@ export class ObjectStackProtocolImplementation implements
65046548
}));
65056549
return { events };
65066550
} catch (err: any) {
6507-
// Table not provisioned (legacy env) or driver doesn't
6508-
// expose `find` — return empty rather than 500ing the tab.
6551+
// [#9638] Benign (the table has not been provisioned in this legacy
6552+
// env) falls through to the empty answer; everything else is a read
6553+
// that DID NOT HAPPEN and leaves as a 503. Byte-for-byte the shape
6554+
// the sibling {@link listCommits} carries (#5980), and the same
6555+
// {@link isMissingTableError} predicate `DatabaseLoader` (#5108) and
6556+
// `SysMetadataRepository` (#4867) ask — a driver quirk is taught to
6557+
// the platform once rather than re-spelled per seam.
6558+
//
6559+
// This `catch` used to be UNQUALIFIED. A connection drop, a
6560+
// permission denial, a malformed row, a query bug or a timeout was
6561+
// reported to the caller as the well-formed statement "this item has
6562+
// no audit entries" — ADR-0110 D3 broken (a miss and a fault are
6563+
// different facts) on the COMPLIANCE surface. `auditMetaItem` is the
6564+
// read behind `GET /api/v1/meta/:type/:name/audit`, which exists so
6565+
// Studio's 审计日志 tab can show who tried what and whether a lock
6566+
// blocked it; an empty answer there reads as *nobody touched this
6567+
// item*. Worse than the static capability gap #9426 fixed one layer
6568+
// up, because a transient read failure makes the same item report a
6569+
// full trail one minute and a clean one the next. The `console.warn`
6570+
// below is on the SERVER; it was never an answer to the reader.
6571+
//
6572+
// The second cause the old comment named — a host engine with no
6573+
// `find` — is decided by the precondition probe above the `try`, so
6574+
// it never reaches here and this arm has exactly ONE benign cause.
6575+
this.rethrowUnlessMetadataStoreUnprovisioned(err);
65096576
console.warn(
65106577
`[Protocol] auditMetaItem read failed for ${request.type}/${request.name}: ${err?.message ?? err}`,
65116578
);

0 commit comments

Comments
 (0)