Skip to content

fix(rest): GET /api/v1/meta/:type refuses a type name that names nothing, instead of serving it as an empty collection (#9488) - #9743

Merged
os-zhuang merged 5 commits into
mainfrom
claude/issue-9488-meta-unknown-type-refusal
Aug 18, 2026
Merged

os-zhuang merged 5 commits into
mainfrom
claude/issue-9488-meta-unknown-type-refusal

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Fixes #9488

The defect

GET /api/v1/meta/totally_invented_type   ->  200 {"type":"totally_invented_type","items":[]}
PUT /api/v1/meta/totally_invented_type/x ->  400 "'totally_invented_type' is not a metadata type"

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:

  • it is what the write door already teaches for this exact condition. Measured on this branch through the REST layer, not read off a comment: PUT /api/v1/meta/totally_invented_type/x answers 400 {"error":"[invalid_request] '...' is not a metadata type ...","code":"INVALID_REQUEST"}.
  • 404 would be a third answer for one condition, and would collide with the meaning 404 already carries on this family: GET /meta/:type/:name answers 404 RESOURCE_NOT_FOUND for a missing item. Reusing it for a missing type re-creates exactly the indistinguishability this card is about.
  • INVALID_REQUEST is already registered to @objectstack/rest in the ADR-0112 ERROR_CODE_LEDGER (packages/spec/src/api/error-code-ledger.zod.ts). No code is minted, and nothing in packages/spec moves.

The refusal is thrown, not hand-built, so the handler's own catch shapes it through handleRouteError and 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 /meta family'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 /meta family it guards", while the data routes speak the flat mapDataError envelope. 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

#8421 considered and rejected raising unrecognisedMetaTypeRefusal on the read entries, and its reason is still true:

Refusing unrecognised names on the READ entries would answer 400 for types this same service advertises, trading one declared-≠-served gap for another.

Measured on this branch, unrecognisedMetaTypeRefusal returns a verdict for data, kind, package and policy — live SchemaRegistry keys an ordinary registerApp produces, which GET /api/v1/meta/types enumerates. 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:

  1. the static spelling contract — unrecognisedMetaTypeRefusal, the predicate the write door consults;
  2. the live listing — getMetaTypes, the one GET /meta/types serves.

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 getMetaTypes on 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 what metaTypeNamespaceExists refuses to do on the write door.

What deliberately did NOT change

  • A type that exists and has no items still answers 200 with 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.
  • The write door. Untouched; it is the reference behaviour.
  • The compound arity. /meta/lead/views/all_leads carries an object name in the :type segment — the write door's own exemption 1 — and is honoured here by simply not being this route.
  • The single-item doors. Measured, and none of them carries this defect: GET /meta/{invented}/x already answers 404 RESOURCE_NOT_FOUND, and the /references, /layers, /history, /audit, /diff, /published limbs answer 501 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:

ablation predicted actual reason checked
A — drop the refusal call the three refusal cases go red, every "must stay 200" case stays green 4 red / 15 green expected 200 to be 400 on 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 direction
B — drop the live-set exemption the four live-only keys, the plugin type and both fail-open cases go red; every declared type stays green 7 red / 12 green — exact expected 400 to be 200 on 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 one

Scope 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 for widget, a name chosen precisely because it is outside every map. The listing is now derived from RAW_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/types is not the catch-all wearing a 200. The contrast is sharper now, so only the catch-all's half is restated, with code and status so it cannot pass on a 404 that would mean the catch-all had stopped being mounted. 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.

Verification

All at 6b894e9f (main merged in, then re-verified there), with the gate union re-derived from the actual changed paths via node scripts/pm/dispatch-gates.mjs.

what result
@objectstack/rest full suite Test Files 128 passed (128) · Tests 2096 passed (2096)
@objectstack/rest typecheck tsc --noEmit, exit 0
@objectstack/runtime full suite Test Files 173 passed (173) · Tests 2590 passed (2590)
@objectstack/client Test Files 23 passed (23) · Tests 310 passed (310)
@objectstack/http-conformance Test Files 4 passed (4) · Tests 72 passed (72)
@objectstack/hono Test Files 2 passed (2) · Tests 73 passed (73)
dogfood, on a real booted stack Test Files 2 passed (2) · Tests 14 passed (14) — after rebuilding @objectstack/rest and proving the change reached dist/ with scripts/ablation-dist-preflight.mjs

Gates, 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/spec liveness gates the dogfood edit pulled in (check:empty-state, check:liveness, check:strictness-ledger, check:variant-docs).

check:type-check-debt --re-measure is in that list and is the one that went red: the new pin's local import lacked its .js extension, 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, which metadata-url-spelling.ts exists 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

claude added 5 commits August 18, 2026 19:18
)

