Skip to content
Merged
13 changes: 13 additions & 0 deletions .changeset/20507-layers-absent-name-oracle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@objectstack/rest": patch
---

**The layered view, `GET /api/v1/meta/:type/:name/layers` and the deprecated `?layers=true` flag, now answers a name with nothing behind it with the plain read's `404 RESOURCE_NOT_FOUND`, the answer it already gave a member for an unpublished app.** Before this release, a name with no layer behind it answered `200` with `code`, `overlay` and `effective` all `null`. A member asking for an unpublished app got `404`, so the difference between the two answers told the member which unpublished apps exist. ADR-0045 §3 declares a hidden app externally unobservable on every surface, and the plain read already kept that promise. This follows triage's grade on #20507.

Clause-②: no

- **What changed:** `createMetaLayeredAnswer`, the one chain both transports call after the store read (`RestServer` and the runtime dispatcher's `/meta` domain), answers a layered read with no layer present as the name's absence, before the per-caller gate runs. Each transport writes that absence in its own envelope, the one it already uses for an unpublished app: `RestServer`'s nested `{ error: { code: "RESOURCE_NOT_FOUND", message } }`, and the dispatcher's `404` error envelope. The flag's `Deprecation` and `Link` headers still ride that answer.
- **Who it applies to:** every caller. The plain read answers an absent name `404` whoever asks, and so does the layered view now. A builder (`studio.access` or `setup.access`) is still served an unpublished app on both spellings. A `?package=` scope that leaves no layer behind the name is that name's absence too.
- **Unchanged:** a name with any layer behind it is judged and served exactly as before. An item whose code layer is scoped away by `?package=` but whose overlay row answers is still served, with `code: null`.

A client that read `/layers` for a name that has never been published, and took a `200` with every layer `null` as "not saved yet", now receives `404`. Treat that `404` as the same answer. Studio's metadata client already maps a `404` from this route to every layer `null`, so the designer's "open an item that exists only as a draft" path is unchanged.
9 changes: 9 additions & 0 deletions packages/rest/src/execctx-consumer-census.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,15 @@ function metaProtocol(doc: any) {
if (k === 'getMetaItems') return vi.fn(async () => [doc]);
if (k === 'getMetaItem') return vi.fn(async () => ({ type: doc.type, name: doc.name, item: doc }));
if (k === 'getMetaItemCached') return undefined;
// [#20507] The layered read in its own shape, `doc` at the code
// layer. The generic answer below carries no layer at all, which
// the layered chain answers as the name's absence (404), so the
// `/layers` row would stop reaching the answer it measures.
if (k === 'getMetaItemLayered') {
return vi.fn(async () => ({
type: doc.type, name: doc.name, code: doc, overlay: null, overlayScope: null, effective: doc,
}));
}
return vi.fn(async () => ({ ok: true, rows: [], data: [], items: [doc], total: 1 }));
},
});
Expand Down
39 changes: 26 additions & 13 deletions packages/rest/src/meta-item-read-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2516,8 +2516,10 @@ export interface MetaLayeredRequest {
* - `serve` — send `layered`, under `cacheControl` when it owes one
* ({@link META_UNDETERMINED_CACHE_CONTROL}, ADR-0106 D6 tier 2). No `Vary`:
* the layered view is not translated;
* - `refuse` — the gate's {@link MetaItemReadRefusal} for a layer the caller
* may not read (`absent` included: an unpublished app to a non-builder);
* - `refuse` — `absent` for a name with no layer behind it (#20507), or the
* gate's {@link MetaItemReadRefusal} for a layer the caller may not read
* (`absent` included: an unpublished app to a non-builder), one answer for
* both;
* - `mask-fault` — a layer's projection left the schema with no field (D6),
* which the transport answers as its field-visibility fault.
*/
Expand Down Expand Up @@ -2558,9 +2560,18 @@ export type MetaLayeredAnswer =
* what is STORED at each layer, so locale-collapsing it (or serving it from the
* published-value cache) would misreport the thing being diagnosed.
*
* ## The steps, in `RestServer`'s order (unchanged)
*
* 1. [#20156] THE per-caller gate on EVERY present layer, `effective` first
* ## The steps, in `RestServer`'s order
*
* 1. [#20507] Absence — an answer with no layer present is `absent`, judged
* BEFORE the gate, exactly as the plain read judges an envelope with no
* `item` ({@link createMetaItemAnswer} step 1). ADR-0045 §3: "Hidden"
* means externally unobservable. The gate answers an unpublished app to a
* non-builder as `absent`, so a name with nothing behind it must get that
* same answer. The protocol answered it `200` with every layer `null`, and
* that difference told a member which unpublished apps exist. The
* protocol's `effective` is never null while another layer is present, so
* this is the name resolving to nothing at all, for every caller.
* 2. [#20156] THE per-caller gate on EVERY present layer, `effective` first
* (it is what the plain read serves, so its refusal is the plain read's
* own), under {@link STORED_VERSION_DOOR_POLICY}: per-caller arms only
* (these are STORED versions, which Studio's designer loads and saves
Expand All @@ -2571,17 +2582,15 @@ export type MetaLayeredAnswer =
* {@link MetaReadGateCaller.mayWriteItem} — its own save door's admission;
* absent reads as `false` (every caller pruned). Every layer is judged
* before any is served, so a refusal sends nothing of the others.
* 2. [ADR-0106 D5(4)] The mask on every layer — each is a full object schema —
* 3. [ADR-0106 D5(4)] The mask on every layer — each is a full object schema —
* through {@link projectMetaObjectSchema} under the posture resolved before
* the read, and the `private, no-store` an undetermined posture owes.
*
* The protocol's answer is never mutated. A layer the gate or the mask leaves as
* it was is served as it was, and every other key of the answer
* (`overlayScope`, `_diagnostics`, the ADR-0010 protection envelope) rides
* through untouched. With nothing behind the name the protocol answers every
* layer `null`, and so does this chain: no layer is present to judge. A gate
* input that cannot be read REJECTS: the transport answers that fault, ⛔ never
* a layered view with a layer missing.
* through untouched. A gate input that cannot be read REJECTS: the transport
* answers that fault, ⛔ never a layered view with a layer missing.
*/
export function createMetaLayeredAnswer(
sources: MetaItemReadGateSources,
Expand All @@ -2590,11 +2599,15 @@ export function createMetaLayeredAnswer(
const { metaType, name, maskPosture } = request;
return async (raw) => {
const layered = raw as Record<string, unknown> | null | undefined;
const present = META_ITEM_LAYERS.filter((layer) => layered?.[layer] != null);

// 1. [#20507] Absence — ADR-0045 §3: a name with no layer behind it is
// the plain read's absence, answered before the gate.
if (present.length === 0) return { kind: 'refuse', refusal: { reason: 'absent' } };

// 1. [#20156] THE per-caller gate, on every present layer.
// 2. [#20156] THE per-caller gate, on every present layer.
const served = new Map<string, unknown>();
{
const present = META_ITEM_LAYERS.filter((layer) => layered?.[layer] != null);
const judge = createMetaItemReadGate(
sources, metaType, name, present.map((layer) => layered![layer]), STORED_VERSION_DOOR_POLICY,
);
Expand All @@ -2605,7 +2618,7 @@ export function createMetaLayeredAnswer(
}
}

// 2. [ADR-0106 D5(4)] The mask, on every layer.
// 3. [ADR-0106 D5(4)] The mask, on every layer.
let cacheControl: typeof META_UNDETERMINED_CACHE_CONTROL | undefined;
for (const layer of META_ITEM_MASKED_LAYERS) {
const document = served.has(layer) ? served.get(layer) : layered?.[layer];
Expand Down
71 changes: 69 additions & 2 deletions packages/runtime/src/domains/meta-list-projection-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1022,8 +1022,15 @@ describe('[#20478] the layered view: the reference moves — each spelling and p
});

it('?package= scopes the code layer, and the object mask projects every layer', async () => {
const scoped = await bootRest('non-holder').read('/meta/app/payroll/layers', { package: 'crm' });
expect({ status: scoped.status, code: scoped.body?.code, effective: scoped.body?.effective }).toEqual({ status: 200, code: null, effective: null });
// [#20507] Scoped to a package that ships no `crm`, the code layer is
// gone and the overlay row still answers for the name.
const scoped = await bootRest('non-holder').read('/meta/app/crm/layers', { package: 'elsewhere' });
expect({ status: scoped.status, code: scoped.body?.code, effective: scoped.body?.effective?.label })
.toEqual({ status: 200, code: null, effective: 'CRM (overlay)' });
// …and a scope that leaves NO layer behind the name is its absence, the
// plain read's 404 — never a 200 with every layer null.
const emptied = await bootRest('non-holder').read('/meta/app/payroll/layers', { package: 'crm' });
expect({ status: emptied.status, code: emptied.code }).toEqual({ status: 404, code: 'RESOURCE_NOT_FOUND' });
const invoice = await bootRest('non-holder').read('/meta/object/invoice/layers');
for (const layer of ['code', 'overlay', 'effective']) expect(Object.keys(invoice.body?.[layer]?.fields ?? {}), layer).toEqual(['amount']);
});
Expand Down Expand Up @@ -1058,6 +1065,66 @@ describe('[#20478] the layered view: the controls', () => {
});
});

/**
* [#20507] ADR-0045 §3: "Hidden" means externally unobservable, consistently
* across every surface. A member asking the layered view for an unpublished app
* is answered its absence (the gate withholds it); a member asking for a name
* with nothing behind it used to be answered `200` with every layer `null` —
* so the two answers told the member which unpublished apps exist. The shared
* chain (`createMetaLayeredAnswer`) now answers a name with no layer present as
* the plain read answers it, judged before the gate, once for both spellings
* and both transports.
*/
describe('[#20507] the layered view: a name with nothing behind it answers what an unpublished app answers — both spellings, both transports', () => {
const TRANSPORTS = { RestServer: bootRest, dispatcher: bootDispatcher } as const;
const SPELLINGS: Record<string, (name: string) => [string, Record<string, string>]> = {
'/layers': (name) => [`/meta/app/${name}/layers`, {}],
'?layers=true': (name) => [`/meta/app/${name}`, { layers: 'true' }],
};
// Everything an answer carries. The flag's `Link` names the caller's OWN
// request path, so the name is masked out of it: it says nothing about the item.
const whole = (a: Answer, name: string) => ({
status: a.status, code: a.code, body: a.body, vary: a.vary, cacheControl: a.cacheControl,
deprecation: a.deprecation, link: a.link?.replace(name, 'NAME'),
});
const nav = (doc: any): string[] => (doc?.navigation ?? []).map((e: any) => e.id);

for (const [transport, boot] of Object.entries(TRANSPORTS)) {
for (const [spelling, at] of Object.entries(SPELLINGS)) {
it(`${spelling} on ${transport}: as a member, an absent name and an unpublished app answer the same status and body`, async () => {
const unpublished = await boot('non-holder').read(...at('launchpad'));
const absent = await boot('non-holder').read(...at('no_such_app'));
expect({ status: unpublished.status, code: unpublished.code }).toEqual({ status: 404, code: 'RESOURCE_NOT_FOUND' });
expect(whole(absent, 'no_such_app')).toEqual(whole(unpublished, 'launchpad'));
});

it(`${spelling} on ${transport}: control — a published app is still served to the member, every layer pruned`, async () => {
const published = await boot('non-holder').read(...at('crm'));
const layered = served(published, transport === 'dispatcher' ? 'dispatcher' : 'rest') as any;
expect(published.status).toBe(200);
for (const layer of ['code', 'overlay', 'effective']) {
expect(nav(layered?.[layer]), layer).toEqual(['nav_leads', 'nav_org_directory']);
}
});
}
}

it('whoever asks, an absent name is the same 404; an unpublished app is still served to a builder', async () => {
for (const [transport, boot] of Object.entries(TRANSPORTS)) {
for (const [spelling, at] of Object.entries(SPELLINGS)) {
for (const who of ITEM_CALLERS) {
const absent = await boot(who).read(...at('no_such_app'));
expect({ status: absent.status, code: absent.code }, `${spelling} ${transport} ${who}`)
.toEqual({ status: 404, code: 'RESOURCE_NOT_FOUND' });
}
// ADR-0045 §3: the builder (`studio.access`) still receives the unpublished app.
const built = await boot('builder').read(...at('launchpad'));
expect(built.status, `${spelling} ${transport} builder launchpad`).toBe(200);
}
}
});
});

describe('[#20408] GET /meta/book/:name/tree: the dispatcher serves the route RestServer serves — every book × query parameter × caller', () => {
for (const book of TREE_CELLS) {
for (const probe of TREE_PROBES) {
Expand Down
Loading