From 6e05b0448e8da96cd60ad81f4f224c592bd78927 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 11:48:21 +0000 Subject: [PATCH 1/3] fix(runtime): the /meta generic branch refuses an item-less envelope, like its own object branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `handleMetadataRequest`'s generic `:type/:name` branch returned `protocol.getMetaItem`'s answer straight through with `deps.success(data)`. That producer answers a miss with the protection envelope wrapped around an absent item (`{ type, name, item: undefined, lock, editable, deletable, resettable }`) rather than with `undefined`, so a name with nothing behind it was announced as a 200 whose body — after `JSON.stringify` drops the member — is the declared envelope minus its required `item`. The `object` branch of the same function already refuses that exact shape and 404s, so one function answered "does absence mean success?" both ways depending on which type you asked for. `GetMetaItemResponseSchema` declares `item` required, and the REST twin of this door refuses the same shape. Apply the sibling branch's hit test, and let the miss fall through to the MetadataService resolver and then to the branch's own existing 404. No new refusal dialect is introduced. Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude --- .../src/domains/meta-item-absent-404.test.ts | 183 ++++++++++++++++++ packages/runtime/src/domains/meta.ts | 30 ++- 2 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 packages/runtime/src/domains/meta-item-absent-404.test.ts diff --git a/packages/runtime/src/domains/meta-item-absent-404.test.ts b/packages/runtime/src/domains/meta-item-absent-404.test.ts new file mode 100644 index 00000000000..07d0d3fbe1f --- /dev/null +++ b/packages/runtime/src/domains/meta-item-absent-404.test.ts @@ -0,0 +1,183 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#18401] `GET /meta/:type/:name` on the dispatcher's `/meta` domain, for a + * name with NOTHING behind it — on the GENERIC `:type/:name` branch. + * + * The generic branch returned `protocol.getMetaItem`'s answer straight through + * with `deps.success(data)`. That producer answers a miss with the protection + * envelope wrapped around an absent item — `{ type, name, item: undefined, + * lock, editable, deletable, resettable }`, because `resolveLockState(undefined, + * false)` is unconditional — never with `undefined`. So the miss arrived at the + * caller as a `200`, and `JSON.stringify` at the transport dropped the `item` + * member on the way out: the declared envelope MINUS its required member, + * announced as a hit. + * + * ── Why this is execution and not a design question ───────────────────────── + * + * Three declarations already agreed with each other and against this one + * branch; only the branch was wrong. + * + * 1. The `object` branch of the SAME function refuses the identical shape + * ("only treat the lookup as a hit when `item` is really there") and 404s. + * §3 below pins the two branches answering one question one way. + * 2. `GetMetaItemResponseSchema` declares `item` a required member while every + * genuinely-optional key beside it is spelled `.optional()`. §2 asserts that + * against the wire body rather than restating it. + * 3. The REST twin of this door refuses the same shape (#18066). + * + * ── What this file deliberately does NOT do ───────────────────────────────── + * + * It adds no refusal DIALECT. The fall-through ends at the branch's own + * `deps.error('Not found', 404)` — the ADR-0112 nested `{ success:false, + * error:{ code, message, httpStatus } }` this file already speaks everywhere — + * so the three-dialect question #18402 raises about this route is neither + * answered nor pre-empted here. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { GetMetaItemResponseSchema } from '@objectstack/spec/api'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +const AGENT = { name: 'triage_bot', label: 'Triage Bot', model: 'claude' }; +const CUSTOMER = { name: 'customer', label: 'Customer', fields: { id: { type: 'text' } } }; + +/** + * ⭐ THE FIXTURE THAT MATTERS: what `metadata-protocol`'s `getMetaItem` really + * resolves for a miss, key for key — the envelope with `item` present and + * holding `undefined`, NOT `undefined` itself. A double that answers + * `undefined` never reproduces this defect, because it never had an envelope to + * lose a member from. + */ +function absentItemEnvelope(type: string, name: string) { + return { + type, name, + item: undefined, + lock: 'none', editable: true, deletable: true, resettable: false, + }; +} + +/** Build a dispatcher whose kernel resolves exactly the named services. */ +function make(services: Record) { + const kernel = { + getServiceAsync: async (name: string) => services[name] ?? null, + getService: (name: string) => services[name] ?? null, + context: { getService: (name: string) => services[name] ?? null }, + } as any; + return new HttpDispatcher(kernel); +} + +const ctx = (): any => ({ + request: {}, + environmentId: 'platform', + executionContext: { userId: 'u1', systemPermissions: [] }, +}); + +/** + * A protocol double whose `getMetaItem` knows `corpus` and answers + * {@link absentItemEnvelope} — the live producer's miss — for anything else. + */ +function protocolDouble(corpus: Record = {}, extra: Record = {}) { + return { + getMetaItem: vi.fn(async ({ type, name }: any) => { + const hit = corpus[`${type}/${name}`]; + return hit === undefined + ? absentItemEnvelope(type, name) + : { type, name, item: hit, lock: 'none', editable: true, deletable: true, resettable: false }; + }), + ...extra, + }; +} + +/** The wire body, after the serialization that drops an `undefined` member. */ +const onWire = (body: any) => JSON.parse(JSON.stringify(body)); + +describe('#18401 dispatcher /meta generic branch — an item-less envelope is a MISS, not a success', () => { + it('§1 refuses an absent name with 404 RESOURCE_NOT_FOUND instead of the item-less 200', async () => { + const protocol = protocolDouble(); + const res = await make({ protocol }).handleMetadata('/agent/no_such_agent_xyz', ctx(), 'GET'); + + // The refusal comes from the item-less guard, not from an absent + // protocol handle: the producer really was consulted. + expect(protocol.getMetaItem).toHaveBeenCalledWith( + expect.objectContaining({ type: 'agent', name: 'no_such_agent_xyz' }), + ); + expect(res.response.status).toBe(404); + // ADR-0112 nested envelope: `code` and `status` are the minimal pin. + expect(res.response.body.error.code).toBe('RESOURCE_NOT_FOUND'); + expect(res.response.body.error.httpStatus).toBe(404); + expect(res.response.body.success).toBe(false); + }); + + it('§2 the item-less envelope never reaches the wire as a 200 — it does not satisfy the response contract', async () => { + const res = await make({ protocol: protocolDouble() }) + .handleMetadata('/agent/no_such_agent_xyz', ctx(), 'GET'); + + // The shape the branch used to serve, measured against the schema the + // route declares. Asserted here so the pin states WHY 200 was wrong, + // not merely that the number changed. + const wouldHaveShipped = onWire(absentItemEnvelope('agent', 'no_such_agent_xyz')); + expect('item' in wouldHaveShipped).toBe(false); + expect(GetMetaItemResponseSchema.safeParse(wouldHaveShipped).success).toBe(false); + + // And it is not what the caller gets. + expect(res.response.status).not.toBe(200); + expect(res.response.body.data).toBeUndefined(); + }); + + it('§3 the `object` branch and the generic branch answer absence THE SAME WAY (the finding)', async () => { + // Same function, same question, entered through two different types. + // `getProjectId` puts the object branch on its scoped path — the one + // that consults the protocol first, exactly as the generic branch does. + const generic = await make({ protocol: protocolDouble() }) + .handleMetadata('/agent/nobody_home', ctx(), 'GET'); + const object = await make({ protocol: protocolDouble({}, { getProjectId: () => 'env_1' }) }) + .handleMetadata('/object/nobody_home', ctx(), 'GET'); + + expect(generic.response.status).toBe(object.response.status); + expect(generic.response.body.error.code).toBe(object.response.body.error.code); + expect(generic.response.status).toBe(404); + }); + + it('§4 an item-less protocol answer FALLS THROUGH to the MetadataService rather than terminating the read', async () => { + // The guard is a miss test, not a hard refusal: the later resolvers in + // the chain must still get their turn, the way the object branch's + // registry fallback does. + const protocol = protocolDouble(); + const getItem = vi.fn(async () => AGENT); + const res = await make({ protocol, metadata: { getItem } }) + .handleMetadata('/agents/triage_bot', ctx(), 'GET'); + + expect(protocol.getMetaItem).toHaveBeenCalled(); + expect(getItem).toHaveBeenCalled(); + expect(res.response.status).toBe(200); + // The plural URL segment still resolves to the canonical singular. + expect(res.response.body.data).toMatchObject({ type: 'agent', name: 'triage_bot' }); + expect(res.response.body.data.item).toMatchObject({ label: 'Triage Bot', model: 'claude' }); + }); + + it('§5 a real hit is untouched — the whole protection envelope still passes through', async () => { + // The control: if the guard were reading the wrong member, or reading it + // too strictly, this is the assertion that fails. Every value read comes + // from INSIDE the answer, so a vacuous pass is not available. + const protocol = protocolDouble({ 'agent/triage_bot': AGENT }); + const res = await make({ protocol }).handleMetadata('/agent/triage_bot', ctx(), 'GET'); + + expect(res.response.status).toBe(200); + expect(res.response.body.data).toMatchObject({ + type: 'agent', name: 'triage_bot', lock: 'none', editable: true, deletable: true, + }); + expect(res.response.body.data.item).toMatchObject({ label: 'Triage Bot', model: 'claude' }); + // And the answer this branch DOES serve satisfies the route's declared + // response contract on the wire, `item` member included. + expect(GetMetaItemResponseSchema.safeParse(onWire(res.response.body.data)).success).toBe(true); + }); + + it('§6 the object branch still serves its own hit — the sibling is not collateral', async () => { + const protocol = protocolDouble({ 'object/customer': CUSTOMER }, { getProjectId: () => 'env_1' }); + const res = await make({ protocol }).handleMetadata('/object/customer', ctx(), 'GET'); + + expect(res.response.status).toBe(200); + expect(res.response.body.data.item).toMatchObject({ label: 'Customer' }); + }); +}); diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index b2c9a06fb1a..4e5e86dde30 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -870,7 +870,35 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // Admin gating is layered on top in a follow-up (step 2). const previewDrafts = query?.preview === 'draft'; const data = await protocol.getMetaItem({ type: singularType, name, packageId, organizationId, previewDrafts }); - return { handled: true, response: deps.success(data) }; + // [#18401] The SAME hit test the `object` branch above runs, + // asked here for the same reason. `getMetaItem` answers a + // miss with the protection envelope around an absent item — + // `{ type, name, item: undefined, lock, editable, deletable, + // resettable }`, because `resolveLockState(undefined, false)` + // is unconditional — never with `undefined`. Returned + // straight through, `JSON.stringify` at the transport drops + // the `item` member and the caller is handed a 200 whose body + // is the declared envelope MINUS its required member: the + // route reports a hit for a name with nothing behind it. + // + // ⭐ What made this a defect rather than a rough edge is that + // this function already answered the same question the other + // way one branch up: `object` refuses the item-less envelope + // and 404s. One function, two opposite answers to "does + // absence mean success?", selected by which type you asked + // for. `GetMetaItemResponseSchema` declares `item` required, + // and the REST twin of this door refuses the identical shape + // (#18066) — three declarations agreeing against one branch. + // + // ⛔ This adds no new refusal dialect. The fall-through ends + // at this block's OWN `deps.error('Not found', 404)` below — + // the ADR-0112 nested envelope every other refusal in this + // file already speaks — so the dialect question #18402 raises + // about this route is untouched here, neither answered nor + // pre-empted. + if (data?.item != null) { + return { handled: true, response: deps.success(data) }; + } } catch (e: any) { // Protocol might throw if not found or not supported } From 31938d06f3553958b52a2231c4777aa34fa91f99 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 12:04:55 +0000 Subject: [PATCH 2/3] test(runtime): narrow the dispatcher result instead of optional-chaining through it `check:test-typecheck` is an exact, shrink-only ratchet and the new pin's file is inside the checked zone, so the sibling files' `res.response?.` spelling is not available to it. A local narrowing helper is the stronger form anyway: the negative assertions in this file (`toBeUndefined()`, `not.toBe(200)`) pass vacuously against an unhandled result, which is the one outcome that must not read as a pass here. Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude --- .../src/domains/meta-item-absent-404.test.ts | 58 +++++++++++++------ 1 file changed, 40 insertions(+), 18 deletions(-) diff --git a/packages/runtime/src/domains/meta-item-absent-404.test.ts b/packages/runtime/src/domains/meta-item-absent-404.test.ts index 07d0d3fbe1f..50536893c87 100644 --- a/packages/runtime/src/domains/meta-item-absent-404.test.ts +++ b/packages/runtime/src/domains/meta-item-absent-404.test.ts @@ -92,6 +92,21 @@ function protocolDouble(corpus: Record = {}, extra: Record JSON.parse(JSON.stringify(body)); +type Answered = NonNullable>['response']>; + +/** + * The dispatcher's answer, or a loud failure. The optional-chained + * `res.response?.` spelling is what the siblings in this directory use, but + * every NEGATIVE assertion below + * (`toBeUndefined()`, `not.toBe(200)`) passes vacuously against an unhandled + * result, which is the one outcome that must not read as a pass here. So this + * file refuses the optional rather than reaching through it. + */ +function answered(res: { response?: Answered }): Answered { + if (!res.response) throw new Error('the dispatcher did not handle the request - there is no answer to assert on'); + return res.response; +} + describe('#18401 dispatcher /meta generic branch — an item-less envelope is a MISS, not a success', () => { it('§1 refuses an absent name with 404 RESOURCE_NOT_FOUND instead of the item-less 200', async () => { const protocol = protocolDouble(); @@ -102,11 +117,12 @@ describe('#18401 dispatcher /meta generic branch — an item-less envelope is a expect(protocol.getMetaItem).toHaveBeenCalledWith( expect.objectContaining({ type: 'agent', name: 'no_such_agent_xyz' }), ); - expect(res.response.status).toBe(404); + const answer = answered(res); + expect(answer.status).toBe(404); // ADR-0112 nested envelope: `code` and `status` are the minimal pin. - expect(res.response.body.error.code).toBe('RESOURCE_NOT_FOUND'); - expect(res.response.body.error.httpStatus).toBe(404); - expect(res.response.body.success).toBe(false); + expect(answer.body.error.code).toBe('RESOURCE_NOT_FOUND'); + expect(answer.body.error.httpStatus).toBe(404); + expect(answer.body.success).toBe(false); }); it('§2 the item-less envelope never reaches the wire as a 200 — it does not satisfy the response contract', async () => { @@ -121,8 +137,9 @@ describe('#18401 dispatcher /meta generic branch — an item-less envelope is a expect(GetMetaItemResponseSchema.safeParse(wouldHaveShipped).success).toBe(false); // And it is not what the caller gets. - expect(res.response.status).not.toBe(200); - expect(res.response.body.data).toBeUndefined(); + const answer = answered(res); + expect(answer.status).not.toBe(200); + expect(answer.body.data).toBeUndefined(); }); it('§3 the `object` branch and the generic branch answer absence THE SAME WAY (the finding)', async () => { @@ -134,9 +151,11 @@ describe('#18401 dispatcher /meta generic branch — an item-less envelope is a const object = await make({ protocol: protocolDouble({}, { getProjectId: () => 'env_1' }) }) .handleMetadata('/object/nobody_home', ctx(), 'GET'); - expect(generic.response.status).toBe(object.response.status); - expect(generic.response.body.error.code).toBe(object.response.body.error.code); - expect(generic.response.status).toBe(404); + const g = answered(generic); + const o = answered(object); + expect(g.status).toBe(o.status); + expect(g.body.error.code).toBe(o.body.error.code); + expect(g.status).toBe(404); }); it('§4 an item-less protocol answer FALLS THROUGH to the MetadataService rather than terminating the read', async () => { @@ -150,10 +169,11 @@ describe('#18401 dispatcher /meta generic branch — an item-less envelope is a expect(protocol.getMetaItem).toHaveBeenCalled(); expect(getItem).toHaveBeenCalled(); - expect(res.response.status).toBe(200); + const answer = answered(res); + expect(answer.status).toBe(200); // The plural URL segment still resolves to the canonical singular. - expect(res.response.body.data).toMatchObject({ type: 'agent', name: 'triage_bot' }); - expect(res.response.body.data.item).toMatchObject({ label: 'Triage Bot', model: 'claude' }); + expect(answer.body.data).toMatchObject({ type: 'agent', name: 'triage_bot' }); + expect(answer.body.data.item).toMatchObject({ label: 'Triage Bot', model: 'claude' }); }); it('§5 a real hit is untouched — the whole protection envelope still passes through', async () => { @@ -163,21 +183,23 @@ describe('#18401 dispatcher /meta generic branch — an item-less envelope is a const protocol = protocolDouble({ 'agent/triage_bot': AGENT }); const res = await make({ protocol }).handleMetadata('/agent/triage_bot', ctx(), 'GET'); - expect(res.response.status).toBe(200); - expect(res.response.body.data).toMatchObject({ + const answer = answered(res); + expect(answer.status).toBe(200); + expect(answer.body.data).toMatchObject({ type: 'agent', name: 'triage_bot', lock: 'none', editable: true, deletable: true, }); - expect(res.response.body.data.item).toMatchObject({ label: 'Triage Bot', model: 'claude' }); + expect(answer.body.data.item).toMatchObject({ label: 'Triage Bot', model: 'claude' }); // And the answer this branch DOES serve satisfies the route's declared // response contract on the wire, `item` member included. - expect(GetMetaItemResponseSchema.safeParse(onWire(res.response.body.data)).success).toBe(true); + expect(GetMetaItemResponseSchema.safeParse(onWire(answer.body.data)).success).toBe(true); }); it('§6 the object branch still serves its own hit — the sibling is not collateral', async () => { const protocol = protocolDouble({ 'object/customer': CUSTOMER }, { getProjectId: () => 'env_1' }); const res = await make({ protocol }).handleMetadata('/object/customer', ctx(), 'GET'); - expect(res.response.status).toBe(200); - expect(res.response.body.data.item).toMatchObject({ label: 'Customer' }); + const answer = answered(res); + expect(answer.status).toBe(200); + expect(answer.body.data.item).toMatchObject({ label: 'Customer' }); }); }); From 7a8e5f3a43af2efd9155b094d5bb517d2ce81ef1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 12:20:14 +0000 Subject: [PATCH 3/3] chore: changeset for the /meta generic-branch item-less refusal `@objectstack/runtime` publishes `dist/`, and the built `dist/index.js` carries the changed hit test (marker count 3, matching the post-fix source, against a positive control that hits), so published bytes move and `skip-changeset` is not available. Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude --- .../18401-meta-generic-branch-itemless-success.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/18401-meta-generic-branch-itemless-success.md diff --git a/.changeset/18401-meta-generic-branch-itemless-success.md b/.changeset/18401-meta-generic-branch-itemless-success.md new file mode 100644 index 00000000000..e9125b75364 --- /dev/null +++ b/.changeset/18401-meta-generic-branch-itemless-success.md @@ -0,0 +1,14 @@ +--- +"@objectstack/runtime": patch +--- + +The dispatcher's `/meta` domain answers `GET /meta/:type/:name` for a name with nothing behind it with `404 RESOURCE_NOT_FOUND` on its generic `:type/:name` branch, instead of announcing the miss as a `200` (#18401). + +**Clause-②: no** — no schema key moves, no accept set widens or narrows, no export changes, and no error code is minted: the refusal reuses the branch's own existing `deps.error('Not found', 404)`, whose code `standardErrorCodeForHttpStatus` already derives. + +`protocol.getMetaItem` answers a miss with the protection envelope wrapped around an absent item — `{ type, name, item: undefined, lock, editable, deletable, resettable }`, because `resolveLockState(undefined, false)` is unconditional — never with `undefined`. The generic branch returned that straight through, and `JSON.stringify` at the transport then dropped the `item` member, so a caller was handed a `200` whose body is the declared `GetMetaItemResponseSchema` envelope **minus its required member**. + +- **The branch disagreed with its own sibling.** The `object` branch of the same function already refused that exact shape and answered `404`, so one function answered "does absence mean success?" both ways, decided by which type you asked for. The generic branch now runs the same hit test. +- **A miss still falls through, it is not a hard refusal.** An item-less protocol answer hands the read on to the `MetadataService` resolver exactly as the object branch hands its own on to the ObjectQL registry; only a read that no resolver can satisfy reaches the `404`. +- **No new refusal dialect.** The fall-through ends at the branch's own pre-existing `404`, the ADR-0112 nested `{ success:false, error:{ code, message, httpStatus } }` this file already speaks — so the separate question of how this route spells its refusals is untouched. +- **What a caller observes**: a name with no item behind it. A request that was previously answered `200` with an item-less body is now answered `404`; a request that resolves to a real item is byte-identical to before, protection envelope included.