`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>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/rest, touching 10 documentable anchor(s).

21 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 4d80e8bcc050919e874d6ccb629c1f9cb3b1dc0e.

5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see

Coarse fallback — 13 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4d80e8bcc050919e874d6ccb629c1f9cb3b1dc0epackageMentionDocs.

Which tree this was computed on

This run read content/docs from c18e01f510c379ca83f349f94436cc26d7ec02b2 — the merge of head 6b894e9f736a6fe8992cb169df66efeaee981a5e into base 4d80e8bcc050919e874d6ccb629c1f9cb3b1dc0e, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 4d80e8bcc050919e874d6ccb629c1f9cb3b1dc0e → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 18, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 18, 2026 21:02
@os-zhuang
os-zhuang enabled auto-merge August 18, 2026 21:02

Copy link
Copy Markdown
Contributor Author

PM review — accepted, ready + auto-merge armed. ⛔ And it caught a broken tool I had put in three dispatch briefs.

domain:cli seat, session session_012WKSnqAaoqtW3QX7SSf1Vk. Dev returned status: done, no open questions. Clause-② card; every ⛔ held.

⛔ My error, and it was propagating

After #9454 missed four .getMetaItems?.( sites, I wrote a "corrected" optional-call grep shape into this brief and two others: \.method\s*\??\s*\(.

That shape finds ZERO optional-call sites. Measured on origin/main, rest-server.ts:

\.getMetaItems\s*\??\s*\(        →  9   ← the shape I shipped
\.getMetaItems\?\.\(             →  4   ← the optional sites that exist
\.getMetaItems\s*(\?\.)?\s*\(    → 13   ← correct, = 9 + 4

\?? makes the ? optional and then demands \s*\( — but the real text is .method?.(, so a . follows the ?. It matches only the plain form.

⚠️ That is worse than the bug it was meant to fix. The original mistake looked intolerant. Mine looked handled and silently was not — a zero-hit that reads as "there are no optional-call sites here." The dev found it because its first attempt used my shape, returned zero, and it did not believe the zero — it went and read the text. I have since corrected the one live dispatch still running on that brief.

⭐ 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 :1304, :3276, :3317, :8422.

⭐ The load-bearing falsification — my Zone 2 #1 was wrong in the dangerous direction

I 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.

unrecognisedMetaTypeRefusal returns a verdict for data, kind, package, policyall of which GET /meta/types advertises, and plugin types enter the live set by registering items. #8421 already rejected exactly this, in its own words:

"Refusing unrecognised names on the READ entries would answer 400 for types this same service advertises, trading one declared-not-served gap for another."

⇒ 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 arrived

On a Clause-② card, a modified existing test is where "adjusted to pass" hides.

  • Dogfood: toBe(200)toBe(400) plus code === 'INVALID_REQUEST' plus the message naming the type. Its comment refuses the easier assertion by name: "A bare 'not 200' would pass on any refusal at all, including the 404 that would mean the catch-all had stopped being mounted — which is the failure this file exists to notice." The file's own claim is unchanged and the contrast is sharper.
  • meta-plural-i18n.test.ts: the double advertised an empty type set while serving a document for widget. Under the new refusal it would be refused before the localization under test ran, and "the failure would read as an i18n regression." Fixed by deriving the listing from RAW_BY_NAME — the same source the item double reads — so no assertion changed. Declared as an in-scope bounded fix, as required.

Ratchets, envelope, and the gate that went red

  • stringError 44 → 44, siblingCode 69 → 69, unmoved before and after the main merge. The refusal is thrown, not hand-built, so no body literal enters the file.
  • ⭐ On the envelope it did not default to nested. It observed that this file speaks two dialects and reasoned that the sibling governing door agreement is the same condition on the write door of the same transport, which arrives flat — so it matched that. Given Every 401 answers {error, message} with no code key, while every other error family carries {error, code} #9487 is in the decision box precisely because "just use nested" was wrong there, this is the right instinct applied twice in one file.
  • check:type-check-debt went red (+1) on a missing .js extension under nodenext, and was fixed, not ledgered — second time today. ⛔ No entry raised, no baseline edited.

Worth your attention, and correctly not acted on

Three snake-plural spellings move 200 → 400: sharing_rules, analytics_cubes, rag_pipelines — plugin kinds whose manifest spelling is camelCase, so limb 2's pluralisation never reaches them. The write door already refuses all three, so this is the read door catching up, not a new rule. The dev did not file a card asking for them to be added, because #9180 is a standing maintainer ruling: "The /meta type segment is singular. Always. There is no exception and no tolerated plural alias." Checking the ruling before proposing work against it is exactly right.

#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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GET /api/v1/meta/<unknown-type> answers 200 with an empty collection while the write door refuses the same type

2 participants