Repository navigation
Commit df1b275
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.
> 1 parent f6189a4 commit df1b275
7 files changed
Lines changed: 453 additions & 25 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
Lines changed: 20 additions & 2 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
311 | 311 | | |
312 | 312 | | |
313 | 313 | | |
314 | | - | |
315 | | - | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
316 | 334 | | |
317 | 335 | | |
318 | 336 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2630 | 2630 | | |
2631 | 2631 | | |
2632 | 2632 | | |
| 2633 | + | |
| 2634 | + | |
| 2635 | + | |
| 2636 | + | |
| 2637 | + | |
| 2638 | + | |
| 2639 | + | |
| 2640 | + | |
| 2641 | + | |
| 2642 | + | |
| 2643 | + | |
| 2644 | + | |
| 2645 | + | |
| 2646 | + | |
| 2647 | + | |
| 2648 | + | |
| 2649 | + | |
| 2650 | + | |
| 2651 | + | |
| 2652 | + | |
| 2653 | + | |
| 2654 | + | |
| 2655 | + | |
| 2656 | + | |
| 2657 | + | |
| 2658 | + | |
| 2659 | + | |
| 2660 | + | |
| 2661 | + | |
| 2662 | + | |
| 2663 | + | |
| 2664 | + | |
| 2665 | + | |
| 2666 | + | |
| 2667 | + | |
| 2668 | + | |
| 2669 | + | |
| 2670 | + | |
| 2671 | + | |
| 2672 | + | |
| 2673 | + | |
| 2674 | + | |
| 2675 | + | |
| 2676 | + | |
| 2677 | + | |
| 2678 | + | |
| 2679 | + | |
| 2680 | + | |
| 2681 | + | |
| 2682 | + | |
| 2683 | + | |
| 2684 | + | |
| 2685 | + | |
| 2686 | + | |
| 2687 | + | |
| 2688 | + | |
| 2689 | + | |
| 2690 | + | |
| 2691 | + | |
| 2692 | + | |
| 2693 | + | |
| 2694 | + | |
| 2695 | + | |
| 2696 | + | |
2633 | 2697 | | |
2634 | 2698 | | |
2635 | 2699 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
492 | 492 | | |
493 | 493 | | |
494 | 494 | | |
495 | | - | |
| 495 | + | |
496 | 496 | | |
497 | 497 | | |
498 | 498 | | |
499 | | - | |
500 | | - | |
501 | | - | |
502 | | - | |
503 | | - | |
504 | | - | |
| 499 | + | |
| 500 | + | |
| 501 | + | |
| 502 | + | |
| 503 | + | |
| 504 | + | |
| 505 | + | |
| 506 | + | |
| 507 | + | |
| 508 | + | |
| 509 | + | |
| 510 | + | |
| 511 | + | |
| 512 | + | |
| 513 | + | |
505 | 514 | | |
506 | 515 | | |
507 | 516 | | |
508 | 517 | | |
509 | 518 | | |
510 | 519 | | |
511 | 520 | | |
| 521 | + | |
512 | 522 | | |
513 | 523 | | |
514 | | - | |
| 524 | + | |
| 525 | + | |
515 | 526 | | |
516 | 527 | | |
| 528 | + | |
| 529 | + | |
| 530 | + | |
| 531 | + | |
| 532 | + | |
517 | 533 | | |
518 | 534 | | |
519 | 535 | | |
| |||
0 commit comments