fix(rest): GET /meta/:type/:name answers 404 RESOURCE_NOT_FOUND for a name with nothing behind it - #18395
Conversation
… name with nothing behind it The uncached arm reached its 404 only from inside `if (isAppType && visible)`, where `visible` is the document, so a name that resolves to nothing skipped the gate whole and fell through to `res.json` as a 200 carrying the declared envelope minus its `item` member. Two in-repo declarations already said otherwise: `GetMetaItemResponseSchema` — this route's own `responseSchema` — makes `item` required, and the cached arm of the same route already throws `metadataItemNotFoundError` on a falsy `item`. The absence answer now goes through one emitter, so the nothing-behind-the-name arm and the unpublished/absent-service arm are byte-identical by construction: ADR-0045 §3 unobservability held structurally rather than by coincidence. The check is ordered before every gate, so an existing-but-withheld app still reaches the app gate and still answers 403 PERMISSION_DENIED. Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude <noreply@anthropic.com>
… the publish-gate suite the live producer miss The new file pins the 404 across types and both query-flag diversions, the #8013 partition in both directions (absent is not the 403; the withheld app still is), the two arms of the route agreeing on status and code, and the body against `GetMetaItemResponseSchema` itself rather than a restatement of it. `meta-app-publish-gate.test.ts`'s double answered `undefined` for an unknown name, a shape no deployment produces: the live producer resolves the protection envelope around an `item: undefined`. It now answers that, so criterion 3 is stated over the shape that carried the defect, and the criterion gains its positive half — "not the denial" was satisfied by a success just as well as by an absence. Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude <noreply@anthropic.com>
📓 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: ⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 15 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 3c4ffc3867ec15ec0863b6ee41649509270ce56d && git checkout 3c4ffc3867ec15ec0863b6ee41649509270ce56d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c3a95d97b8969d78be2e0ed3067b58f837891653 78f881fead846a7182a0e1b82aa8bb9f11d97c97 && git checkout -B drift-repro c3a95d97b8969d78be2e0ed3067b58f837891653 && git merge --no-ff 78f881fead846a7182a0e1b82aa8bb9f11d97c97
node scripts/docs-audit/affected-docs.mjs --json c3a95d97b8969d78be2e0ed3067b58f837891653
|
…d book that exists still answers 403
The absent-vs-withheld partition is not app-scoped and neither is the trap. The
ADR-0046 §6.7 audience gate is the other arm on this handler that converts a
withheld-but-existing document into a refusal, and it is guarded by `&& visible`
exactly as the app gate is.
Measured while writing it: that arm emits through `sendDeclaredFault`, whose body
is the flat `{ error, code }`, while the app gate's 403 emits the nested
`{ success: false, error: { code, message } }` — so one handler answers
PERMISSION_DENIED in two dialects. Asserted in the shape the route really sends,
with the divergence recorded rather than converged here.
Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3
Co-authored-by: Claude <noreply@anthropic.com>
|
Fixes #18066
Clause-②: no — this PR puts no new key on a published payload. It makes
GET /meta/app/NAMEanswer theRESOURCE_NOT_FOUND/ 404 thatpackages/rest/src/meta-app-publish-gate.test.tsalready declares and thatGetMetaItemResponseSchemaalready implies by makingitemrequired — restoring a declared contract, which narrows rather than widens. Declared by the dispatching seat in claim5693775028.RESOURCE_NOT_FOUNDis an already-published code and no newERROR_CODE_LEDGERentry is introduced.What it does
GET /api/v1/meta/:type/:namefor a name with nothing behind it answered 200 with the declared envelope MINUS itsitemmember. It now answers404 RESOURCE_NOT_FOUND.The reported body, re-measured on this tree against the route's own
responseSchema:Why this is execution, not a design call
The card's "Not measured, deliberately left open" section reads like an open question. It is not: three declarations in this repository already agreed with each other and against the live route, and the loudest of the three — the 200 — was the wrong one.
Governing text, maintainer ruling 2026-09-10, quoted rather than translated:
GetMetaItemResponseSchema(packages/spec/src/api/protocol.zod.ts, this route's declaredresponseSchemaperrest-route-ledger.ts) makesitema required member, while every genuinely-optional key beside it is spelled.optional(). Measured above.packages/specwas read only and is not edited by this PR.getMetaItemCachedthrowsmetadataItemNotFoundErroron a falsyitem. So which arm a request took decided whether absence was an error at all —app,dashboard,doc,book,?state=draft,?preview=draft,?package=and everyenableCache: falsedeployment are diverted around the cache. That is theGET /meta/:type/:nameanswers two different body shapes on the same request — the cached branch (the DEFAULT) returns the bare document, the non-cached branch returns the spec-declared{ type, name, item }envelope #5563 defect class, one member over.packages/runtime/src/domains/meta.tsrefuses the same item-less answer in its object branch — "only treat the lookup as a hit whenitemis really there" — and 404s. Cited as corroboration, not touched here; see Acceptance notes.Mechanism, re-verified on today's tree
The card's reading (dated 2026-09-13) still holds. Both the 403
PERMISSION_DENIEDand the 404RESOURCE_NOT_FOUNDsit insideif (isAppType && visible), wherevisibleis the document, so for a name that resolves to nothing the whole gate was skipped and the envelope fell through tores.json.Two things the card left open, now measured:
apprule. The fall-through sat in the shared tail of the uncached arm, below the per-type gates, so every type diverted around the cache reached it.appis what the report saw because that type bypasses the cache structurally; a?state=draft,?preview=draftor?package=read of any type met the same 200. Pinned acrossdashboard,view,object,flow,pageand all three query-flag diversions.packages/metadata-protocol/src/protocol.ts:12084getMetaItemCacheddelegates togetMetaItem, takesresult?.item, and atif (!item)throwsmetadataItemNotFoundError(type, name)—RESOURCE_NOT_FOUND,status: 404. Exercised over the route as well, not only read: §3 drives the cached branch with a provider that answers that rejection and assertsgetMetaItemCachedwas the verb called, then drives the same address through the uncached branch withenableCache: falseand asserts the two now agree on status and code. The one remaining asymmetry between them is the envelope dialect, which this PR deliberately does not touch (see Acceptance notes).And one mechanism detail the card could not have seen:
JSON.stringify, not in the producer.metadata-protocol'sgetMetaItemreturns{ type, name, item: undefined, lock, … }for a miss.itemis present holdingundefined, andz.unknown()admits that, so the returned object PASSESGetMetaItemResponseSchema; serialization then drops the member and produces the body the schema rejects. A conformance probe written against the returned object rather than the wire bytes reports agreement — which is the trap, not a detail. Both halves are asserted in §4.Type scope: separable, and covered anyway — with the reading
The card measured
appalone. This change covers every type, so the reading that justifies it:One handler serves every type.
GET /meta/:type/:nameis registered once, atpackages/rest/src/rest-server.ts:6593, and:typeis a path parameter. Inside it the only type-specific arms are four booleans computed at lines 6702 (isAppType), 6780 (isDashboardType) and 6784 (isAudienceGatedType, which isbookordoc), plus adoc-only locale collapse. The producer call is at 6972,let visible = envelope?.itemat 6979, and the new absence check at 7005-7012 — above all of them. The fall-through tores.jsonthat produced the item-less 200 is in the shared tail and reads no type at all.So is per-type narrowing possible? Yes — and I did not take it. The narrowing is spellable in one token:
if (isAppType && visible == null). Stated plainly so it can be judged as an over-scope if that is the call. My reason for covering every type:GetMetaItemResponseSchemais this route's singleresponseSchemafor every:type.app-only narrowing would leave the divergence standing everywhere else and make it depend on a query flag:GET /meta/view/no_such_viewwould answer 404 plainly and an item-less 200 under?state=draft. That is the same configuration-dependent split this change exists to remove, preserved for every type but one.⇒ On this reading, "all types" is the narrowest fix that is actually correct, not a widening. It is the maintainer's call to disagree; narrowing is a one-token patch round.
The 27 pages the docs-drift check names are AFFECTED, not edited. This PR edits zero files under
content/docs/— the diff is 3 files inpackages/rest/srcplus one changeset.scripts/docs-audit/affected-docs.mjsreports "27 docs name something this change touched", of which 6 are the release-owned pages undercontent/docs/releases/. Those are read-only under AGENTS.md's Documentation Guardrails and are not touched here. Read for a false claim about this behaviour: none of the six states anything about the by-name absence answer; the only/meta/:type/:namementions arev17/17-3.mdxlines 362-370, which document the section-segment URL collapse and remain correct. Nothing to file against them. The breadth of the affected set is a property of the anchor (rest-server.tsplus the/metaroute), not evidence of a wider code change.The RBAC ordering, which is the sharp part
The new check is ordered ahead of every gate, and that ordering is the security half of the change rather than a style choice. The three gates below it all read
&& visible, so they are reachable only by a document that exists; a name that resolves to nothing can therefore never be converted into the 403 that #8013 reserves for an app the caller may not open. An existing-but-withheld app answers exactly what it answered before.It also closes an enumeration hole in the other direction, which the card did not name. ADR-0045 §3 makes an unpublished app externally unobservable, and #8013 states the contract as absence and nonexistence being indistinguishable. They were not: an unpublished app answered the 404 while a nonexistent name answered the 200, so the pair of responses reported which app names exist-but-are-unpublished. Both absence answers now come from one emitter,
sendMetaItemAbsent, and are byte-identical by construction rather than by two hand-built literals agreeing.RESOURCE_NOT_FOUNDrequiredPermissionsnot heldPERMISSION_DENIEDPERMISSION_DENIED(unchanged)_unpublishedRESOURCE_NOT_FOUNDRESOURCE_NOT_FOUND(unchanged)requiresServiceabsentRESOURCE_NOT_FOUNDRESOURCE_NOT_FOUND(unchanged)SERVICE_UNAVAILABLESERVICE_UNAVAILABLE(unchanged)bookexists, audience not heldPERMISSION_DENIEDPERMISSION_DENIED(unchanged)bookname resolves to nothingRESOURCE_NOT_FOUNDBoth directions are pinned on a non-
apptype too, because neither the invariant nor the trap is app-scoped: ADR-0046 §6.7's audience gate is the other arm on this handler that converts a withheld-but-existing document into a refusal, and it is guarded by&& visibleexactly as the app gate is. A check placed after it — or one that fired on a document that exists — would turn a gated book into an absence.sendDeclaredFault, whose body is the flat{ error: '…', code }, while the app gate's 403 one screen up emitssendEnvelopeError's nested{ success: false, error: { code, message } }. One handler, one code, two dialects —body.error.code, the accessor #8013 settled on, readsundefinedon the audience arm. The pin asserts the shape the route really sends. Filed below with the matching 404 split.The fixture half
meta-app-publish-gate.test.ts's double answeredundefinedfor an unknown name — a shape no deployment produces, and the reason its criterion-3 case passed over a stub that could not exhibit the defect the live provider had. It now answers what the live producer answers: the protection envelope wrapped around anitem: undefined. The rejecting-producer case keeps its own override, so both producer shapes reach this route from that file.That case also gains its positive half. "Not the denial" was satisfied by a success just as well as by an absence, which is how the route answered an item-less 200 for years with every assertion in that suite green.
Verification
Ablation (fix committed first; mutation = delete the absence check; restore =
git checkout HEAD -- …):Direction observed: turns red, 8 cases. Worth recording which cases did NOT move — the rejecting-producer case and "an absent name never becomes the 403" both stayed green under the mutation, which is precisely the card's complaint: a fixture that rejects, and a fence phrased as a negative, cannot see this defect.
Suites and gates, all re-run at the final commit
78f881fe:pnpm --filter @objectstack/rest testpnpm --filter @objectstack/rest typechecktsc --noEmit+check:test-typecheck: test layer compiles, 0 errors)pnpm --filter '@objectstack/rest^...' buildpnpm exec turbo run build(all packages)node scripts/pm/dispatch-gates.mjs --commands→ 62 families, each run, exit codes captured to disk before readingnode scripts/pm/dispatch-gates.mjs --ranpnpm lint(whole repo,eslint . --no-inline-config)The one red is
pnpm check:cross-package-test-inputs, and it is not this diff. It reds onpackages/cli/test/init-created-files-summary.e2e.test.tsdescendingpackages/spec/dist/. Controlled in place: the four changed paths were restored to theirorigin/maincontent with the build state left untouched, and the gate produced the byte-identical finding at exit 1 (sendMetaItemAbsentoccurrences on disk = 0 during the control, then every blob restored to its HEAD hash withgit diff HEADempty). Filed as a finding below.Two gates first reported
PREREQUISITE NOT MET(exit 3) and one a shallow-clone refusal; all three were re-run after the missing prerequisite was supplied and are green —check:dual-build-cjs-loadsandcheck:type-check-debtafter the full build,check-plugin-teardown-shape --self-testaftergit fetch --unshallow. None is recorded as a failed measurement.Acceptance notes
To file (three-class findings, not fixed here — filing is the triage seat's, with dedupe words):
packages/runtime/src/domains/meta.ts— the generic:type/:namebranch returnsdeps.success(data)straight fromprotocol.getMetaItem(...), so the same item-less envelope reaches its callers as a success on that door too. Its own sibling object branch in the same function explicitly refuses this shape and 404s, so the file disagrees with itself. Not repaired here:packages/runtime/carries another live claim in this batch, so the bounded in-place exemption fails on the no-other-claimant condition. Dedupe words:runtime meta dispatcher,domains/meta.ts singularType,deps.success(data) item-less,getMetaItem envelope,GetMetaItemResponseSchema item required.pnpm check:cross-package-test-inputsreds on unmodifiedmainwheneverpackages/spec/distexists on disk — the gate walks that tree, so the verdict is a function of local build state. A checkout that has runpnpm buildgets a red naming a test it never touched; one that has not gets a green. Repro: run it with and withoutpackages/spec/distpresent, same source. Dedupe words:cross-package-test-inputs,init-created-files-summary.e2e,packages/spec/dist walk,declared glob reaches inside,build-state-dependent gate.GET /meta/:type/:nameanswers absence as ADR-0112's nested{ error: { code, message } }from the in-route arms and as the flat{ error: '…', code }from the thrown arm (resolveErrorResponse's declared-status passthrough, pinned inrest-meta-outage-vs-miss.test.ts), so which envelope a caller must parse depends on a server-side cache setting it cannot see — a client readingbody.error.codegetsundefinedon the cached arm, which is the finding:rest-server.ts里三个相邻/metahandler 的错误信封是三种不同形状,其中两种不符合 ADR-0112 #7035 failure class. Deliberately NOT converged here: matching the flat shape would have broken the byte-identity above, which is a security property, and converging the thrown side has repo-wide blast radius. Pinned as-is in §3 of the new file so a future convergence is a deliberate edit. Dedupe words:meta item 404 dialect,resolveErrorResponse flat envelope,ADR-0112 nested error.code,getMetaItemCached throw,cached arm body shape.Noted, not filed:
appread — once hoisted forreadOrganizationId, once inside the RBAC gate. It is memoised per request (aWeakMapkeyed on the request object), so the cost is a map lookup, and the hoisted comment already says so. Successor: none — no PR and no person is routed to this file by it.Scope
packages/rest/src/only, as the claim declared. Authored by theos-devseat in sessionsession_01DvvamiacK328idtBYJBxV3(https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3) — recorded in prose because editing this body over raw REST replaces the session-URL footer with the platform's bare one.packages/spec/src/was read and not edited. No new error code, noERROR_CODE_LEDGERentry, nopackage.jsontouched (so no lockfile is owed), no governed surface in the diff. Noskills/**path, so no line-budget reading is owed.Generated by Claude Code