Skip to content

Commit df1b275

Browse files
fix(rest): GET /meta/:type/:name answers absence in one envelope, whichever arm produced it (#18691)
Fixes #18402 Clause-②: no — re-declared from the measured diff, not inherited from the claim. The contract surface (`packages/spec`) is not in this diff; no authorable key, no closed-set member, no published export, no registry entry moves. The claim's arm is not carried: see **What moves for consumers**. ## The direction question, answered by measurement The card fences this off: *"matching the flat shape would break the byte-identity between the absent and the unpublished answer"*, and *"converging the thrown side has repo-wide blast radius"*. Both premises were measured before anything was changed. **Premise 2 — blast radius: CONFIRMED.** `sendDeclaredFault` has **4** emission sites (all in `rest-server.ts`: two on `GET /meta/book/:name/tree`, two on this route's ADR-0046 audience gate) plus **1** internal caller (`sendFieldVisibilityFault`). But the flat dialect is not its property — it comes from `resolveErrorResponse`, shared with `sendThrownError` (7 sites) and `handleRouteError` (**37** sites). Consumers reading the flat `code`: **496** assertions across the repo's test files, plus live readers in `plugin-auth` (`e?.body?.code`, 2 sites), `rest-server.ts:11935`, and 9 internal reads in `error-response.ts`. Converging that door is repo-wide, exactly as the card says. **Premise 1 — byte-identity: PRESERVED, and measured byte-for-byte**, not reasoned from the code. The absent and the unpublished answer are compared as `JSON.stringify` output before and after, in `meta-item-absent-404.test.ts` §2 and §5. They were identical at `551139bb7` and are identical now. **The STOP condition did not fire.** A self-consistent fix exists that pulls code *back* to the ADR-0112 nested envelope rather than moving a declared surface outward, and it is bounded to this one handler. ## The three-dialect table, re-derived on `origin/main` at `551139bb7` Not copied from the card. Every refusal arm of this handler driven, wire bytes dumped: | arm | status | body | `body.error.code` | |:--|--:|:--|:--| | absent name, uncached | 404 | `{"error":{"code":"RESOURCE_NOT_FOUND","message":"Metadata item not found or access denied."}}` | ✅ | | unpublished app | 404 | *(byte-identical to the row above)* | ✅ | | app permission denied | 403 | `{"success":false,"error":{"code":"PERMISSION_DENIED","message":"…"}}` | ✅ | | book audience, authed non-holder | 403 | `{"error":"This documentation is limited to holders…","code":"PERMISSION_DENIED"}` | ❌ | | book audience, anonymous | 401 | `{"error":"This documentation requires sign-in","code":"UNAUTHENTICATED"}` | ❌ | | **cached-arm miss** | 404 | `{"error":"Metadata item view/no_such_view not found","code":"RESOURCE_NOT_FOUND"}` | ❌ | | **uncached, producer throws** | 404 | `{"error":"Metadata item object/acct not found","code":"RESOURCE_NOT_FOUND"}` | ❌ | | store outage | 503 | `{"error":"Internal server error","code":"SERVICE_UNAVAILABLE"}` | ❌ | | repeated query param | 400 | `{"error":{"code":"VALIDATION_ERROR","message":"…"}}` | ✅ | ⭐ Rows 1/2 against rows 6/7 are the severe half the card names: **the same absence, the same status, the same code, two envelopes** — and which one a caller gets is decided by `metadata.enableCache` (default **true**) and by which protocol implementation is mounted. Neither is visible to the caller. That is the #7035 failure class. ## What this PR changes The catch block of `GET /meta/:type/:name` routes a bare `404 RESOURCE_NOT_FOUND` to `sendMetaItemAbsent` — the route's existing single absence emitter — instead of to the classification door. Rows 6 and 7 become byte-identical to rows 1 and 2. Nothing else on the table moves. The recogniser (`thrownAnswerIsBareNotFound`) **asks the classification door** what it would have answered rather than re-reading the error, so the fork and the `handleRouteError` it forks away from cannot drift about what a caught value means. ⭐ This **strengthens** ADR-0045 §3 rather than merely preserving it. The unpublished app 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 `Metadata item TYPE/NAME not found` prose where the emitter says one fixed sentence that names nothing. ## ⛔ Two narrowings that were measured, not assumed **It is not "every 404 is absence."** The first draft of this change was exactly that rule, and the repo falsified it: `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. It is pinned byte-for-byte in `rest-expected-error-logging.test.ts` and `rest-4xx-message-truncation.test.ts`. Folding it in would have told a designer the object does not exist: #5532's flattening, reintroduced by the repair for a sibling of it. Same reasoning excludes a producer-declared code the ADR-0112 ledger does not know — that spelling lives in `declaredCode`, the open author-authored channel the ADR declares. A second falsification, also by measurement: a producer declaring a 404 and **no** code does not get `RESOURCE_NOT_FOUND` derived into its body. `thrownCodeFields` answers `{}` — ADR-0112's rule that nothing is invented for the half the producer did not name — so that arm reads false and keeps the shape it had. Folding it in would mean inventing the member the ADR declines to invent. **It does not converge the flat dialect itself.** That envelope POSITION is the live ratchet **#9559** owns repo-wide (`check:route-envelope` pins `rest-server.ts` at `stringError 44 / siblingCode 69`, ratchet `#9559 (option 1: convert onto the shared sendOk/sendError)`). Converting two of `sendDeclaredFault`'s four emissions here would mint a new divergence: the same audience refusal answering two shapes depending on whether `/meta/:type/:name` or `/meta/book/:name/tree` served it. Same for the `success` flag — the nested-with-no-`success` shape is already a named, ratcheted row covering `rest-server.ts`, `query-allowlist.ts` and `query-multiplicity.ts`. ## Evidence **Reverse verification** — the two source files reverted to `551139bb7` (mutation proven on disk by blob hash `6e37390…` / `91e3cf9…`, not by exit code), the pins re-run, restore re-verified by blob hash against `HEAD` and `git diff HEAD` empty: | leg | result | |:--|:--| | fix reverted | **5 failed** / 19 passed — the new and updated assertions, and only those | | restored | 44 passed / 0 failed across the four affected files | The 19 that stay green under ablation are the controls: §2's byte-identity, the #8013 403 partition, and the `NO_DRAFT` pins all pass either way, so the 5 reds are the change and not the harness. **Suites** (`bash scripts/pm/os-verify-lock.sh`, verdict read from the wrapper's own `VERDICT command-exit` line): - `pnpm --filter @objectstack/rest typecheck && pnpm --filter @objectstack/rest test` — `VERDICT command-exit 0`; **193 files / 3234 passed / 1 skipped**; `check:test-typecheck: OK — 0 file(s) / 0 error(s)`. **Docs-drift rider.** Predicate stated before reading: *a hand-written doc shows this route's absence refusal in the flat shape, or names `body.code` as its accessor*. Swept by symbol (`sendMetaItemAbsent`, `getMetaItemCached`, `metadataItemNotFoundError`, `RESOURCE_NOT_FOUND`, the route pattern) and by input shape (the flat-body JSON literal, the accessor prose). **NOT FALSIFIED** — `content/docs/api/metadata-api.mdx`, the one hand-written page documenting this route, documents no refusal body at all; `wire-format.mdx` already describes `/api/v1/meta/*` as answering the nested declared envelope, which this change moves the REST door *toward*. Controls both directions: the sweep finds the flat-body literal in `api/index.mdx` and the accessor prose in `wire-format.mdx` (positive, 2), and returns nothing for a nonsense token (negative). ## What moves for consumers A caller that branched on `body.code` for this route's **absence** reads `body.error.code` now. Every other refusal on this route (400, 401, 403, `NO_DRAFT`'s 404, 503) is byte-identical to before. > ⚠️ **CORRECTED after this body was first written — the original claim was falsified by my own later measurement, and is left visible rather than deleted.** This paragraph first read: *“No caller could have had a working dependency on the flat shape here … and the default deployment's uncached arm answered the nested shape all along. That is why this is declared a `patch` fix and not a narrowing.”* > > **That is false for every type that does not bypass the cache.** `metadata.enableCache` defaults to `true`, so `object`, `view`, `flow`, `page` and the rest took the **cached** arm — which threw, and therefore answered the **flat** shape. The nested shape was the minority path (`app`, `dashboard`, `doc`, `book`, and the three query flags), not the default. The measurement that falsified it: the `Dogfood Regression Gate` went red on `showcase-anonymous-deny-surfaces.dogfood.test.ts`, whose pin on `GET /meta/object/:name` was reading the flat `body.code` against a really booted showcase app that declares no `enableCache`. > > ⇒ **This change moves the DEFAULT wire answer for non-`app` types**, which is a larger consumer impact than the original sentence admitted — and it understated it in the author's favour, the one direction an inaccuracy must not run. The changeset is accordingly `minor` with a **BREAKING** banner and an ADR-0087 disposition, not `patch`; the non-determinism reasoning holds only ACROSS deployments, while within a single deployment `enableCache` is fixed and the flat shape was stable and dependable. ## Acceptance notes - **Noted, not filed — the `success` flag split on this handler.** `sendMetaItemAbsent` emits `{error:{…}}` and the app-permission 403 emits `{success:false,error:{…}}`; `BaseResponseSchema` requires `success`. Already a named, ratcheted row under **#9559**, which is the carrier that will touch this file. Not a second card. - **Noted, not filed — residual enumeration surface for a third-party protocol.** A protocol that throws a *bespoke* 404 code on an `app` miss would keep the flat body while the unpublished app answers the emitter's, so the pair would differ. The in-repo `metadata-protocol` never throws for `app` (it resolves item-less), so this is unreachable today, and closing it would require destroying `declaredCode` — which ADR-0112 declares. Carrier: **#9559**. - **NOT MEASURED — the field-visibility 503 arm.** The measurement harness's masker override did not reach `ObjectSchemaMaskEvaluationError`, so that row answered `500 INTERNAL_ERROR` in the rig rather than the 503 the code declares. It is outside this diff either way — `sendFieldVisibilityFault` is untouched — and is reported as unmeasured rather than as a reading. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f6189a4 commit df1b275

7 files changed

Lines changed: 453 additions & 25 deletions
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
"@objectstack/rest": minor
3+
---
4+
5+
fix(rest)!: `GET /meta/:type/:name` answers absence in ONE envelope, whichever arm produced it (#18402)
6+
7+
<!-- adr-0087: not-required (no-migration-prescription) nothing an author writes is retired, renamed or given a new meaning here: no metadata key, no spec schema, no `packages/spec` file is in this diff, and no stored `sys_metadata` document changes shape or content. The only thing that moves is the WIRE BODY of one REST refusal — the absence answer of a single read route — which is not an ADR-0087 surface at all, so `objectstack migrate meta` has nothing it could rewrite and there is no registry entry for this to be missing. Judged against this diff's own facts: the six changed files are `packages/rest/src/{rest-server,error-response}.ts`, three `packages/rest` pin tests and one `packages/qa/dogfood` pin test. -->
8+
9+
Clause-②: no
10+
11+
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.
12+
13+
## What was wrong
14+
15+
#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**:
16+
17+
- `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.
18+
- 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.
19+
20+
Re-measured on `origin/main` at `551139bb7` rather than copied from the report — the same absent `view`, driven through both arms:
21+
22+
| arm | status | body |
23+
|:--|--:|:--|
24+
| uncached, item-less return | 404 | `{"error":{"code":"RESOURCE_NOT_FOUND","message":"Metadata item not found or access denied."}}` |
25+
| cached, producer throws | 404 | `{"error":"Metadata item view/no_such_view not found","code":"RESOURCE_NOT_FOUND"}` |
26+
27+
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.
28+
29+
## What it does now
30+
31+
Both arms reach `sendMetaItemAbsent`. The route's absence answer is one body:
32+
33+
```
34+
404 {"error":{"code":"RESOURCE_NOT_FOUND","message":"Metadata item not found or access denied."}}
35+
```
36+
37+
⭐ 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.
38+
39+
## **BREAKING** — the default wire answer moves for non-`app` types
40+
41+
**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).
42+
43+
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.
44+
45+
Only `app` (and `dashboard`, `doc`, `book`, `?state=draft`, `?preview=draft`, `?package=`) bypassed the cache and already answered the nested shape.
46+
47+
**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.
48+
49+
## ⛔ What it deliberately does NOT do
50+
51+
- **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.
52+
- **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.

‎packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -311,8 +311,26 @@ describe('showcase: anonymous posture is uniform across surfaces (#2567)', () =>
311311
}
312312
const r = await stack.apiAs(adminToken, 'GET', `/meta/object/${META_PROBE_OBJECT}`);
313313
expect(r.status, 'the object the anonymous PUT tried to author must not exist').toBe(404);
314-
const body = (await r.json()) as Record<string, unknown>;
315-
expect(body.code).toBe('RESOURCE_NOT_FOUND');
314+
const body = (await r.json()) as { error?: { code?: string } };
315+
// [#18402] ENVELOPE, not semantics. The claim this case makes — the
316+
// anonymous PUT left nothing behind, so the object is absent — is carried
317+
// by the `404` above and is unchanged; only where the code is READ moved.
318+
// `GET /meta/:type/:name` used to answer absence in two envelopes and
319+
// `metadata.enableCache` picked one, so this line read the FLAT `body.code`
320+
// and the route's own item-less arm answered the nested one. Both arms now
321+
// reach the single absence emitter, and this is the ADR-0112 accessor #8013
322+
// settled on.
323+
//
324+
// ⭐ Worth recording where this file records it: the showcase declares no
325+
// `enableCache`, so it runs the DEFAULT `true` and `object` takes the
326+
// CACHED arm. This case is therefore the measurement that the flat dialect
327+
// was the answer a default deployment really shipped for a non-`app` type —
328+
// not the minority path.
329+
//
330+
// ⛔ Read in its own shape, with no `??` chain across the two shells — the
331+
// #5632 rule this file already enforces for the 401 bodies and for the
332+
// `/actions` 404 below.
333+
expect(body.error?.code).toBe('RESOURCE_NOT_FOUND');
316334
});
317335

318336
it('[#12176 D3] the retired compound save routes NOWHERE — 404 for everyone, not a 401', async () => {

‎packages/rest/src/error-response.ts‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2630,6 +2630,70 @@ export function logUnexpectedRouteError(error: any, resolved: { status: number;
26302630
logWithheldServerFault(error, resolved);
26312631
}
26322632

2633+
/**
2634+
* [#18402] Would the classification door answer this caught value with a
2635+
* BARE `404 RESOURCE_NOT_FOUND` — no code the producer chose, nothing else
2636+
* riding along?
2637+
*
2638+
* ## Why a predicate rather than a second reading of the error
2639+
*
2640+
* A handler that owns ONE absence answer has to recognise the absences its
2641+
* producers THROW, and the tempting spelling — `error?.status === 404 &&
2642+
* error?.code === 'RESOURCE_NOT_FOUND'` — is a second opinion about what a
2643+
* caught value means. It disagrees with this door on every shape the door
2644+
* classifies rather than reads: a producer that declares a status and no code,
2645+
* an unregistered spelling that {@link thrownCodeFields} demotes to
2646+
* `declaredCode` while deriving `code` from the status, a structured arm that
2647+
* owns its own envelope. Each disagreement is one arm of one route quietly
2648+
* answering a different body again — the exact class the caller was fixing.
2649+
*
2650+
* So this ASKS the door. `resolveErrorResponse` is the function that would
2651+
* have rendered the value one line later; reading its verdict means the
2652+
* handler's fork and the fallback it forks away from can never drift apart.
2653+
* The function is pure and this runs on an error path, so the second
2654+
* classification pass costs nothing worth naming — the same argument
2655+
* {@link resolveErrorResponse} already makes for its own `mapDataError`
2656+
* re-entry.
2657+
*
2658+
* ## ⛔ Why it is NOT "the status is 404"
2659+
*
2660+
* MEASURED, and the measurement is the reason this function has three
2661+
* conditions instead of one. `404` on a metadata route is not a synonym for
2662+
* "you get nothing": `metadata-protocol` throws `{ code: 'NO_DRAFT', status:
2663+
* 404 }` from the Studio designer's draft probe — pinned byte-for-byte in
2664+
* `rest-expected-error-logging.test.ts` and `rest-4xx-message-truncation.test.ts`
2665+
* — and that refusal says the ITEM is there and its DRAFT is not. Folding it
2666+
* into an absence would tell a designer the object does not exist while it
2667+
* plainly does: the #5532 flattening, one pair over, minted by the repair for
2668+
* a sibling of it.
2669+
*
2670+
* So the question is asked about the ANSWER, not the status:
2671+
*
2672+
* - `status` is 404, and
2673+
* - `code` is `RESOURCE_NOT_FOUND`, i.e. the producer named that member. ⚠️
2674+
* MEASURED: a producer that declares a 404 and NO code at all does not get
2675+
* one derived into its BODY — {@link thrownCodeFields} answers `{}`,
2676+
* ADR-0112's rule that nothing is invented for the half the producer did
2677+
* not name — so that answer is false here and keeps the shape it had.
2678+
* Folding it in would mean inventing the member the ADR declines to
2679+
* invent, and
2680+
* - no `declaredCode` sits beside it. Presence MEANS demotion (see
2681+
* `ApiErrorSchema`): the producer spelled a code the ledger does not know,
2682+
* and ADR-0112 keeps that spelling as the open, author-authored channel.
2683+
* Converting such an answer would delete the one field it exists to carry.
2684+
*
2685+
* ⚠️ This predicate does NOT decide what a route answers — it only recognises
2686+
* an answer. The 503 an unreadable metadata store throws (#5532) resolves to
2687+
* 503 and is false here, which is the distinction that must never be
2688+
* flattened.
2689+
*/
2690+
export function thrownAnswerIsBareNotFound(error: any, object?: string): boolean {
2691+
const resolved = resolveErrorResponse(error, object);
2692+
return resolved.status === 404
2693+
&& resolved.body?.code === 'RESOURCE_NOT_FOUND'
2694+
&& resolved.body?.declaredCode === undefined;
2695+
}
2696+
26332697
/**
26342698
* The single door a route catch block should use: resolve the response once,
26352699
* log it only if it is a real fault, then send it. Wire behaviour is identical

‎packages/rest/src/meta-app-publish-gate.test.ts‎

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -492,28 +492,44 @@ describe('#8013 — by-name: a permission denial is REPORTED, absence still is n
492492
expect(absent.statusCode).toBe(404);
493493
});
494494

495-
it('criterion 3: …and the REJECTING producer shape reaches the same status and code', async () => {
495+
it('criterion 3: …and the REJECTING producer shape reaches the same BODY, not merely the same code', async () => {
496496
// The other producer shape this door must survive: a protocol
497497
// implementation that REJECTS with a declared `RESOURCE_NOT_FOUND` /
498498
// `status: 404` (`rest-meta-outage-vs-miss.test.ts` pins the rendering).
499-
// ⚠️ Its body is the FLAT `{ error: '<message>', code }` that
500-
// `resolveErrorResponse`'s declared-status passthrough produces, not the
501-
// nested ADR-0112 envelope the in-route refusals emit — so this case
502-
// asserts `body.code`, and the case above asserts `body.error.code`, on
503-
// purpose. Both reach this route, so the criterion is stated against
504-
// both rather than against one stub's.
499+
//
500+
// [#18402] This case used to read `missing.body?.code` while criterion
501+
// 2 above read `body.error.code` — "on purpose", said the note that
502+
// stood here, because the rejecting shape rendered the FLAT
503+
// `{ error: '<message>', code }` and the resolving shape the nested
504+
// ADR-0112 envelope. ⚠️ That IS the finding: one door, one absence, two
505+
// envelopes, and which one a caller got depended on the protocol
506+
// implementation and on `metadata.enableCache` — neither visible to the
507+
// caller. Both arms now reach this route's single absence emitter.
508+
//
509+
// ⭐ So the criterion is STRENGTHENED rather than translated: the
510+
// rejecting shape is compared against the UNPUBLISHED app as a whole
511+
// body, which is what ADR-0045 §3 actually asks. A status-and-code
512+
// assertion could never have carried that — the flat and the nested
513+
// body agreed on both while differing everywhere a client looks.
505514
const { rest, protocol } = setup([], GATED_APPS);
506515
protocol.getMetaItem = vi.fn().mockRejectedValue(Object.assign(
507516
new Error('Metadata item app/no_such_app not found'),
508517
{ code: 'RESOURCE_NOT_FOUND', status: 404 },
509518
));
510519

511520
const missing = await getItem(rest, 'no_such_app');
521+
const unpublished = await getItem(setup(['manage_users'], GATED_APPS).rest, 'production_management');
512522

513523
expect(missing.statusCode).toBe(404);
514-
expect(missing.body?.code).toBe('RESOURCE_NOT_FOUND');
524+
expect(missing.body?.error?.code).toBe('RESOURCE_NOT_FOUND');
525+
expect(missing.body?.code).toBeUndefined();
515526
expect(missing.statusCode).not.toBe(403);
516527
expect(JSON.stringify(missing.body ?? {})).not.toContain('PERMISSION_DENIED');
528+
529+
// The producer's sentence named the type and the name; the emitter's
530+
// names nothing, and the unpublished app answers the emitter's.
531+
expect(JSON.stringify(missing.body)).toBe(JSON.stringify(unpublished.body));
532+
expect(JSON.stringify(missing.body ?? {})).not.toContain('no_such_app');
517533
});
518534

519535
it('criterion 4: the LIST route is untouched — the app is absent, not flagged', async () => {

0 commit comments

Comments
 (0)