Skip to content

Commit b43a814

Browse files
objectstack-fleet[bot]hotlongclaude
authored
fix(rest): the layered view answers an absent name with the plain read's 404, as it answers an unpublished app (#20507) (#20527)
Fixes #20507 Clause-②: no ## What this changes The layered view, `GET /meta/:type/:name/layers` and the deprecated `?layers=true` flag, answered a name with nothing behind it `200` with `code`, `overlay` and `effective` all `null`. It answered a member's read of an unpublished app `404 RESOURCE_NOT_FOUND`. The difference told a member which unpublished apps exist. ADR-0045 §3 says "Hidden" means externally unobservable, consistently across every surface, and the plain read already kept that promise. `createMetaLayeredAnswer` in `packages/rest/src/meta-item-read-gate.ts` is the one chain both transports call after the store read (`RestServer`, and the runtime dispatcher's `/meta` domain). It now answers a layered read with no layer present as `absent`, before the per-caller gate runs. That is the same place the plain read judges absence (`createMetaItemAnswer` step 1). Both transports already write `absent` in their own absence envelope, the one they use for an unpublished app. So there is one change, and no transport edit: `rest-server.ts` and `runtime/src/domains/meta.ts` are untouched. ## The four measurements the dispatch asked for **H0, the reader: nothing depends on the 200.** Measured at objectui's pinned `.objectui-sha` `dd3f7e1be3561d63267d7162f3fc0ac52e72834d`, by `git grep` over the tree at that commit. - Every wire read of the layered view goes through one method: `MetadataClient.layered` (`packages/data-objectstack/src/metadata-client.ts`, lines 1289 to 1304). It reads `/layers`. On `404` it already returns `{ code: null, overlay: null, overlayScope: null, effective: null }`, the same four null layers the old `200` carried. - There is no `?layers=` spelling in objectui's non-test source. As a control, the same pattern hits the client's own `/layers` URL at line 1301. - The client has 15 non-test call sites. All are Studio or metadata-admin authoring screens, used by callers who hold an authoring capability: - `ResourceEditPage.tsx`: 1017, 1510, 1649, 1689 and 1723. This is the metadata edit page. Its load effect skips the layered read in create mode. - `StudioDesignSurface.tsx`: 1820 (app navigation), 1949 (leaf designer), 2962 (object designer) and 4015 (flow designer). - `EmbeddedItemEditor.tsx`: 110, the parent re-read before a save. - `PermissionMatrixEditor.tsx`: 512, 841 and 885 (the Access pillar). - `PackageOwdOverviewPanel.tsx`: 155 and 249. - Each site reads `effective ?? code` and takes the draft through `getDraft`. It reads the protection verdicts defensively: `editable !== false`, `deletable !== false`, `lock && lock !== 'none'`, and `resettable ?? isArtifactItem`. So the four-null result from a `404` renders exactly as the old `200` did. `PermissionMatrixEditor.tsx` line 834 already relies on it in words: a record the server does not hold "answers the 404 shape". - No member-facing caller exists. - The SDK (`packages/client/src`, `packages/client-react/src`) has zero layered reads. As a control, `packages/client/src/index.ts` has 26 `/meta` hits. The route ledger records this route as `server-only`, consumed by objectui over plain HTTP. **H1, re-measured on `main` before the fix** (`fb194c70e5`). The new pins ran red against the unfixed chain. As a member, on both spellings and both transports, `no_such_app` answered `200` with every layer `null` (plus `lock`, `editable`, `deletable` and `resettable`). `launchpad`, the unpublished app, answered `404 RESOURCE_NOT_FOUND`. The four published-app controls stayed green. The reading: 6 failed and 4 passed. **H2, what reaches the chain and what a builder now receives.** An absent name reaches `createMetaLayeredAnswer` with every layer `null`. The protocol documents that `effective` is never null while another layer is present, so "no layer present" means the name resolves to nothing. The chain answers `{ kind: 'refuse', refusal: { reason: 'absent' } }` before any layer is judged. `RestServer` writes it with `sendMetaItemAbsent`: `404` `{ error: { code: "RESOURCE_NOT_FOUND", message } }`. The dispatcher writes it as `deps.error('Not found', 404)`. These are the bytes each transport already sends for an unpublished app. - **A builder** (`studio.access`) now receives `404 RESOURCE_NOT_FOUND` for an absent name, on both spellings and both transports. So do the holder, the member and the author. This is the plain read's absence, which does not depend on who asks. - A builder is still served the unpublished `launchpad` (`200`). **H3, ablation.** I deleted the new branch through `scripts/ablation-replace.mjs` in WRAP mode. The tool recorded: anchor count 1 to 0, blob `662d87187124` to `af0967c88051`. Under the mutation I ran the full `@objectstack/runtime` and `@objectstack/rest` suites. `@objectstack/rest` resolves from source in both suites: the runtime vitest config aliases it to `../rest/src/index.ts`. So `dist` is not on the path. - Runtime: 6 failed, 4885 passed. The 6 are exactly the absent-name pins: the four "absent name and unpublished app answer the same" cases, the "whoever asks" case, and the `?package=` emptied-scope case. The four published-app controls stayed green. - Rest: 221 files passed. - Restore: the tool's own verdict was that the blob after restore equals HEAD (`662d87187124`) and `git diff HEAD` is empty. `git status --porcelain` was empty afterwards. ## Tests The new pins are in `packages/runtime/src/domains/meta-list-projection-parity.test.ts`. That file drives `RestServer` and the dispatcher over the same fixtures: - As a member, for each of `/layers` and `?layers=true` on each transport, an absent name and an unpublished app answer the same status, code, body, `Vary`, `Cache-Control` and `Deprecation`. The flag's `Link` also matches once the requested name is masked, because it names the caller's own path. - Control: a published app is still served to the member, with every layer pruned. - Every caller gets the same `404` for an absent name, and a builder is still served the unpublished app. Two existing fixtures pinned the old answer, and I triaged them: - **The parity `?package=` case** asserted `200` with all-null layers for `payroll?package=crm`, which is exactly the answer this change removes. Its purpose, showing that `?package=` scopes the code layer, is kept by `crm?package=elsewhere`: `200`, `code: null`, and the overlay still answers. `payroll?package=crm` is now an absence pin. - **The execctx census's protocol double** (`packages/rest/src/execctx-consumer-census.test.ts`) answered `getMetaItemLayered` with a generic object that carried no layer. Its `/layers` row read `200` only because the chain served an all-absent body. The double now answers the layered read in its own shape, so the row still measures "serves on its own reading". Results on the final head `32de5e64fc` (`origin/main` merged): - `pnpm --filter @objectstack/rest exec vitest run --maxWorkers=2`: 222 files and 4239 tests passed (40 skipped). This covers both projects. - `pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2`: 289 files and 4897 tests passed (1 skipped). This covers both projects. - `pnpm --filter @objectstack/rest --filter @objectstack/runtime typecheck`: exit 0, and both packages print `check:test-typecheck: OK`. - Before the tests, the dependency closure was built with `pnpm --filter '@objectstack/runtime...' build` after the merge. ## Gates - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`, derived at `32de5e64fc`: 61 families. - 60 exited 0. - `pnpm check:dual-build-cjs-loads` exited 3, PREREQUISITE NOT MET: 36 packages have no `dist/`. That is NOT MEASURED, and a whole-repo `pnpm build` was not run on this shared host. As a narrowed probe of the one package whose `dist` this diff changes, `require('packages/rest/dist/index.cjs')` loads and exports `createMetaLayeredAnswer` as a function. The diff changes no import, export or module format. - `--ran` reconciliation: "61 derived famil(ies) accounted for — 60 run, 1 NOT-MEASURED". - `node scripts/check-issue-citations.mjs --base origin/main`, run after the merge: every citation this change adds resolves (5 of 5). - `pnpm lint` at `32de5e64fc`: exit 0, 35s. Every build and test command above ran through `scripts/pm/os-verify-lock.sh`, which printed this for each run. The block below is from the final `@objectstack/rest` run: **Declared narrowing — verification ran UNLOCKED.** `scripts/pm/os-verify-lock.sh` could not take the shared verify lock on this host: no usable `flock`. The shared verify lock is declared Linux-only (`flock` is util-linux, and a stock macOS does not ship it), so the command below was run directly, without the lock — a declared narrowing, not a silent one. No serialization guarantee held for this run, nor for any sibling agent in this container while it ran. pnpm --filter @objectstack/rest exec vitest run --maxWorkers=2 ## Acceptance notes - **Spec text drift, for the spec seat.** `GetMetaItemLayeredResponseSchema.effective` in `packages/spec/src/api/protocol.zod.ts` says its value is `null` "when the item resolves to nothing at all". Through the REST route, that case now answers `404` instead of a body. The text still describes the protocol method's in-process return, which is unchanged. It is outside this card's file surface (`packages/spec/**` is read-only here), so I have not filed or edited it. Carrier: the spec seat. - **Consumers not run.** `packages/qa/http-conformance` and `packages/qa/dogfood` were not run. By `git grep`, the conformance suite makes no `/layers` or `?layers=` HTTP read. The dogfood suites read `/layers` only for items that exist (`crm_order`, `showcase_account`). CI runs both. - **ADR anchor.** `scripts/adr-anchors/packages__rest__src__meta-item-read-gate.ts.json` already anchors ADR-0045 §3 on this file. The code comment carries the ADR id. --- _Generated by [Claude Code](https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289)_ --------- Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent fb38607 commit b43a814

