fix(rest): GET /api/v1/meta/:type refuses a type name that names nothing, instead of serving it as an empty collection (#9488) - #9743
Conversation
) `GET /api/v1/meta/totally_invented_type` answered `200 {"items":[]}` while the write door for the same name answered `400 '<type>' is not a metadata type`, so the two doors disagreed about which type names exist. A 200-with-an-empty collection is indistinguishable from "this type exists and holds nothing". The read door now consults the UNION of the two authorities the platform already has — the static spelling contract `unrecognisedMetaTypeRefusal` (the predicate the write door consults) and the live listing `getMetaTypes` (the one `GET /meta/types` serves) — and refuses a name in neither, with the same status and the same code the write door emits. The live half is required, not decorative: #8421 rejected raising the static verdict on the read entries because live-only keys (`data`, `kind`, `package`, `policy`) and plugin types are enumerated by `GET /meta/types` while sitting outside the static contract. It fails open when the listing cannot be read. Co-Authored-By: Claude <noreply@anthropic.com>
) `meta-plural-i18n.test.ts` used `widget` precisely because it is outside every spelling map, while its `getMetaTypes` double advertised an empty type set and its `getMetaItems` double served a document for it anyway. With the list door now refusing a `:type` in neither the static contract nor the live listing, that inconsistency answered 400 before the localization under test ever ran. The listing is derived from `RAW_BY_NAME` — the same source the item double reads — and carries both spellings, because the item double answers both. No assertion in the file changes. Plus the changeset for the behaviour change. Co-Authored-By: Claude <noreply@anthropic.com>
#9488) `meta-types-create-seed.dogfood.test.ts` pinned the defect literally: an unknown type answering `200 {type, items: []}`, used as the contrast that proves `/meta/types` is not the `/meta/:type` catch-all wearing a 200. The catch-all now refuses that name, which makes the contrast sharper rather than weaker, so the test's own claim is unchanged and only the catch-all's half is restated — asserted with `code` AND `status`, so it cannot pass on a 404 that would mean the catch-all had stopped being mounted at all. Its sibling `route-ledger-live-mount-parity.dogfood.test.ts` asserts ROUTING for the same name and is untouched: the request still reaches `/meta/:type`. Co-Authored-By: Claude <noreply@anthropic.com>
Under `moduleResolution: nodenext` a relative import without the extension does not resolve, so the new pin added one TS2835 to `@objectstack/rest`'s TEST_DEBT entry (155 -> 156) and `check:type-check-debt --re-measure` refused it. Fixed by fixing the error, which is the author's remedy — the ledger is shrink-only and raising it is maintainer-only. 92 local imports in this package's tests already carry the extension. Co-Authored-By: Claude <noreply@anthropic.com>
…a-unknown-type-refusal
📓 Docs Drift CheckThis PR changes 1 package(s): 21 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not seeCoarse fallback — 13 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c18e01f510c379ca83f349f94436cc26d7ec02b2 && git checkout c18e01f510c379ca83f349f94436cc26d7ec02b2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4d80e8bcc050919e874d6ccb629c1f9cb3b1dc0e 6b894e9f736a6fe8992cb169df66efeaee981a5e && git checkout -B drift-repro 4d80e8bcc050919e874d6ccb629c1f9cb3b1dc0e && git merge --no-ff 6b894e9f736a6fe8992cb169df66efeaee981a5e
node scripts/docs-audit/affected-docs.mjs --json 4d80e8bcc050919e874d6ccb629c1f9cb3b1dc0e
|
PM review — accepted, ready + auto-merge armed. ⛔ And it caught a broken tool I had put in three dispatch briefs.
⛔ My error, and it was propagatingAfter #9454 missed four That shape finds ZERO optional-call sites. Measured on
⭐ The standing lesson, now on its third instance and pointing somewhere new: a control that proves your query works does not validate the inference you draw from it — and here the query itself was what needed a control. The dev supplied one: intolerant shape 15 lines, tolerant 19, gap of 4 real sites at ⭐ The load-bearing falsification — my Zone 2 #1 was wrong in the dangerous directionI assumed a single authoritative list of type names existed and that the read door should consult the one the write door uses. Half right, and the missing half was a regression trap my own STOP condition named.
⇒ The read door needs the union of two authorities — the static spelling contract and the live listing — refusing only a name in neither. Had the dev followed my assumption, it would have shipped a 400 for four types the service itself enumerates. Ordering is right too: static verdict first (silent for all 68 accepted spellings, so a normal request pays nothing), live probe reached only by a request already headed for refusal, and fails open when the listing cannot be read. The other two assumptions, both falsified, both saving work
The two modified tests are strengthened, and I checked this before the report arrivedOn a Clause-② card, a modified existing test is where "adjusted to pass" hides.
Ratchets, envelope, and the gate that went red
Worth your attention, and correctly not acted onThree snake-plural spellings move #9744 filed for the single-item 404's inability to distinguish an unknown type from an unknown item — deduped first, and correctly left alone here since it is distinguishable from success and therefore not this card's defect. ⛔ Not graded by this seat. Generated by Claude Code |
Fixes #9488
The defect
The two doors disagreed about which type names exist. A 200-with-an-empty-collection is indistinguishable from "this type exists and holds nothing" — the same trap
GET /meta/app?id=was already filed for, where the empty answer read to a runner as "the app metadata is gone". A typo'd or renamed type name read as an empty surface rather than as a mistake.The refusal, measured against the write door
400/INVALID_REQUEST, naming the type — chosen, not invented:PUT /api/v1/meta/totally_invented_type/xanswers400 {"error":"[invalid_request] '...' is not a metadata type ...","code":"INVALID_REQUEST"}.404would be a third answer for one condition, and would collide with the meaning 404 already carries on this family:GET /meta/:type/:nameanswers404 RESOURCE_NOT_FOUNDfor a missing item. Reusing it for a missing type re-creates exactly the indistinguishability this card is about.INVALID_REQUESTis already registered to@objectstack/restin the ADR-0112ERROR_CODE_LEDGER(packages/spec/src/api/error-code-ledger.zod.ts). No code is minted, and nothing inpackages/specmoves.The refusal is thrown, not hand-built, so the handler's own
catchshapes it throughhandleRouteErrorand the wire body is byte-identical to the write door's for the same condition. Building a body here would author a second dialect for one condition — and would tick this file's envelope ratchets up.On the envelope, since #9487 found a third dialect in this file
Checked rather than assumed. This file speaks two dialects, and the
/metafamily's own sibling says which is which:refuseRepeatedQueryParams— the gate this very handler opens with — "writes the ADR-0112 nested body itself, which is right for the/metafamily it guards", while the data routes speak the flatmapDataErrorenvelope. But the sibling that matters for door agreement is not another gate, it is the same condition on the write door of the same transport, and that arrives flat. Matching it was the whole point, so this throws into the shared classification door rather than picking a shape by family.Ratchets, before and after (
check:route-envelope,packages/rest/src/rest-server.ts):stringError 44 / siblingCode 69->stringError 44 / siblingCode 69. Unmoved.Why the static predicate alone is the wrong rule on a READ door
#8421considered and rejected raisingunrecognisedMetaTypeRefusalon the read entries, and its reason is still true:Measured on this branch,
unrecognisedMetaTypeRefusalreturns a verdict fordata,kind,packageandpolicy— liveSchemaRegistrykeys an ordinaryregisterAppproduces, whichGET /api/v1/meta/typesenumerates. And a plugin's own type enters the live set the same way (content/docs/plugins/adding-a-metadata-type.mdx: "A third-party package's type instead enters the live set as a side effect of registering items of that type").So the rule is the union of the two authorities the platform already has, and neither is restated here:
unrecognisedMetaTypeRefusal, the predicate the write door consults;getMetaTypes, the oneGET /meta/typesserves.A name in neither is a name nothing can serve. ⛔ No list of type names is hand-written anywhere in this change.
The static verdict runs first and is silent for all 68 accepted spellings, so an ordinary list request pays nothing; the live listing is consulted only by a request already headed for a refusal — the same shape the write door uses (static verdict, then
metaTypeNamespaceExists).It fails open. No
getMetaTypeson the host's protocol, or a rejecting call, keeps today's answer: "no such type" is an existence claim, and stating it while the authority that would know is unreachable is precisely whatmetaTypeNamespaceExistsrefuses to do on the write door.What deliberately did NOT change
200with an empty collection. That is the legitimate case the defect was indistinguishable from; breaking it would be worse than the bug. Pinned for declared types, for the four live-only keys, and for a plugin-registered type./meta/lead/views/all_leadscarries an object name in the:typesegment — the write door's own exemption 1 — and is honoured here by simply not being this route.GET /meta/{invented}/xalready answers404 RESOURCE_NOT_FOUND, and the/references,/layers,/history,/audit,/diff,/publishedlimbs answer501 NOT_IMPLEMENTED. All already distinguishable from a served answer.enforceAuth, the org-scope read threading and the audit limb — all outside this change.Reverse verification
Prediction written before running, then compared on failure reasons, not totals:
expected 200 to be 400on all three refusal cases, and the write-door-agreement case fails on the same comparison. The fourth red is a case added after the prediction ("does not ask the protocol to list a type it has refused"), which failed on its own claim — recorded as a prediction miss in count, not in directionexpected 400 to be 200on all seven, i.e. the predicted direction. The eight declared-type cases stayed green, which is what makes B a discriminating ablation rather than a blunt oneScope of the fixture triage
Two fixtures elsewhere spelled the old behaviour and were triaged individually rather than mass-edited:
packages/rest/src/meta-plural-i18n.test.ts— its double advertised an empty type set while serving a document forwidget, a name chosen precisely because it is outside every map. The listing is now derived fromRAW_BY_NAME, the same source the item double reads, in both spellings the item double answers. No assertion in the file changed.packages/qa/dogfood/test/meta-types-create-seed.dogfood.test.ts— pinned the defect literally, as the contrast proving/meta/typesis not the catch-all wearing a 200. The contrast is sharper now, so only the catch-all's half is restated, withcodeandstatusso it cannot pass on a 404 that would mean the catch-all had stopped being mounted. Its siblingroute-ledger-live-mount-parity.dogfood.test.tsasserts routing for the same name and is untouched — the request still reaches/meta/:type.Verification
All at
6b894e9f(mainmerged in, then re-verified there), with the gate union re-derived from the actual changed paths vianode scripts/pm/dispatch-gates.mjs.@objectstack/restfull suiteTest Files 128 passed (128)·Tests 2096 passed (2096)@objectstack/resttypechecktsc --noEmit, exit 0@objectstack/runtimefull suiteTest Files 173 passed (173)·Tests 2590 passed (2590)@objectstack/clientTest Files 23 passed (23)·Tests 310 passed (310)@objectstack/http-conformanceTest Files 4 passed (4)·Tests 72 passed (72)@objectstack/honoTest Files 2 passed (2)·Tests 73 passed (73)Test Files 2 passed (2)·Tests 14 passed (14)— after rebuilding@objectstack/restand proving the change reacheddist/withscripts/ablation-dist-preflight.mjsGates, each echoed rather than reported as an exit code:
check:route-envelope(ratchets unmoved),check:authz-resolver,check:cross-package-test-inputs,check:dispatcher-error-vocabulary,check:changeset-gate-self-tests,check:objectui-changeset,check:nul-bytes,check:error-code-casing,check:engine-double-contract,check:where-matcher,check:query-options-erasure,check:type-check-coverage,check:test-source-alias,check:type-source-resolution,check-adr-0087-registration,check-affected-docs, and the four@objectstack/specliveness gates the dogfood edit pulled in (check:empty-state,check:liveness,check:strictness-ledger,check:variant-docs).check:type-check-debt --re-measureis in that list and is the one that went red: the new pin's local import lacked its.jsextension, adding one TS2835 to@objectstack/rest's shrink-only TEST_DEBT entry (155 -> 156). Fixed by fixing the error —OK — 33 ledger entr(ies) re-measured, none above its recorded number. ⛔ No ledger entry raised, no threshold moved, no test skipped.Residue, stated rather than hidden
The live listing returns spellings as each source stores them. Both sides of the comparison are folded through
canonicalMetaUrlType, the platform's own fold, so every mapped plural meets its singular. A registry that stored a type under an unmapped plural while a caller addressed the singular would not meet — the alternative is re-deriving a pluralizer downstream, whichmetadata-url-spelling.tsexists to prevent ("Nothing here should ever be consulted by a predicate one layer down"). Plugin types are singular by declared convention (the type field is singular ... there is no automatic pluralization), so this is off-convention on both sides at once.Generated by Claude Code