From 6bacd8c45affb37e7c5b013396b4179ca003f5d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 14:42:55 +0000 Subject: [PATCH 1/6] wip(rest): #18402 converge the meta item route's thrown absence onto its one emitter Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude --- packages/rest/src/error-response.ts | 39 ++++++++++ packages/rest/src/rest-server.ts | 60 +++++++++++++++ packages/rest/src/zz-measure-18402.test.ts | 88 ++++++++++++++++++++++ 3 files changed, 187 insertions(+) create mode 100644 packages/rest/src/zz-measure-18402.test.ts diff --git a/packages/rest/src/error-response.ts b/packages/rest/src/error-response.ts index 91e3cf9fd54..4c7079ec32f 100644 --- a/packages/rest/src/error-response.ts +++ b/packages/rest/src/error-response.ts @@ -2630,6 +2630,45 @@ export function logUnexpectedRouteError(error: any, resolved: { status: number; logWithheldServerFault(error, resolved); } +/** + * [#18402] Would the classification door answer this caught value `404`? + * + * ## Why a predicate rather than a second reading of the error + * + * A handler that owns ONE absence answer has to recognise the absences its + * producers THROW, and the tempting spelling — `error?.status === 404 && + * error?.code === 'RESOURCE_NOT_FOUND'` — is a second opinion about what a + * caught value means. It disagrees with this door on every shape the door + * classifies rather than reads: a producer that declares a status and no code, + * an unregistered spelling that {@link thrownCodeFields} demotes to + * `declaredCode` while deriving `code` from the status, a structured arm that + * owns its own envelope. Each disagreement is one arm of one route quietly + * answering a different body again — the exact class the caller was fixing. + * + * So this ASKS the door. `resolveErrorResponse` is the function that would + * have rendered the value one line later; reading its verdict means the + * handler's fork and the fallback it forks away from can never drift apart. + * The function is pure and this runs on an error path, so the second + * classification pass costs nothing worth naming — the same argument + * {@link resolveErrorResponse} already makes for its own `mapDataError` + * re-entry. + * + * ⛔ Deliberately the STATUS alone, not `status` plus a `code` test. The + * `code` a 404 carries is derived by this door from the status whenever the + * producer named none, so testing both narrows nothing while adding a second + * place for the vocabulary to be restated. A caller that needs "absent, and + * the producer named it so" is asking a different question and should not use + * this. + * + * ⚠️ This predicate does NOT decide what a route answers — it only recognises + * an answer. The 503 an unreadable metadata store throws (#5532) resolves to + * 503 and is false here, which is the distinction that must never be + * flattened. + */ +export function thrownAnswerIsNotFound(error: any, object?: string): boolean { + return resolveErrorResponse(error, object).status === 404; +} + /** * The single door a route catch block should use: resolve the response once, * log it only if it is a real fault, then send it. Wire behaviour is identical diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 6e3739057cb..ce11d5dc4e8 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -335,6 +335,7 @@ import { sendDeclaredFault, sendFieldVisibilityFault, handleRouteError, + thrownAnswerIsNotFound, logUnexpectedRouteError, isExpectedRouteError, applyDroppedFieldsHeader, @@ -7189,6 +7190,65 @@ export class RestServer { )); } } catch (error: any) { + // [#18402] THE one absence answer, whichever arm + // produced it — the last half of #18066. + // + // #18066 gave this route a single absence EMITTER + // ({@link sendMetaItemAbsent}) and reached it from the + // two conditions that RETURN nothing. The conditions + // that THROW one were left on the classification door + // below, which renders the flat `{ error: '', + // code }` — so `body.error.code`, the accessor #8013 + // settled on and objectui#4252 reads, was `undefined` + // on exactly those. Which one a caller got was decided + // by two things it cannot see: + // + // - `metadata.enableCache` (default TRUE). The cached + // arm's `getMetaItemCached` THROWS + // `metadataItemNotFoundError` on a falsy `item`; the + // uncached arm resolves item-less and returns. One + // request, one missing name, two envelopes, chosen + // by a server setting — the #7035 failure class. + // - which protocol implementation is mounted. The + // in-repo `metadata-protocol` resolves item-less + // from `getMetaItem`, but a protocol that throws the + // miss instead reached the same flat door + // (pinned in `rest-meta-outage-vs-miss.test.ts`). + // + // Recognised by the STATUS this repo's own + // classification door would have answered — see + // {@link thrownAnswerIsNotFound}, which asks that door + // rather than re-reading the error, so this fork and + // the `handleRouteError` it forks away from cannot + // drift. Every 404 out of this handler is absence: + // ordering and arity refusals are 400, the audience + // gate 401/403, the app gate 403, field visibility 503, + // an unreadable store 503 (#5532 — false here, and that + // distinction is the one this must never flatten). + // + // ⭐ It STRENGTHENS the ADR-0045 §3 property rather + // than merely preserving it. The unpublished app and + // the service-gated one answer through the emitter, so + // an absence that kept the thrown dialect was a + // response pair that told them apart — by envelope + // shape, and by the producer's `Metadata item + // / not found` prose where the emitter says + // one fixed sentence. Four arms, one body now; the + // byte-identity is pinned in + // `meta-item-absent-404.test.ts` §2 and §5. + // + // ⛔ NOT a convergence of the flat dialect itself. The + // audience gate's `sendDeclaredFault` 401/403 beside + // this, and the door in `error-response.ts`, still + // answer flat: that position is the live ratchet + // #9559 owns repo-wide (`check:route-envelope`), and + // converting two of its four emissions here would mint + // a new divergence — the same refusal answering two + // shapes depending on which ROUTE served it. + if (thrownAnswerIsNotFound(error)) { + sendMetaItemAbsent(res); + return; + } handleRouteError(res, error); } }, diff --git a/packages/rest/src/zz-measure-18402.test.ts b/packages/rest/src/zz-measure-18402.test.ts new file mode 100644 index 00000000000..413035c8e9c --- /dev/null +++ b/packages/rest/src/zz-measure-18402.test.ts @@ -0,0 +1,88 @@ +// TEMPORARY MEASUREMENT — deleted before commit. #18402 dialect re-derivation. +import { describe, it, vi } from 'vitest'; +import { RestServer } from './rest-server'; + +const ANON = { api: { requireAuth: false } }; + +function mockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; +} +function makeRes() { + const res: any = { statusCode: 200, body: undefined, sent: false }; + res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); + res.json = vi.fn((b: any) => { res.body = b; res.sent = true; return res; }); + res.header = vi.fn(() => res); + res.setHeader = vi.fn(); res.write = vi.fn(); res.end = vi.fn(); + res.send = vi.fn(() => { res.sent = true; return res; }); + return res; +} +function absentItemEnvelope(type: string, name: string) { + return { type, name, item: undefined, lock: 'none', editable: true, deletable: true, resettable: false }; +} +function setup(corpus: Record = {}, opts: any = {}) { + const protocol: any = { + getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' } }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn().mockResolvedValue([]), + getMetaItem: opts.getMetaItem ?? vi.fn(async ({ type, name }: any) => { + const hit = corpus[`${type}/${name}`]; + return hit === undefined + ? absentItemEnvelope(type, name) + : { type, name, item: JSON.parse(JSON.stringify(hit)), lock: 'none', editable: true, deletable: true, resettable: false }; + }), + findData: vi.fn().mockResolvedValue([]), + ...(opts.cached !== undefined ? { getMetaItemCached: opts.cached } : {}), + }; + const rest = new RestServer(mockServer() as any, protocol as any, (opts.config ?? ANON) as any); + (rest as any).resolveExecCtx = async () => (opts.ctx === null ? undefined : { userId: 'u1', systemPermissions: opts.perms ?? [], ...(opts.ctx ?? {}) }); + if (opts.masker) (rest as any).resolveObjectMasker = opts.masker; + rest.registerRoutes(); + return { rest, protocol }; +} +async function getItem(rest: any, type: string, name: string, query: any = {}, headers: any = {}) { + const route = rest.getRoutes().find((r: any) => r.method === 'GET' && r.path === '/api/v1/meta/:type/:name'); + const res = makeRes(); + await route.handler({ method: 'GET', params: { type, name }, query, body: {}, headers }, res); + return res; +} +function dump(label: string, res: any) { + const bytes = res.body === undefined ? '' : JSON.stringify(res.body); + // eslint-disable-next-line no-console + console.log(`\n### ${label}\n status=${res.statusCode}\n bytes=${bytes}\n body.error.code=${JSON.stringify(res.body?.error?.code)} body.code=${JSON.stringify(res.body?.code)} top-keys=${JSON.stringify(Object.keys(res.body ?? {}))}`); +} + +const UNPUBLISHED_APP = { name: 'production_management', label: 'PM', _unpublished: true, navigation: [{ id: 'n1', type: 'object', objectName: 'secret_line' }] }; +const FINANCE_APP = { name: 'finance', label: 'Finance', requiredPermissions: ['finance.access'], navigation: [{ id: 'n2', type: 'object', objectName: 'invoice' }] }; +const GATED_BOOK = { name: 'admin_guide', label: 'Admin Guide', audience: { permissionSet: 'crm_admin' }, groups: [] }; +const GATED = { 'app/production_management': UNPUBLISHED_APP, 'app/finance': FINANCE_APP }; + +describe('#18402 MEASUREMENT on current origin/main', () => { + it('drives every refusal arm and dumps wire bytes', async () => { + dump('A1 absent name (uncached, app)', await getItem(setup(GATED, { perms: ['manage_users'] }).rest, 'app', 'no_such_app_xyz')); + dump('A2 UNPUBLISHED app (uncached)', await getItem(setup(GATED, { perms: ['manage_users'] }).rest, 'app', 'production_management')); + dump('A3 app permission denied', await getItem(setup(GATED, { perms: ['manage_users'] }).rest, 'app', 'finance')); + dump('A4 book audience denied (authed non-holder)', await getItem(setup({ 'book/admin_guide': GATED_BOOK }).rest, 'book', 'admin_guide')); + dump('A5 book audience anonymous', await getItem(setup({ 'book/admin_guide': GATED_BOOK }, { ctx: null }).rest, 'book', 'admin_guide')); + + const cachedMiss = Object.assign(new Error('Metadata item view/no_such_view not found'), { code: 'RESOURCE_NOT_FOUND', status: 404 }); + dump('A6 CACHED arm miss (getMetaItemCached throws)', await getItem(setup({}, { cached: vi.fn().mockRejectedValue(cachedMiss) }).rest, 'view', 'no_such_view')); + + dump('A7 UNCACHED arm, producer THROWS the miss', await getItem(setup({}, { getMetaItem: vi.fn().mockRejectedValue(Object.assign(new Error('Metadata item object/acct not found'), { code: 'RESOURCE_NOT_FOUND', status: 404 })), config: { api: { requireAuth: false }, metadata: { enableCache: false } } }).rest, 'object', 'acct')); + + dump('A8 uncached absence, non-app type (enableCache:false)', await getItem(setup({}, { config: { api: { requireAuth: false }, metadata: { enableCache: false } } }).rest, 'view', 'no_such_view')); + + dump('A9 store outage 503', await getItem(setup({}, { getMetaItem: vi.fn().mockRejectedValue(Object.assign(new Error('The metadata store could not be read.'), { code: 'SERVICE_UNAVAILABLE', status: 503 })), config: { api: { requireAuth: false }, metadata: { enableCache: false } } }).rest, 'object', 'acct')); + + // field-visibility 503 — masker throws ObjectSchemaMaskEvaluationError + const { ObjectSchemaMaskEvaluationError } = await import('@objectstack/types'); + dump('A10 field-visibility unresolved 503', await getItem(setup({}, { + masker: async () => { throw new ObjectSchemaMaskEvaluationError('nope'); }, + }).rest, 'object', 'acct')); + + // repeated query param refusal (ADR-0112 nested, per Route & surface ownership) + dump('A11 repeated query param', await getItem(setup({}).rest, 'object', 'acct', { state: ['draft', 'draft'] })); + }, 60_000); +}); From c72d5e4c33889bd965f7f030cb4c7c7dc28a0d29 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 14:46:51 +0000 Subject: [PATCH 2/6] fix(rest): GET /meta/:type/:name answers absence through one emitter on every arm Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude --- packages/rest/src/error-response.ts | 38 +++- .../rest/src/meta-item-absent-404.test.ts | 205 +++++++++++++++++- .../rest/src/rest-meta-outage-vs-miss.test.ts | 27 ++- packages/rest/src/rest-server.ts | 32 ++- 4 files changed, 266 insertions(+), 36 deletions(-) diff --git a/packages/rest/src/error-response.ts b/packages/rest/src/error-response.ts index 4c7079ec32f..01b3a1cca66 100644 --- a/packages/rest/src/error-response.ts +++ b/packages/rest/src/error-response.ts @@ -2631,7 +2631,9 @@ export function logUnexpectedRouteError(error: any, resolved: { status: number; } /** - * [#18402] Would the classification door answer this caught value `404`? + * [#18402] Would the classification door answer this caught value with a + * BARE `404 RESOURCE_NOT_FOUND` — no code the producer chose, nothing else + * riding along? * * ## Why a predicate rather than a second reading of the error * @@ -2653,20 +2655,38 @@ export function logUnexpectedRouteError(error: any, resolved: { status: number; * {@link resolveErrorResponse} already makes for its own `mapDataError` * re-entry. * - * ⛔ Deliberately the STATUS alone, not `status` plus a `code` test. The - * `code` a 404 carries is derived by this door from the status whenever the - * producer named none, so testing both narrows nothing while adding a second - * place for the vocabulary to be restated. A caller that needs "absent, and - * the producer named it so" is asking a different question and should not use - * this. + * ## ⛔ Why it is NOT "the status is 404" + * + * MEASURED, and the measurement is the reason this function has three + * conditions instead of one. `404` on a metadata route is not a synonym for + * "you get nothing": `metadata-protocol` throws `{ code: 'NO_DRAFT', status: + * 404 }` from the Studio designer's draft probe — pinned byte-for-byte in + * `rest-expected-error-logging.test.ts` and `rest-4xx-message-truncation.test.ts` + * — and that refusal says the ITEM is there and its DRAFT is not. Folding it + * into an absence would tell a designer the object does not exist while it + * plainly does: the #5532 flattening, one pair over, minted by the repair for + * a sibling of it. + * + * So the question is asked about the ANSWER, not the status: + * + * - `status` is 404, and + * - `code` is `RESOURCE_NOT_FOUND` — which this door derives from the status + * when the producer named none, and otherwise is the producer agreeing, and + * - no `declaredCode` sits beside it. Presence MEANS demotion (see + * `ApiErrorSchema`): the producer spelled a code the ledger does not know, + * and ADR-0112 keeps that spelling as the open, author-authored channel. + * Converting such an answer would delete the one field it exists to carry. * * ⚠️ This predicate does NOT decide what a route answers — it only recognises * an answer. The 503 an unreadable metadata store throws (#5532) resolves to * 503 and is false here, which is the distinction that must never be * flattened. */ -export function thrownAnswerIsNotFound(error: any, object?: string): boolean { - return resolveErrorResponse(error, object).status === 404; +export function thrownAnswerIsBareNotFound(error: any, object?: string): boolean { + const resolved = resolveErrorResponse(error, object); + return resolved.status === 404 + && resolved.body?.code === 'RESOURCE_NOT_FOUND' + && resolved.body?.declaredCode === undefined; } /** diff --git a/packages/rest/src/meta-item-absent-404.test.ts b/packages/rest/src/meta-item-absent-404.test.ts index 3df2d7a1fb8..9df77152e34 100644 --- a/packages/rest/src/meta-item-absent-404.test.ts +++ b/packages/rest/src/meta-item-absent-404.test.ts @@ -298,6 +298,17 @@ describe('[#18066] §2 — the #8013 partition survives, in both directions', () const unpublished = await getItem(setup(GATED, { perms: ['manage_users'] }).rest, 'app', 'production_management'); const absent = await getItem(setup(GATED, { perms: ['manage_users'] }).rest, 'app', 'no_such_app_xyz'); + // [#18402] BYTE-for-byte, on the SERIALIZED body — the wire is what + // ADR-0045 §3 is about, and `toEqual` below is a statement about two + // in-process objects. They can agree while the bytes do not: a member + // holding `undefined` is equal to an absent one here and disappears at + // `JSON.stringify` (§4 measures exactly that trap on the 200 this + // route used to send), and key ORDER is invisible to `toEqual` while + // being the first thing a response diff shows. Asserted first, so a + // future change that keeps the objects equal and moves the bytes fails + // on the line that names the property rather than on a weaker one. + expect(JSON.stringify(unpublished.body)).toBe(JSON.stringify(absent.body)); + expect(unpublished.statusCode).toBe(absent.statusCode); expect(unpublished.body).toEqual(absent.body); expect(unpublished.statusCode).toBe(404); @@ -333,19 +344,22 @@ describe('[#18066] §3 — the two arms of this route now answer absence alike', ); expect(viaUncached.statusCode).toBe(viaCache.statusCode); - // ⚠️ Status and CODE agree; the envelope DIALECT still differs and that - // is deliberately not touched here. The thrown arm is rendered by - // `resolveErrorResponse`'s declared-status passthrough, whose body is - // the flat `{ error: '', code }` pinned in - // `rest-meta-outage-vs-miss.test.ts`; the in-route arm emits ADR-0112's - // nested `{ error: { code, message } }`, which is what its own sibling - // refusals on this handler emit and what objectui#4252 reads. Matching - // the flat one HERE would have broken the §2 byte-identity, which is a - // security property; converging the two dialects is a separate change - // on the thrown side. Asserted rather than left implicit, so a future - // convergence is a deliberate edit to this line. - expect(viaCache.body?.code).toBe('RESOURCE_NOT_FOUND'); + // [#18402] THE LINE #18066 SAID A CONVERGENCE WOULD HAVE TO EDIT, and + // this is that edit. It used to read `viaCache.body?.code` against + // `viaUncached.body?.error?.code` — one status, one code, TWO + // envelopes, chosen by `metadata.enableCache`. Both arms now answer + // ADR-0112's nested `{ error: { code, message } }` through the single + // emitter, which is the accessor objectui#4252 reads and the shape the + // sibling refusals on this handler already emitted. + // + // ⚠️ The direction matters and is NOT symmetric: the flat arm was + // pulled back to the nested one. Matching the FLAT shape here is what + // #18066 fenced off — it would have moved the emitter, and with it the + // §2 byte-identity, which is a security property rather than a style + // preference. + expect(viaCache.body?.error?.code).toBe('RESOURCE_NOT_FOUND'); expect(viaUncached.body?.error?.code).toBe('RESOURCE_NOT_FOUND'); + expect(viaCache.body?.code).toBeUndefined(); }); it('an unreadable metadata STORE is still a 503, never this 404', async () => { @@ -408,3 +422,170 @@ describe('[#18066] §4 — against `packages/spec`, not against a restatement of } }); }); + +describe('[#18402] §5 — ONE absence body on this route, whichever arm produced it', () => { + /** + * Every way `GET /meta/:type/:name` can arrive at "you get nothing", + * driven side by side. The card that filed this measured THREE refusal + * dialects from this one handler and named the severe half: which dialect + * a caller must parse *for absence* was decided by `metadata.enableCache` + * — a server-side setting the caller cannot see (the #7035 class). + * + * @returns the serialized wire body, because that is the artefact ADR-0045 + * §3 is a statement about. An in-process `toEqual` is satisfied by two + * objects that `JSON.stringify` differently (§4 measures that exact trap + * on the 200 this route used to send), so the arms are compared as bytes. + */ + const wire = (res: any) => `${res.statusCode} ${JSON.stringify(res.body)}`; + + const UNPUBLISHED = { name: 'production_management', label: 'PM', _unpublished: true, navigation: [] }; + + async function thrownMiss(err: unknown, opts: { perms?: string[]; config?: any; cached?: any } = {}) { + const { rest, protocol } = setup({}, opts); + protocol.getMetaItem = vi.fn().mockRejectedValue(err); + return getItem(rest, 'view', 'no_such_view'); + } + + it('⭐ all four arms are byte-identical — the three-dialect table collapses to one', async () => { + // ① the uncached arm's item-less RETURN (the #18066 condition). + const returned = await getItem( + setup({}, { config: { api: { requireAuth: false }, metadata: { enableCache: false } } }).rest, + 'view', 'no_such_view', + ); + // ② the CACHED arm's throw — `getMetaItemCached` raises + // `metadataItemNotFoundError` on a falsy `item`. This arm is the + // DEFAULT (`enableCache` defaults to true). + const cached = await getItem( + setup({}, { + cached: vi.fn().mockRejectedValue(Object.assign( + new Error('Metadata item view/no_such_view not found'), + { code: 'RESOURCE_NOT_FOUND', status: 404 }, + )), + }).rest, + 'view', 'no_such_view', + ); + // ③ a protocol whose UNCACHED `getMetaItem` throws the miss instead of + // resolving item-less. Not hypothetical: it is the shape + // `rest-meta-outage-vs-miss.test.ts` drives. + const thrown = await thrownMiss( + Object.assign(new Error('Metadata item view/no_such_view not found'), { + code: 'RESOURCE_NOT_FOUND', status: 404, + }), + { config: { api: { requireAuth: false }, metadata: { enableCache: false } } }, + ); + // ④ a producer that declares the 404 and NO code at all. The door + // derives `RESOURCE_NOT_FOUND` from the status, so this is the same + // answer arriving by a different road. + const uncoded = await thrownMiss( + Object.assign(new Error('nothing there'), { status: 404 }), + { config: { api: { requireAuth: false }, metadata: { enableCache: false } } }, + ); + + const arms = { returned, cached, thrown, uncoded }; + for (const [name, res] of Object.entries(arms)) { + expect(`${name}: ${wire(res)}`).toBe(`${name}: ${wire(returned)}`); + } + expect(wire(returned)).toBe('404 {"error":{"code":"RESOURCE_NOT_FOUND","message":"Metadata item not found or access denied."}}'); + }); + + it('the producer`s own prose stops reaching the wire — one fixed sentence, no type/name echo', async () => { + // Not tidiness. The thrown arms shipped `Metadata item / + // not found`; the emitter says one sentence that names nothing. An + // unpublished app answers the emitter's sentence, so an absence that + // echoed the producer told the two apart in prose even once the + // envelope matched. + const res = await thrownMiss( + Object.assign(new Error('Metadata item view/secret_thing not found'), { + code: 'RESOURCE_NOT_FOUND', status: 404, + }), + { config: { api: { requireAuth: false }, metadata: { enableCache: false } } }, + ); + expect(JSON.stringify(res.body)).not.toContain('secret_thing'); + expect(res.body?.error?.message).toBe('Metadata item not found or access denied.'); + expect(res.body?.declaredCode).toBeUndefined(); + expect(res.body?.error?.declaredCode).toBeUndefined(); + }); + + it('…and the UNPUBLISHED app matches the thrown arm byte for byte, across the cache fork', async () => { + // §2 proved absent == unpublished while both took the returning arm. + // This is the same property across the fork that used to decide the + // dialect: the unpublished app (uncached by construction — `app` + // bypasses the cache) against an absence rendered by the throwing one. + const unpublished = await getItem(setup({ 'app/production_management': UNPUBLISHED }, { perms: ['manage_users'] }).rest, 'app', 'production_management'); + const thrown = await thrownMiss( + Object.assign(new Error('Metadata item app/production_management not found'), { + code: 'RESOURCE_NOT_FOUND', status: 404, + }), + { config: { api: { requireAuth: false }, metadata: { enableCache: false } } }, + ); + expect(wire(thrown)).toBe(wire(unpublished)); + }); + + it('⛔ NOT every 404 — a producer-NAMED 404 keeps its own refusal, envelope and all', async () => { + // The narrowing that the first draft of this change did not have, and + // the reason it is measured rather than reasoned. `404` on this route + // is not a synonym for absence. + // + // - `NO_DRAFT` is the Studio designer's `?state=draft` probe. It says + // the ITEM is there and its DRAFT is not. Folding it into + // `RESOURCE_NOT_FOUND` would tell a designer the object does not + // exist — #5532's flattening, reintroduced by the repair for a + // sibling of it. Its wire answer is pinned byte-for-byte in + // `rest-expected-error-logging.test.ts` and + // `rest-4xx-message-truncation.test.ts`; those pins must keep + // passing, and this states here WHY they are not collateral. + // - a code the ADR-0112 ledger does not know is DEMOTED to + // `declaredCode`, the open author-authored channel the ADR declares. + // Converting would delete the one field it exists to carry. + const noDraft = await thrownMiss( + Object.assign(new Error('[no_draft] No pending draft exists for view/no_such_view.'), { + code: 'NO_DRAFT', status: 404, + }), + { config: { api: { requireAuth: false }, metadata: { enableCache: false } } }, + ); + expect(noDraft.statusCode).toBe(404); + expect(noDraft.body).toEqual({ + error: '[no_draft] No pending draft exists for view/no_such_view.', + code: 'NO_DRAFT', + }); + + const bespoke = await thrownMiss( + Object.assign(new Error('gone'), { code: 'MY_OWN_MISS', status: 404 }), + { config: { api: { requireAuth: false }, metadata: { enableCache: false } } }, + ); + expect(bespoke.statusCode).toBe(404); + expect(bespoke.body?.declaredCode).toBe('MY_OWN_MISS'); + }); + + it('⛔ 503, 403 and 401 are untouched — the three other boundaries', async () => { + // The three boundaries this must not flatten, each one a refusal that + // means something else. + // + // - 503: #5532's outage. "We could not look" is not "it is not there", + // and a converted 503 would tell a caller the item does not exist + // during a metadata-plane outage. + // - 403 PERMISSION_DENIED on an app that EXISTS: #8013's partition. + // - 401/403 on an audience-gated book: ADR-0046 §6.7. Still the FLAT + // dialect, deliberately — that position belongs to ratchet #9559, + // and converting it here would leave `/meta/book/:name/tree` and + // this route answering the same refusal two ways. + const outage = await thrownMiss( + Object.assign(new Error('The metadata store could not be read.'), { + code: 'SERVICE_UNAVAILABLE', status: 503, + }), + { config: { api: { requireAuth: false }, metadata: { enableCache: false } } }, + ); + expect(outage.statusCode).toBe(503); + expect(JSON.stringify(outage.body ?? {})).not.toContain('RESOURCE_NOT_FOUND'); + + const FINANCE = { name: 'finance', label: 'Finance', requiredPermissions: ['finance.access'], navigation: [] }; + const denied = await getItem(setup({ 'app/finance': FINANCE }, { perms: ['manage_users'] }).rest, 'app', 'finance'); + expect(denied.statusCode).toBe(403); + expect(denied.body?.error?.code).toBe('PERMISSION_DENIED'); + + const GATED_BOOK = { name: 'admin_guide', label: 'Admin Guide', audience: { permissionSet: 'crm_admin' }, groups: [] }; + const gated = await getItem(setup({ 'book/admin_guide': GATED_BOOK }).rest, 'book', 'admin_guide'); + expect(gated.statusCode).toBe(403); + expect(gated.body?.code).toBe('PERMISSION_DENIED'); + }); +}); diff --git a/packages/rest/src/rest-meta-outage-vs-miss.test.ts b/packages/rest/src/rest-meta-outage-vs-miss.test.ts index fa5804756b6..542076dd006 100644 --- a/packages/rest/src/rest-meta-outage-vs-miss.test.ts +++ b/packages/rest/src/rest-meta-outage-vs-miss.test.ts @@ -152,14 +152,35 @@ describe('[#5532] an unreadable metadata store reaches the client as a retryable }); describe('[#5532 / fix C] a real miss reaches the client as a coded 404', () => { - it('404 + RESOURCE_NOT_FOUND, with the caller-facing message intact', async () => { + it('404 + RESOURCE_NOT_FOUND, answered by the route`s ONE absence emitter', async () => { const { rest } = setup({ getMetaItem: vi.fn().mockRejectedValue(itemNotFound()) }); const res = await callMetaItem(rest, { type: 'object', name: 'acct' }); + // What #5532 bought, unchanged: the miss is a CODED 404 and not the + // unattributable 500 the un-coded throw used to become (§ the last case + // in this block still measures that). expect(res.statusCode).toBe(404); - expect(res.body.code).toBe('RESOURCE_NOT_FOUND'); - expect(res.body.error).toBe('Metadata item object/acct not found'); + + // [#18402] The ENVELOPE moved, and only the envelope. This assertion + // read `res.body.code` / `res.body.error === 'Metadata item object/acct + // not found'` — the flat dialect `resolveErrorResponse` renders — while + // the SAME route's item-less arm answered ADR-0112's nested + // `{ error: { code, message } }` two screens away. Which one a caller + // got was decided by `metadata.enableCache` and by which protocol was + // mounted, neither of which is visible to it. Both arms now reach + // `sendMetaItemAbsent`, so `body.error.code` — the accessor #8013 + // settled on — is defined on every absence this route answers. + expect(res.body.error.code).toBe('RESOURCE_NOT_FOUND'); + expect(res.body.code).toBeUndefined(); + + // ⚠️ The producer's sentence is deliberately NOT relayed any more. It + // named the type and the name; the emitter says one fixed sentence, + // because an unpublished app answers that same sentence and ADR-0045 + // §3 makes the two indistinguishable. The operator's copy is unaffected + // — the producer still threw it, and nothing here withholds a log. + expect(res.body.error.message).toBe('Metadata item not found or access denied.'); + expect(JSON.stringify(res.body)).not.toContain('object/acct'); }, 60_000); it('and is NOT logged as an unhandled fault — a miss is a normal outcome', async () => { diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index ce11d5dc4e8..c78ee15a2a2 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -335,7 +335,7 @@ import { sendDeclaredFault, sendFieldVisibilityFault, handleRouteError, - thrownAnswerIsNotFound, + thrownAnswerIsBareNotFound, logUnexpectedRouteError, isExpectedRouteError, applyDroppedFieldsHeader, @@ -7215,16 +7215,24 @@ export class RestServer { // miss instead reached the same flat door // (pinned in `rest-meta-outage-vs-miss.test.ts`). // - // Recognised by the STATUS this repo's own - // classification door would have answered — see - // {@link thrownAnswerIsNotFound}, which asks that door - // rather than re-reading the error, so this fork and - // the `handleRouteError` it forks away from cannot - // drift. Every 404 out of this handler is absence: - // ordering and arity refusals are 400, the audience - // gate 401/403, the app gate 403, field visibility 503, - // an unreadable store 503 (#5532 — false here, and that - // distinction is the one this must never flatten). + // Recognised by the ANSWER this repo's own + // classification door would have given — see + // {@link thrownAnswerIsBareNotFound}, which asks that + // door rather than re-reading the error, so this fork + // and the `handleRouteError` it forks away from cannot + // drift apart about what a caught value means. + // + // ⛔ NOT "the status is 404", and that narrowing was + // measured rather than assumed. `NO_DRAFT` is a 404 on + // THIS route — the Studio designer's `?state=draft` + // probe, pinned byte-for-byte two files over — and it + // says the item IS there and its draft is not. + // Answering it as absence would tell a designer the + // object does not exist, which is #5532's flattening + // reintroduced by the repair for a sibling of it. Same + // reasoning excludes a producer-declared code the + // ledger does not know: ADR-0112 keeps that spelling in + // `declaredCode`, and converting would delete it. // // ⭐ It STRENGTHENS the ADR-0045 §3 property rather // than merely preserving it. The unpublished app and @@ -7245,7 +7253,7 @@ export class RestServer { // converting two of its four emissions here would mint // a new divergence — the same refusal answering two // shapes depending on which ROUTE served it. - if (thrownAnswerIsNotFound(error)) { + if (thrownAnswerIsBareNotFound(error)) { sendMetaItemAbsent(res); return; } From 8816b236c57c7e73bd83b6b62b699f6cab2fa05d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 14:49:39 +0000 Subject: [PATCH 3/6] test(rest): pin the one absence body and the 404s that are NOT absence Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude --- packages/rest/src/error-response.ts | 9 +- .../rest/src/meta-item-absent-404.test.ts | 28 +++--- packages/rest/src/zz-measure-18402.test.ts | 88 ------------------- 3 files changed, 25 insertions(+), 100 deletions(-) delete mode 100644 packages/rest/src/zz-measure-18402.test.ts diff --git a/packages/rest/src/error-response.ts b/packages/rest/src/error-response.ts index 01b3a1cca66..450da14fd0f 100644 --- a/packages/rest/src/error-response.ts +++ b/packages/rest/src/error-response.ts @@ -2670,8 +2670,13 @@ export function logUnexpectedRouteError(error: any, resolved: { status: number; * So the question is asked about the ANSWER, not the status: * * - `status` is 404, and - * - `code` is `RESOURCE_NOT_FOUND` — which this door derives from the status - * when the producer named none, and otherwise is the producer agreeing, and + * - `code` is `RESOURCE_NOT_FOUND`, i.e. the producer named that member. ⚠️ + * MEASURED: a producer that declares a 404 and NO code at all does not get + * one derived into its BODY — {@link thrownCodeFields} answers `{}`, + * ADR-0112's rule that nothing is invented for the half the producer did + * not name — so that answer is false here and keeps the shape it had. + * Folding it in would mean inventing the member the ADR declines to + * invent, and * - no `declaredCode` sits beside it. Presence MEANS demotion (see * `ApiErrorSchema`): the producer spelled a code the ledger does not know, * and ADR-0112 keeps that spelling as the open, author-authored channel. diff --git a/packages/rest/src/meta-item-absent-404.test.ts b/packages/rest/src/meta-item-absent-404.test.ts index 9df77152e34..7cba7faee8e 100644 --- a/packages/rest/src/meta-item-absent-404.test.ts +++ b/packages/rest/src/meta-item-absent-404.test.ts @@ -446,7 +446,7 @@ describe('[#18402] §5 — ONE absence body on this route, whichever arm produce return getItem(rest, 'view', 'no_such_view'); } - it('⭐ all four arms are byte-identical — the three-dialect table collapses to one', async () => { + it('⭐ the three absence arms are byte-identical — the dialect fork closes', async () => { // ① the uncached arm's item-less RETURN (the #18066 condition). const returned = await getItem( setup({}, { config: { api: { requireAuth: false }, metadata: { enableCache: false } } }).rest, @@ -473,15 +473,7 @@ describe('[#18402] §5 — ONE absence body on this route, whichever arm produce }), { config: { api: { requireAuth: false }, metadata: { enableCache: false } } }, ); - // ④ a producer that declares the 404 and NO code at all. The door - // derives `RESOURCE_NOT_FOUND` from the status, so this is the same - // answer arriving by a different road. - const uncoded = await thrownMiss( - Object.assign(new Error('nothing there'), { status: 404 }), - { config: { api: { requireAuth: false }, metadata: { enableCache: false } } }, - ); - - const arms = { returned, cached, thrown, uncoded }; + const arms = { returned, cached, thrown }; for (const [name, res] of Object.entries(arms)) { expect(`${name}: ${wire(res)}`).toBe(`${name}: ${wire(returned)}`); } @@ -555,6 +547,22 @@ describe('[#18402] §5 — ONE absence body on this route, whichever arm produce ); expect(bespoke.statusCode).toBe(404); expect(bespoke.body?.declaredCode).toBe('MY_OWN_MISS'); + + // ⚠️ MEASURED, and it corrected this file's first draft. A producer + // that declares a 404 and NO code does not get `RESOURCE_NOT_FOUND` + // derived into its body — `thrownCodeFields` answers `{}`, ADR-0112's + // own rule that nothing is invented for the half the producer did not + // name. So the door emits neither `code` nor `declaredCode` here, the + // predicate reads false, and this arm keeps the answer it had. Folding + // it in would mean INVENTING the vocabulary member the ADR declines to + // invent, in order to make a table look tidier. + const uncoded = await thrownMiss( + Object.assign(new Error('nothing there'), { status: 404 }), + { config: { api: { requireAuth: false }, metadata: { enableCache: false } } }, + ); + expect(uncoded.statusCode).toBe(404); + expect(uncoded.body).toEqual({ error: 'nothing there' }); + expect(uncoded.body?.code).toBeUndefined(); }); it('⛔ 503, 403 and 401 are untouched — the three other boundaries', async () => { diff --git a/packages/rest/src/zz-measure-18402.test.ts b/packages/rest/src/zz-measure-18402.test.ts deleted file mode 100644 index 413035c8e9c..00000000000 --- a/packages/rest/src/zz-measure-18402.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -// TEMPORARY MEASUREMENT — deleted before commit. #18402 dialect re-derivation. -import { describe, it, vi } from 'vitest'; -import { RestServer } from './rest-server'; - -const ANON = { api: { requireAuth: false } }; - -function mockServer() { - return { - get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), - use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), - }; -} -function makeRes() { - const res: any = { statusCode: 200, body: undefined, sent: false }; - res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); - res.json = vi.fn((b: any) => { res.body = b; res.sent = true; return res; }); - res.header = vi.fn(() => res); - res.setHeader = vi.fn(); res.write = vi.fn(); res.end = vi.fn(); - res.send = vi.fn(() => { res.sent = true; return res; }); - return res; -} -function absentItemEnvelope(type: string, name: string) { - return { type, name, item: undefined, lock: 'none', editable: true, deletable: true, resettable: false }; -} -function setup(corpus: Record = {}, opts: any = {}) { - const protocol: any = { - getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' } }), - getMetaTypes: vi.fn().mockResolvedValue([]), - getMetaItems: vi.fn().mockResolvedValue([]), - getMetaItem: opts.getMetaItem ?? vi.fn(async ({ type, name }: any) => { - const hit = corpus[`${type}/${name}`]; - return hit === undefined - ? absentItemEnvelope(type, name) - : { type, name, item: JSON.parse(JSON.stringify(hit)), lock: 'none', editable: true, deletable: true, resettable: false }; - }), - findData: vi.fn().mockResolvedValue([]), - ...(opts.cached !== undefined ? { getMetaItemCached: opts.cached } : {}), - }; - const rest = new RestServer(mockServer() as any, protocol as any, (opts.config ?? ANON) as any); - (rest as any).resolveExecCtx = async () => (opts.ctx === null ? undefined : { userId: 'u1', systemPermissions: opts.perms ?? [], ...(opts.ctx ?? {}) }); - if (opts.masker) (rest as any).resolveObjectMasker = opts.masker; - rest.registerRoutes(); - return { rest, protocol }; -} -async function getItem(rest: any, type: string, name: string, query: any = {}, headers: any = {}) { - const route = rest.getRoutes().find((r: any) => r.method === 'GET' && r.path === '/api/v1/meta/:type/:name'); - const res = makeRes(); - await route.handler({ method: 'GET', params: { type, name }, query, body: {}, headers }, res); - return res; -} -function dump(label: string, res: any) { - const bytes = res.body === undefined ? '' : JSON.stringify(res.body); - // eslint-disable-next-line no-console - console.log(`\n### ${label}\n status=${res.statusCode}\n bytes=${bytes}\n body.error.code=${JSON.stringify(res.body?.error?.code)} body.code=${JSON.stringify(res.body?.code)} top-keys=${JSON.stringify(Object.keys(res.body ?? {}))}`); -} - -const UNPUBLISHED_APP = { name: 'production_management', label: 'PM', _unpublished: true, navigation: [{ id: 'n1', type: 'object', objectName: 'secret_line' }] }; -const FINANCE_APP = { name: 'finance', label: 'Finance', requiredPermissions: ['finance.access'], navigation: [{ id: 'n2', type: 'object', objectName: 'invoice' }] }; -const GATED_BOOK = { name: 'admin_guide', label: 'Admin Guide', audience: { permissionSet: 'crm_admin' }, groups: [] }; -const GATED = { 'app/production_management': UNPUBLISHED_APP, 'app/finance': FINANCE_APP }; - -describe('#18402 MEASUREMENT on current origin/main', () => { - it('drives every refusal arm and dumps wire bytes', async () => { - dump('A1 absent name (uncached, app)', await getItem(setup(GATED, { perms: ['manage_users'] }).rest, 'app', 'no_such_app_xyz')); - dump('A2 UNPUBLISHED app (uncached)', await getItem(setup(GATED, { perms: ['manage_users'] }).rest, 'app', 'production_management')); - dump('A3 app permission denied', await getItem(setup(GATED, { perms: ['manage_users'] }).rest, 'app', 'finance')); - dump('A4 book audience denied (authed non-holder)', await getItem(setup({ 'book/admin_guide': GATED_BOOK }).rest, 'book', 'admin_guide')); - dump('A5 book audience anonymous', await getItem(setup({ 'book/admin_guide': GATED_BOOK }, { ctx: null }).rest, 'book', 'admin_guide')); - - const cachedMiss = Object.assign(new Error('Metadata item view/no_such_view not found'), { code: 'RESOURCE_NOT_FOUND', status: 404 }); - dump('A6 CACHED arm miss (getMetaItemCached throws)', await getItem(setup({}, { cached: vi.fn().mockRejectedValue(cachedMiss) }).rest, 'view', 'no_such_view')); - - dump('A7 UNCACHED arm, producer THROWS the miss', await getItem(setup({}, { getMetaItem: vi.fn().mockRejectedValue(Object.assign(new Error('Metadata item object/acct not found'), { code: 'RESOURCE_NOT_FOUND', status: 404 })), config: { api: { requireAuth: false }, metadata: { enableCache: false } } }).rest, 'object', 'acct')); - - dump('A8 uncached absence, non-app type (enableCache:false)', await getItem(setup({}, { config: { api: { requireAuth: false }, metadata: { enableCache: false } } }).rest, 'view', 'no_such_view')); - - dump('A9 store outage 503', await getItem(setup({}, { getMetaItem: vi.fn().mockRejectedValue(Object.assign(new Error('The metadata store could not be read.'), { code: 'SERVICE_UNAVAILABLE', status: 503 })), config: { api: { requireAuth: false }, metadata: { enableCache: false } } }).rest, 'object', 'acct')); - - // field-visibility 503 — masker throws ObjectSchemaMaskEvaluationError - const { ObjectSchemaMaskEvaluationError } = await import('@objectstack/types'); - dump('A10 field-visibility unresolved 503', await getItem(setup({}, { - masker: async () => { throw new ObjectSchemaMaskEvaluationError('nope'); }, - }).rest, 'object', 'acct')); - - // repeated query param refusal (ADR-0112 nested, per Route & surface ownership) - dump('A11 repeated query param', await getItem(setup({}).rest, 'object', 'acct', { state: ['draft', 'draft'] })); - }, 60_000); -}); From 1b39500ea08f6c9566279c6e7473cb5ccad52f77 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 14:54:30 +0000 Subject: [PATCH 4/6] chore(changeset): #18402 one absence envelope on the meta item route Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude --- .../18402-meta-item-one-absence-envelope.md | 77 +++++++++++++++++++ .../rest/src/meta-app-publish-gate.test.ts | 32 ++++++-- 2 files changed, 101 insertions(+), 8 deletions(-) create mode 100644 .changeset/18402-meta-item-one-absence-envelope.md diff --git a/.changeset/18402-meta-item-one-absence-envelope.md b/.changeset/18402-meta-item-one-absence-envelope.md new file mode 100644 index 00000000000..0c46de58887 --- /dev/null +++ b/.changeset/18402-meta-item-one-absence-envelope.md @@ -0,0 +1,77 @@ +--- +"@objectstack/rest": patch +--- + +fix(rest): `GET /meta/:type/:name` answers absence in ONE envelope, whichever arm produced it (#18402) + +`patch` — a bug fix in a released package. No exported symbol added to the +package entry, no spec or ADR edit, no authorable key touched. +`Clause-②: no` — the contract surface (`packages/spec`) is not in this diff, and +nothing an author can write changes. + +## What was wrong + +#18066 gave this route ONE absence emitter and reached it from the two +conditions that RETURN nothing. The conditions that THROW one were left on the +classification door, which renders the flat `{ error: '', code }`. So +`body.error.code` — the accessor #8013 settled on and objectui#4252 reads — was +`undefined` on exactly those, and **which envelope a caller had to parse for an +absence was decided by two things it cannot see**: + +- `metadata.enableCache`, which **defaults to `true`**. The cached arm's + `getMetaItemCached` throws `metadataItemNotFoundError` on a falsy `item`; the + uncached arm resolves item-less and returns. +- which protocol implementation is mounted. The in-repo `metadata-protocol` + resolves item-less from `getMetaItem`; a protocol that throws the miss reached + the same flat door. + +Re-measured on `origin/main` at `551139bb7` rather than copied from the report — +the same absent `view`, driven through both arms: + +| arm | status | body | +|:--|--:|:--| +| uncached, item-less return | 404 | `{"error":{"code":"RESOURCE_NOT_FOUND","message":"Metadata item not found or access denied."}}` | +| cached, producer throws | 404 | `{"error":"Metadata item view/no_such_view not found","code":"RESOURCE_NOT_FOUND"}` | + +Same route, same status, same code, two envelopes — and the flat one echoed the +type and the name where the emitter says one fixed sentence. + +## What it does now + +Both arms reach `sendMetaItemAbsent`. The route's absence answer is one body: + +``` +404 {"error":{"code":"RESOURCE_NOT_FOUND","message":"Metadata item not found or access denied."}} +``` + +⭐ This **strengthens** the ADR-0045 §3 property rather than merely preserving +it. The unpublished app and the service-gated one already answered through the +emitter, so an absence that kept the thrown dialect was a response pair that +told them apart — by envelope shape, and by the producer's prose. Byte-identity +across all of them is now pinned on the SERIALIZED body, not on object equality. + +## ⛔ What it deliberately does NOT do + +- **It is not "every 404 is absence."** `NO_DRAFT` is a 404 on this same route — + the Studio designer's `?state=draft` probe — and it says the item IS there and + its draft is not. Folding it in would tell a designer the object does not + exist: #5532's flattening, reintroduced by the repair for a sibling of it. A + producer-declared code the ADR-0112 ledger does not know keeps its + `declaredCode` for the same reason, and a producer that declared NO code gets + none invented for it. +- **It does not converge the flat dialect itself.** The ADR-0046 audience gate's + `sendDeclaredFault` 401/403 beside this, and the door in `error-response.ts`, + still answer flat. That envelope POSITION is the live ratchet **#9559** owns + repo-wide (`check:route-envelope`); converting two of its four emissions here + would mint a new divergence — the same refusal answering two shapes depending + on which ROUTE served it. + +## What moves for consumers + +A caller that branched on `body.code` for this route's **absence** reads +`body.error.code` now. ⚠️ No caller could have had a working dependency on the +flat shape here: it was already nondeterministic from the caller's side, decided +by a server setting and by which protocol was mounted, and the default +deployment's uncached arm answered the nested shape all along. Every other +refusal on this route — 400, 401, 403, `NO_DRAFT`'s 404, 503 — is byte-identical +to before. diff --git a/packages/rest/src/meta-app-publish-gate.test.ts b/packages/rest/src/meta-app-publish-gate.test.ts index 396922d4921..324eb79db13 100644 --- a/packages/rest/src/meta-app-publish-gate.test.ts +++ b/packages/rest/src/meta-app-publish-gate.test.ts @@ -492,16 +492,25 @@ describe('#8013 — by-name: a permission denial is REPORTED, absence still is n expect(absent.statusCode).toBe(404); }); - it('criterion 3: …and the REJECTING producer shape reaches the same status and code', async () => { + it('criterion 3: …and the REJECTING producer shape reaches the same BODY, not merely the same code', async () => { // The other producer shape this door must survive: a protocol // implementation that REJECTS with a declared `RESOURCE_NOT_FOUND` / // `status: 404` (`rest-meta-outage-vs-miss.test.ts` pins the rendering). - // ⚠️ Its body is the FLAT `{ error: '', code }` that - // `resolveErrorResponse`'s declared-status passthrough produces, not the - // nested ADR-0112 envelope the in-route refusals emit — so this case - // asserts `body.code`, and the case above asserts `body.error.code`, on - // purpose. Both reach this route, so the criterion is stated against - // both rather than against one stub's. + // + // [#18402] This case used to read `missing.body?.code` while criterion + // 2 above read `body.error.code` — "on purpose", said the note that + // stood here, because the rejecting shape rendered the FLAT + // `{ error: '', code }` and the resolving shape the nested + // ADR-0112 envelope. ⚠️ That IS the finding: one door, one absence, two + // envelopes, and which one a caller got depended on the protocol + // implementation and on `metadata.enableCache` — neither visible to the + // caller. Both arms now reach this route's single absence emitter. + // + // ⭐ So the criterion is STRENGTHENED rather than translated: the + // rejecting shape is compared against the UNPUBLISHED app as a whole + // body, which is what ADR-0045 §3 actually asks. A status-and-code + // assertion could never have carried that — the flat and the nested + // body agreed on both while differing everywhere a client looks. const { rest, protocol } = setup([], GATED_APPS); protocol.getMetaItem = vi.fn().mockRejectedValue(Object.assign( new Error('Metadata item app/no_such_app not found'), @@ -509,11 +518,18 @@ describe('#8013 — by-name: a permission denial is REPORTED, absence still is n )); const missing = await getItem(rest, 'no_such_app'); + const unpublished = await getItem(setup(['manage_users'], GATED_APPS).rest, 'production_management'); expect(missing.statusCode).toBe(404); - expect(missing.body?.code).toBe('RESOURCE_NOT_FOUND'); + expect(missing.body?.error?.code).toBe('RESOURCE_NOT_FOUND'); + expect(missing.body?.code).toBeUndefined(); expect(missing.statusCode).not.toBe(403); expect(JSON.stringify(missing.body ?? {})).not.toContain('PERMISSION_DENIED'); + + // The producer's sentence named the type and the name; the emitter's + // names nothing, and the unpublished app answers the emitter's. + expect(JSON.stringify(missing.body)).toBe(JSON.stringify(unpublished.body)); + expect(JSON.stringify(missing.body ?? {})).not.toContain('no_such_app'); }); it('criterion 4: the LIST route is untouched — the app is absent, not flagged', async () => { From ad231f0f1af8e864a3026fd19fc158b387ac3f41 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 15:26:06 +0000 Subject: [PATCH 5/6] test(dogfood): read the meta absence code on the ADR-0112 accessor, not the flat one Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude --- ...se-anonymous-deny-surfaces.dogfood.test.ts | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts b/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts index e81e3df5e4e..1aad1c25a6f 100644 --- a/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts @@ -311,8 +311,26 @@ describe('showcase: anonymous posture is uniform across surfaces (#2567)', () => } const r = await stack.apiAs(adminToken, 'GET', `/meta/object/${META_PROBE_OBJECT}`); expect(r.status, 'the object the anonymous PUT tried to author must not exist').toBe(404); - const body = (await r.json()) as Record; - expect(body.code).toBe('RESOURCE_NOT_FOUND'); + const body = (await r.json()) as { error?: { code?: string } }; + // [#18402] ENVELOPE, not semantics. The claim this case makes — the + // anonymous PUT left nothing behind, so the object is absent — is carried + // by the `404` above and is unchanged; only where the code is READ moved. + // `GET /meta/:type/:name` used to answer absence in two envelopes and + // `metadata.enableCache` picked one, so this line read the FLAT `body.code` + // and the route's own item-less arm answered the nested one. Both arms now + // reach the single absence emitter, and this is the ADR-0112 accessor #8013 + // settled on. + // + // ⭐ Worth recording where this file records it: the showcase declares no + // `enableCache`, so it runs the DEFAULT `true` and `object` takes the + // CACHED arm. This case is therefore the measurement that the flat dialect + // was the answer a default deployment really shipped for a non-`app` type — + // not the minority path. + // + // ⛔ Read in its own shape, with no `??` chain across the two shells — the + // #5632 rule this file already enforces for the 401 bodies and for the + // `/actions` 404 below. + expect(body.error?.code).toBe('RESOURCE_NOT_FOUND'); }); it('[#12176 D3] the retired compound save routes NOWHERE — 404 for everyone, not a 401', async () => { From 6c1456fc56b5a88461aa2848c8fab5e368106736 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 15:52:36 +0000 Subject: [PATCH 6/6] chore(changeset): declare the meta absence envelope change BREAKING as minor (#18402) Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude --- .../18402-meta-item-one-absence-envelope.md | 75 +++++++------------ 1 file changed, 25 insertions(+), 50 deletions(-) diff --git a/.changeset/18402-meta-item-one-absence-envelope.md b/.changeset/18402-meta-item-one-absence-envelope.md index 0c46de58887..c0ec68911e9 100644 --- a/.changeset/18402-meta-item-one-absence-envelope.md +++ b/.changeset/18402-meta-item-one-absence-envelope.md @@ -1,40 +1,30 @@ --- -"@objectstack/rest": patch +"@objectstack/rest": minor --- -fix(rest): `GET /meta/:type/:name` answers absence in ONE envelope, whichever arm produced it (#18402) +fix(rest)!: `GET /meta/:type/:name` answers absence in ONE envelope, whichever arm produced it (#18402) -`patch` — a bug fix in a released package. No exported symbol added to the -package entry, no spec or ADR edit, no authorable key touched. -`Clause-②: no` — the contract surface (`packages/spec`) is not in this diff, and -nothing an author can write changes. + + +Clause-②: no + +The contract surface (`packages/spec`) is not in this diff; no authorable key, no closed-set member, no published export and no registry entry moves. ## What was wrong -#18066 gave this route ONE absence emitter and reached it from the two -conditions that RETURN nothing. The conditions that THROW one were left on the -classification door, which renders the flat `{ error: '', code }`. So -`body.error.code` — the accessor #8013 settled on and objectui#4252 reads — was -`undefined` on exactly those, and **which envelope a caller had to parse for an -absence was decided by two things it cannot see**: +#18066 gave this route ONE absence emitter and reached it from the two conditions that RETURN nothing. The conditions that THROW one were left on the classification door, which renders the flat envelope — a string `error` beside a top-level `code`. So `body.error.code` — the accessor #8013 settled on and objectui#4252 reads — was `undefined` on exactly those, and **which envelope a caller had to parse for an absence was decided by two things it cannot see**: -- `metadata.enableCache`, which **defaults to `true`**. The cached arm's - `getMetaItemCached` throws `metadataItemNotFoundError` on a falsy `item`; the - uncached arm resolves item-less and returns. -- which protocol implementation is mounted. The in-repo `metadata-protocol` - resolves item-less from `getMetaItem`; a protocol that throws the miss reached - the same flat door. +- `metadata.enableCache`, which **defaults to `true`**. The cached arm's `getMetaItemCached` throws `metadataItemNotFoundError` on a falsy `item`; the uncached arm resolves item-less and returns. +- which protocol implementation is mounted. The in-repo `metadata-protocol` resolves item-less from `getMetaItem`; a protocol that throws the miss reached the same flat door. -Re-measured on `origin/main` at `551139bb7` rather than copied from the report — -the same absent `view`, driven through both arms: +Re-measured on `origin/main` at `551139bb7` rather than copied from the report — the same absent `view`, driven through both arms: | arm | status | body | |:--|--:|:--| | uncached, item-less return | 404 | `{"error":{"code":"RESOURCE_NOT_FOUND","message":"Metadata item not found or access denied."}}` | | cached, producer throws | 404 | `{"error":"Metadata item view/no_such_view not found","code":"RESOURCE_NOT_FOUND"}` | -Same route, same status, same code, two envelopes — and the flat one echoed the -type and the name where the emitter says one fixed sentence. +Same route, same status, same code, two envelopes — and the flat one echoed the type and the name where the emitter says one fixed sentence. ## What it does now @@ -44,34 +34,19 @@ Both arms reach `sendMetaItemAbsent`. The route's absence answer is one body: 404 {"error":{"code":"RESOURCE_NOT_FOUND","message":"Metadata item not found or access denied."}} ``` -⭐ This **strengthens** the ADR-0045 §3 property rather than merely preserving -it. The unpublished app and the service-gated one already answered through the -emitter, so an absence that kept the thrown dialect was a response pair that -told them apart — by envelope shape, and by the producer's prose. Byte-identity -across all of them is now pinned on the SERIALIZED body, not on object equality. +⭐ This **strengthens** the ADR-0045 §3 property rather than merely preserving it. The unpublished app and the service-gated one already answered through the emitter, so an absence that kept the thrown dialect was a response pair that told them apart — by envelope shape, and by the producer's prose. Byte-identity across all of them is now pinned on the SERIALIZED body, not on object equality. + +## **BREAKING** — the default wire answer moves for non-`app` types + +**BREAKING** in the accept-set sense, landing in the launch window as `minor` (the lockstep convention: `major` is refused by `check-changeset-no-major`, and breaking-ness is carried by this banner plus the ADR-0087 disposition above). + +What breaks: on `GET /meta/:type/:name`, the **absence** refusal moves from the flat top-level `code` to the nested `error.code`. ⚠️ For every type that does **not** bypass the cache — `object`, `view`, `flow`, `page` and the rest — this is the **default** answer, not a minority path: `metadata.enableCache` defaults to `true`, so those types took the cached arm and the cached arm threw. Measured in this repo against a real booted app: the showcase declares no `enableCache`, and its dogfood pin on `GET /meta/object/:name` was reading the flat `body.code` — a real consumer, in-tree, depending on the flat shape for exactly this refusal. + +Only `app` (and `dashboard`, `doc`, `book`, `?state=draft`, `?preview=draft`, `?package=`) bypassed the cache and already answered the nested shape. + +**The remedy is one accessor.** Read `body.error.code` instead of `body.code` on this route's 404. Nothing else about the refusal moves: the status is still `404`, the code is still `RESOURCE_NOT_FOUND`, and the message is the emitter's fixed sentence rather than the producer's. `ObjectStackClient` normalizes both envelopes already, so SDK callers are unaffected. ## ⛔ What it deliberately does NOT do -- **It is not "every 404 is absence."** `NO_DRAFT` is a 404 on this same route — - the Studio designer's `?state=draft` probe — and it says the item IS there and - its draft is not. Folding it in would tell a designer the object does not - exist: #5532's flattening, reintroduced by the repair for a sibling of it. A - producer-declared code the ADR-0112 ledger does not know keeps its - `declaredCode` for the same reason, and a producer that declared NO code gets - none invented for it. -- **It does not converge the flat dialect itself.** The ADR-0046 audience gate's - `sendDeclaredFault` 401/403 beside this, and the door in `error-response.ts`, - still answer flat. That envelope POSITION is the live ratchet **#9559** owns - repo-wide (`check:route-envelope`); converting two of its four emissions here - would mint a new divergence — the same refusal answering two shapes depending - on which ROUTE served it. - -## What moves for consumers - -A caller that branched on `body.code` for this route's **absence** reads -`body.error.code` now. ⚠️ No caller could have had a working dependency on the -flat shape here: it was already nondeterministic from the caller's side, decided -by a server setting and by which protocol was mounted, and the default -deployment's uncached arm answered the nested shape all along. Every other -refusal on this route — 400, 401, 403, `NO_DRAFT`'s 404, 503 — is byte-identical -to before. +- **It is not "every 404 is absence."** `NO_DRAFT` is a 404 on this same route — the Studio designer's `?state=draft` probe — and it says the item **is** there and its draft is not. Folding it in would tell a designer the object does not exist: #5532's flattening, reintroduced by the repair for a sibling of it. A producer-declared code the ADR-0112 ledger does not know keeps its `declaredCode` for the same reason, and a producer that declared NO code gets none invented for it. +- **It does not converge the flat dialect itself.** That envelope POSITION is the live ratchet **#9559** owns repo-wide (`check:route-envelope`); converting two of `sendDeclaredFault`'s four emissions here would mint a new divergence — the same audience refusal answering two shapes depending on which ROUTE served it.