4 files changed

Lines changed: 117 additions & 15 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
**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.
6+
7+
Clause-②: no
8+
9+
- **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.
10+
- **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.
11+
- **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`.
12+
13+
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.

‎packages/rest/src/execctx-consumer-census.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,15 @@ function metaProtocol(doc: any) {
163163
if (k === 'getMetaItems') return vi.fn(async () => [doc]);
164164
if (k === 'getMetaItem') return vi.fn(async () => ({ type: doc.type, name: doc.name, item: doc }));
165165
if (k === 'getMetaItemCached') return undefined;
166+
// [#20507] The layered read in its own shape, `doc` at the code
167+
// layer. The generic answer below carries no layer at all, which
168+
// the layered chain answers as the name's absence (404), so the
169+
// `/layers` row would stop reaching the answer it measures.
170+
if (k === 'getMetaItemLayered') {
171+
return vi.fn(async () => ({
172+
type: doc.type, name: doc.name, code: doc, overlay: null, overlayScope: null, effective: doc,
173+
}));
174+
}
166175
return vi.fn(async () => ({ ok: true, rows: [], data: [], items: [doc], total: 1 }));
167176
},
168177
});

‎packages/rest/src/meta-item-read-gate.ts‎

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2516,8 +2516,10 @@ export interface MetaLayeredRequest {
25162516
* - `serve` — send `layered`, under `cacheControl` when it owes one
25172517
* ({@link META_UNDETERMINED_CACHE_CONTROL}, ADR-0106 D6 tier 2). No `Vary`:
25182518
* the layered view is not translated;
2519-
* - `refuse` — the gate's {@link MetaItemReadRefusal} for a layer the caller
2520-
* may not read (`absent` included: an unpublished app to a non-builder);
2519+
* - `refuse` — `absent` for a name with no layer behind it (#20507), or the
2520+
* gate's {@link MetaItemReadRefusal} for a layer the caller may not read
2521+
* (`absent` included: an unpublished app to a non-builder), one answer for
2522+
* both;
25212523
* - `mask-fault` — a layer's projection left the schema with no field (D6),
25222524
* which the transport answers as its field-visibility fault.
25232525
*/
@@ -2558,9 +2560,18 @@ export type MetaLayeredAnswer =
25582560
* what is STORED at each layer, so locale-collapsing it (or serving it from the
25592561
* published-value cache) would misreport the thing being diagnosed.
25602562
*
2561-
* ## The steps, in `RestServer`'s order (unchanged)
2562-
*
2563-
* 1. [#20156] THE per-caller gate on EVERY present layer, `effective` first
2563+
* ## The steps, in `RestServer`'s order
2564+
*
2565+
* 1. [#20507] Absence — an answer with no layer present is `absent`, judged
2566+
* BEFORE the gate, exactly as the plain read judges an envelope with no
2567+
* `item` ({@link createMetaItemAnswer} step 1). ADR-0045 §3: "Hidden"
2568+
* means externally unobservable. The gate answers an unpublished app to a
2569+
* non-builder as `absent`, so a name with nothing behind it must get that
2570+
* same answer. The protocol answered it `200` with every layer `null`, and
2571+
* that difference told a member which unpublished apps exist. The
2572+
* protocol's `effective` is never null while another layer is present, so
2573+
* this is the name resolving to nothing at all, for every caller.
2574+
* 2. [#20156] THE per-caller gate on EVERY present layer, `effective` first
25642575
* (it is what the plain read serves, so its refusal is the plain read's
25652576
* own), under {@link STORED_VERSION_DOOR_POLICY}: per-caller arms only
25662577
* (these are STORED versions, which Studio's designer loads and saves
@@ -2571,17 +2582,15 @@ export type MetaLayeredAnswer =
25712582
* {@link MetaReadGateCaller.mayWriteItem} — its own save door's admission;
25722583
* absent reads as `false` (every caller pruned). Every layer is judged
25732584
* before any is served, so a refusal sends nothing of the others.
2574-
* 2. [ADR-0106 D5(4)] The mask on every layer — each is a full object schema —
2585+
* 3. [ADR-0106 D5(4)] The mask on every layer — each is a full object schema —
25752586
* through {@link projectMetaObjectSchema} under the posture resolved before
25762587
* the read, and the `private, no-store` an undetermined posture owes.
25772588
*
25782589
* The protocol's answer is never mutated. A layer the gate or the mask leaves as
25792590
* it was is served as it was, and every other key of the answer
25802591
* (`overlayScope`, `_diagnostics`, the ADR-0010 protection envelope) rides
2581-
* through untouched. With nothing behind the name the protocol answers every
2582-
* layer `null`, and so does this chain: no layer is present to judge. A gate
2583-
* input that cannot be read REJECTS: the transport answers that fault, ⛔ never
2584-
* a layered view with a layer missing.
2592+
* through untouched. A gate input that cannot be read REJECTS: the transport
2593+
* answers that fault, ⛔ never a layered view with a layer missing.
25852594
*/
25862595
export function createMetaLayeredAnswer(
25872596
sources: MetaItemReadGateSources,
@@ -2590,11 +2599,15 @@ export function createMetaLayeredAnswer(
25902599
const { metaType, name, maskPosture } = request;
25912600
return async (raw) => {
25922601
const layered = raw as Record<string, unknown> | null | undefined;
2602+
const present = META_ITEM_LAYERS.filter((layer) => layered?.[layer] != null);
2603+
2604+
// 1. [#20507] Absence — ADR-0045 §3: a name with no layer behind it is
2605+
// the plain read's absence, answered before the gate.
2606+
if (present.length === 0) return { kind: 'refuse', refusal: { reason: 'absent' } };
25932607

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

2608-
// 2. [ADR-0106 D5(4)] The mask, on every layer.
2621+
// 3. [ADR-0106 D5(4)] The mask, on every layer.
26092622
let cacheControl: typeof META_UNDETERMINED_CACHE_CONTROL | undefined;
26102623
for (const layer of META_ITEM_MASKED_LAYERS) {
26112624
const document = served.has(layer) ? served.get(layer) : layered?.[layer];

‎packages/runtime/src/domains/meta-list-projection-parity.test.ts‎

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1022,8 +1022,15 @@ describe('[#20478] the layered view: the reference moves — each spelling and p
10221022
});
10231023

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

1068+
/**
1069+
* [#20507] ADR-0045 §3: "Hidden" means externally unobservable, consistently
1070+
* across every surface. A member asking the layered view for an unpublished app
1071+
* is answered its absence (the gate withholds it); a member asking for a name
1072+
* with nothing behind it used to be answered `200` with every layer `null` —
1073+
* so the two answers told the member which unpublished apps exist. The shared
1074+
* chain (`createMetaLayeredAnswer`) now answers a name with no layer present as
1075+
* the plain read answers it, judged before the gate, once for both spellings
1076+
* and both transports.
1077+
*/
1078+
describe('[#20507] the layered view: a name with nothing behind it answers what an unpublished app answers — both spellings, both transports', () => {
1079+
const TRANSPORTS = { RestServer: bootRest, dispatcher: bootDispatcher } as const;
1080+
const SPELLINGS: Record<string, (name: string) => [string, Record<string, string>]> = {
1081+
'/layers': (name) => [`/meta/app/${name}/layers`, {}],
1082+
'?layers=true': (name) => [`/meta/app/${name}`, { layers: 'true' }],
1083+
};
1084+
// Everything an answer carries. The flag's `Link` names the caller's OWN
1085+
// request path, so the name is masked out of it: it says nothing about the item.
1086+
const whole = (a: Answer, name: string) => ({
1087+
status: a.status, code: a.code, body: a.body, vary: a.vary, cacheControl: a.cacheControl,
1088+
deprecation: a.deprecation, link: a.link?.replace(name, 'NAME'),
1089+
});
1090+
const nav = (doc: any): string[] => (doc?.navigation ?? []).map((e: any) => e.id);
1091+
1092+
for (const [transport, boot] of Object.entries(TRANSPORTS)) {
1093+
for (const [spelling, at] of Object.entries(SPELLINGS)) {
1094+
it(`${spelling} on ${transport}: as a member, an absent name and an unpublished app answer the same status and body`, async () => {
1095+
const unpublished = await boot('non-holder').read(...at('launchpad'));
1096+
const absent = await boot('non-holder').read(...at('no_such_app'));
1097+
expect({ status: unpublished.status, code: unpublished.code }).toEqual({ status: 404, code: 'RESOURCE_NOT_FOUND' });
1098+
expect(whole(absent, 'no_such_app')).toEqual(whole(unpublished, 'launchpad'));
1099+
});
1100+
1101+
it(`${spelling} on ${transport}: control — a published app is still served to the member, every layer pruned`, async () => {
1102+
const published = await boot('non-holder').read(...at('crm'));
1103+
const layered = served(published, transport === 'dispatcher' ? 'dispatcher' : 'rest') as any;
1104+
expect(published.status).toBe(200);
1105+
for (const layer of ['code', 'overlay', 'effective']) {
1106+
expect(nav(layered?.[layer]), layer).toEqual(['nav_leads', 'nav_org_directory']);
1107+
}
1108+
});
1109+
}
1110+
}
1111+
1112+
it('whoever asks, an absent name is the same 404; an unpublished app is still served to a builder', async () => {
1113+
for (const [transport, boot] of Object.entries(TRANSPORTS)) {
1114+
for (const [spelling, at] of Object.entries(SPELLINGS)) {
1115+
for (const who of ITEM_CALLERS) {
1116+
const absent = await boot(who).read(...at('no_such_app'));
1117+
expect({ status: absent.status, code: absent.code }, `${spelling} ${transport} ${who}`)
1118+
.toEqual({ status: 404, code: 'RESOURCE_NOT_FOUND' });
1119+
}
1120+
// ADR-0045 §3: the builder (`studio.access`) still receives the unpublished app.
1121+
const built = await boot('builder').read(...at('launchpad'));
1122+
expect(built.status, `${spelling} ${transport} builder launchpad`).toBe(200);
1123+
}
1124+
}
1125+
});
1126+
});
1127+
10611128
describe('[#20408] GET /meta/book/:name/tree: the dispatcher serves the route RestServer serves — every book × query parameter × caller', () => {
10621129
for (const book of TREE_CELLS) {
10631130
for (const probe of TREE_PROBES) {

0 commit comments

Comments
 (0)