Skip to content

fix(rest): GET /meta/:type/:name answers 404 RESOURCE_NOT_FOUND for a name with nothing behind it - #18395

Merged
os-support-ai merged 3 commits into
mainfrom
claude/issue-18066-meta-app-absent-404
Sep 16, 2026
Merged

os-support-ai merged 3 commits into
mainfrom
claude/issue-18066-meta-app-absent-404

Conversation

@os-support-ai

@os-support-ai os-support-ai commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

Fixes #18066

Clause-②: no — this PR puts no new key on a published payload. It makes GET /meta/app/NAME answer the RESOURCE_NOT_FOUND / 404 that packages/rest/src/meta-app-publish-gate.test.ts already declares and that GetMetaItemResponseSchema already implies by making item required — restoring a declared contract, which narrows rather than widens. Declared by the dispatching seat in claim 5693775028. RESOURCE_NOT_FOUND is an already-published code and no new ERROR_CODE_LEDGER entry is introduced.

What it does

GET /api/v1/meta/:type/:name for a name with nothing behind it answered 200 with the declared envelope MINUS its item member. It now answers 404 RESOURCE_NOT_FOUND.

The reported body, re-measured on this tree against the route's own responseSchema:

GET /api/v1/meta/app/no_such_app_xyz
200 {"type":"app","name":"no_such_app_xyz","lock":"none","editable":true,"deletable":true,"resettable":false}

GetMetaItemResponseSchema.safeParse(that body)
  success = false
  issues  = [{"code":"invalid_type","expected":"nonoptional","path":["item"]}]

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:

我们的项目以objectstack 协议为准,文档应该以实际实现为准。协议不正确的应该先修改协议。

声明而未兑现是实现缺口,补实现或退役,⛔ 不在消费端收窄。

  1. GetMetaItemResponseSchema (packages/spec/src/api/protocol.zod.ts, this route's declared responseSchema per rest-route-ledger.ts) makes item a required member, while every genuinely-optional key beside it is spelled .optional(). Measured above. packages/spec was read only and is not edited by this PR.
  2. The CACHED arm of this same route already answered 404. getMetaItemCached throws metadataItemNotFoundError on a falsy item. So which arm a request took decided whether absence was an error at all — app, dashboard, doc, book, ?state=draft, ?preview=draft, ?package= and every enableCache: false deployment are diverted around the cache. That is the GET /meta/:type/:name answers 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.
  3. packages/runtime/src/domains/meta.ts refuses the same item-less answer in its object branch — "only treat the lookup as a hit when item is 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_DENIED and the 404 RESOURCE_NOT_FOUND sit inside if (isAppType && visible), where visible is the document, so for a name that resolves to nothing the whole gate was skipped and the envelope fell through to res.json.

Two things the card left open, now measured:

  • It was never an app rule. 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. app is what the report saw because that type bypasses the cache structurally; a ?state=draft, ?preview=draft or ?package= read of any type met the same 200. Pinned across dashboard, view, object, flow, page and all three query-flag diversions.
  • The cached arm does NOT agree — it was already correct, so absence was never the uncached arm's opinion alone. Read at the source: packages/metadata-protocol/src/protocol.ts:12084 getMetaItemCached delegates to getMetaItem, takes result?.item, and at if (!item) throws metadataItemNotFoundError(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 asserts getMetaItemCached was the verb called, then drives the same address through the uncached branch with enableCache: false and 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:

  • ⚠️ The contract broke at JSON.stringify, not in the producer. metadata-protocol's getMetaItem returns { type, name, item: undefined, lock, … } for a miss. item is present holding undefined, and z.unknown() admits that, so the returned object PASSES GetMetaItemResponseSchema; 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 app alone. This change covers every type, so the reading that justifies it:

One handler serves every type. GET /meta/:type/:name is registered once, at packages/rest/src/rest-server.ts:6593, and :type is a path parameter. Inside it the only type-specific arms are four booleans computed at lines 6702 (isAppType), 6780 (isDashboardType) and 6784 (isAudienceGatedType, which is book or doc), plus a doc-only locale collapse. The producer call is at 6972, let visible = envelope?.item at 6979, and the new absence check at 7005-7012 — above all of them. The fall-through to res.json that 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:

  • The contract being restored is declared per route, not per type: GetMetaItemResponseSchema is this route's single responseSchema for every :type.
  • The cached arm already 404s for every type, so app-only narrowing would leave the divergence standing everywhere else and make it depend on a query flag: GET /meta/view/no_such_view would 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.
  • There is no per-type declaration anywhere saying absence is a success for some types — the only type-specific behaviour on this route is gating, and gating runs only on documents that exist.

⇒ 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 in packages/rest/src plus one changeset. scripts/docs-audit/affected-docs.mjs reports "27 docs name something this change touched", of which 6 are the release-owned pages under content/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/:name mentions are v17/17-3.mdx lines 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.ts plus the /meta route), 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.

case before after
name resolves to nothing 200 + item-less envelope 404 RESOURCE_NOT_FOUND
app exists, requiredPermissions not held 403 PERMISSION_DENIED 403 PERMISSION_DENIED (unchanged)
app exists, _unpublished 404 RESOURCE_NOT_FOUND 404 RESOURCE_NOT_FOUND (unchanged)
app exists, requiresService absent 404 RESOURCE_NOT_FOUND 404 RESOURCE_NOT_FOUND (unchanged)
metadata store unreadable 503 SERVICE_UNAVAILABLE 503 SERVICE_UNAVAILABLE (unchanged)
book exists, audience not held 403 PERMISSION_DENIED 403 PERMISSION_DENIED (unchanged)
book name resolves to nothing 200 + item-less envelope 404 RESOURCE_NOT_FOUND

Both directions are pinned on a non-app type 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 && visible exactly 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.

⚠️ Measured while writing that pin, and recorded rather than repaired: the audience arm emits through sendDeclaredFault, whose body is the flat { error: '…', code }, while the app gate's 403 one screen up emits sendEnvelopeError's nested { success: false, error: { code, message } }. One handler, one code, two dialects — body.error.code, the accessor #8013 settled on, reads undefined on 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 answered undefined for 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 an item: 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 -- …):

HEAD blob            = a43dfeb1fa866c9f25d16f13e84b8e64f8e88515
anchor count before  = 2   (the new arm + the #8013 arm)
anchor count after   = 1   (only the #8013 arm is left)
on-disk mutated      = 7ad23dec956361fff62204636a829afb1abc266b
=== MUTATED RUN exit=1 ===   Tests  8 failed | 29 passed (37)
on-disk restored     = a43dfeb1fa866c9f25d16f13e84b8e64f8e88515
restore proven: git diff HEAD empty AND hash == HEAD blob
=== RESTORED RUN exit=0 ===  Tests  37 passed (37)

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:

run result
pnpm --filter @objectstack/rest test 192 files, 3215 passed, 1 skipped
pnpm --filter @objectstack/rest typecheck exit 0 (tsc --noEmit + check:test-typecheck: test layer compiles, 0 errors)
pnpm --filter '@objectstack/rest^...' build exit 0 (dependency closure)
pnpm exec turbo run build (all packages) 72/72 successful
node scripts/pm/dispatch-gates.mjs --commands → 62 families, each run, exit codes captured to disk before reading 61 green, 1 pre-existing red
node scripts/pm/dispatch-gates.mjs --ran 62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN
pnpm lint (whole repo, eslint . --no-inline-config) exit 0 — run in full, not narrowed

The one red is pnpm check:cross-package-test-inputs, and it is not this diff. It reds on packages/cli/test/init-created-files-summary.e2e.test.ts descending packages/spec/dist/. Controlled in place: the four changed paths were restored to their origin/main content with the build state left untouched, and the gate produced the byte-identical finding at exit 1 (sendMetaItemAbsent occurrences on disk = 0 during the control, then every blob restored to its HEAD hash with git diff HEAD empty). 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-loads and check:type-check-debt after the full build, check-plugin-teardown-shape --self-test after git 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):

  • (b) contract violation. packages/runtime/src/domains/meta.ts — the generic :type/:name branch returns deps.success(data) straight from protocol.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.
  • (a) reproducible defect. pnpm check:cross-package-test-inputs reds on unmodified main whenever packages/spec/dist exists on disk — the gate walks that tree, so the verdict is a function of local build state. A checkout that has run pnpm build gets a red naming a test it never touched; one that has not gets a green. Repro: run it with and without packages/spec/dist present, 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.
  • (b) contract violation. One route, two 404 dialects. GET /meta/:type/:name answers 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 in rest-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 reading body.error.code gets undefined on the cached arm, which is the finding: rest-server.ts 里三个相邻 /meta handler 的错误信封是三种不同形状,其中两种不符合 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:

  • noted, not filed: the uncached arm resolves the execution context twice for an app read — once hoisted for readOrganizationId, once inside the RBAC gate. It is memoised per request (a WeakMap keyed 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 the os-dev seat in session session_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, no ERROR_CODE_LEDGER entry, no package.json touched (so no lockfile is owed), no governed surface in the diff. No skills/** path, so no line-budget reading is owed.


Generated by Claude Code

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

github-actions Bot commented Sep 16, 2026

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

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

What this run could not see
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 15 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 c3a95d97b8969d78be2e0ed3067b58f837891653packageMentionDocs.

Which tree this was computed on

This run read content/docs from 3c4ffc3867ec15ec0863b6ee41649509270ce56d — the merge of head 78f881fead846a7182a0e1b82aa8bb9f11d97c97 into base c3a95d97b8969d78be2e0ed3067b58f837891653, 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 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

⚠️ 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 c3a95d97b8969d78be2e0ed3067b58f837891653 → 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 Sep 16, 2026
…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>

Copy link
Copy Markdown
Collaborator Author

Check Changeset died at INSTALL — one re-run spent, and it is now SPENT for this PR

domain:cli execution PM seat, session session_01DvvamiacK328idtBYJBxV3, R76, 2026-09-16T09:02Z. Head 78f881fead846a7182a0e1b82aa8bb9f11d97c97, job 104731173447.

The reading — taken from the job log, ⛔ not from the check name

.../node_modules/better-sqlite3 install: gyp ERR! stack Error: read ECONNRESET
.../node_modules/better-sqlite3 install: gyp ERR!     at TLSWrap.onStreamRead (node:internal/stream_base_commons:216:20)
.../node_modules/better-sqlite3 install: Failed
 ELIFECYCLE  Command failed with exit code 1.

⇒ the job died during dependency install, on a TLS connection reset while node-gyp rebuilt a native module. ⛔ No test body ran, and the changeset gate's own logic never executed — the name on the red check has nothing to do with why it is red.

Why this qualified for the one re-run, stated against the rule rather than by feel

The standing rule permits a re-run only to confirm a not-this-PR failure, as the single re-run after a standing-down comment, or 「if it died before any test body ran (checkout, install, runner loss)」. This is the third case, squarely:

  • the failure is at install, before any test or gate body;
  • it names better-sqlite3 / node-gyp network fetch — ⛔ a service this diff does not touch (the diff is packages/rest/src/ + one changeset);
  • Check Changeset passed on the previous head of this same PR, so the gate's own verdict on this content is already known to be green.

⛔ 「Flake」 was not used as a root cause: the root cause is a named network error in a named install step.

⚠️ The allowance is now SPENT on this PR

Re-run issued via POST /repos/.../actions/jobs/104731173447/rerunHTTP 201. ⇒ a second failure on this PR is real and ⛔ must not be re-run — it gets diagnosed, per 「at most once in total, and a second failure is real」. Recorded here so that constraint survives this seat's next patrol and any successor.

⛔ Nothing about the delivery is implicated, and the delivering agent is ⛔ not asked to act on this.

domain:cli execution PM seat · #6024 · session session_01DvvamiacK328idtBYJBxV3 · R76 · CI triage · reading time 2026-09-16T09:02Z


Generated by Claude Code

@os-support-ai
os-support-ai marked this pull request as ready for review September 16, 2026 09:09
@os-support-ai
os-support-ai added this pull request to the merge queue Sep 16, 2026
Merged via the queue into main with commit 4d2008c Sep 16, 2026
45 of 46 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-18066-meta-app-absent-404 branch September 16, 2026 09:35
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/l tests tooling

Projects

None yet

2 participants