Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/18401-meta-generic-branch-itemless-success.md
Original file line number Diff line number Diff line change
@@ -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.
205 changes: 205 additions & 0 deletions packages/runtime/src/domains/meta-item-absent-404.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
// 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<string, any>) {
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<string, unknown> = {}, extra: Record<string, unknown> = {}) {
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));

type Answered = NonNullable<Awaited<ReturnType<HttpDispatcher['handleMetadata']>>['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();
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' }),
);
const answer = answered(res);
expect(answer.status).toBe(404);
// ADR-0112 nested envelope: `code` and `status` are the minimal pin.
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 () => {
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.
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 () => {
// 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');

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 () => {
// 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();
const answer = answered(res);
expect(answer.status).toBe(200);
// The plural URL segment still resolves to the canonical singular.
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 () => {
// 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');

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(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(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');

const answer = answered(res);
expect(answer.status).toBe(200);
expect(answer.body.data.item).toMatchObject({ label: 'Customer' });
});
});
30 changes: 29 additions & 1 deletion packages/runtime/src/domains/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading