Skip to content

fix(rest): GET /meta/:type/:name answers absence in one envelope, whichever arm produced it - #18691

Merged
os-support-ai merged 6 commits into
mainfrom
claude/issue-18402-meta-item-refusal-dialects
Sep 17, 2026
Merged

os-support-ai merged 6 commits into
mainfrom
claude/issue-18402-meta-item-refusal-dialects

Conversation

@os-support-ai

@os-support-ai os-support-ai commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator

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.

⚠️ CORRECTED after this body was first written — the original claim was falsified by my own later measurement, and is left visible rather than deleted. This paragraph first read: “No caller could have had a working dependency on the flat shape here … and the default deployment's uncached arm answered the nested shape all along. That is why this is declared a patch fix and not a narrowing.”

That is false for every type that does not bypass the cache. metadata.enableCache defaults to true, so object, view, flow, page and the rest took the cached arm — which threw, and therefore answered the flat shape. The nested shape was the minority path (app, dashboard, doc, book, and the three query flags), not the default. The measurement that falsified it: the Dogfood Regression Gate went red on showcase-anonymous-deny-surfaces.dogfood.test.ts, whose pin on GET /meta/object/:name was reading the flat body.code against a really booted showcase app that declares no enableCache.

⇒ This change moves the DEFAULT wire answer for non-app types, which is a larger consumer impact than the original sentence admitted — and it understated it in the author's favour, the one direction an inaccuracy must not run. The changeset is accordingly minor with a BREAKING banner and an ADR-0087 disposition, not patch; the non-determinism reasoning holds only ACROSS deployments, while within a single deployment enableCache is fixed and the flat shape was stable and dependable.

Acceptance notes

  • Noted, not filed — the success flag split on this handler. sendMetaItemAbsent emits {error:{…}} and the app-permission 403 emits {success:false,error:{…}}; BaseResponseSchema requires success. Already a named, ratcheted row under [tracking] Envelope-position convergence line for packages/rest's flat dialect — the live ratchet owner #9559, which is the carrier that will touch this file. Not a second card.
  • Noted, not filed — residual enumeration surface for a third-party protocol. A protocol that throws a bespoke 404 code on an app miss would keep the flat body while the unpublished app answers the emitter's, so the pair would differ. The in-repo metadata-protocol never throws for app (it resolves item-less), so this is unreachable today, and closing it would require destroying declaredCode — which ADR-0112 declares. Carrier: [tracking] Envelope-position convergence line for packages/rest's flat dialect — the live ratchet owner #9559.
  • NOT MEASURED — the field-visibility 503 arm. The measurement harness's masker override did not reach ObjectSchemaMaskEvaluationError, so that row answered 500 INTERNAL_ERROR in the rig rather than the 503 the code declares. It is outside this diff either way — sendFieldVisibilityFault is untouched — and is reported as unmeasured rather than as a reading.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3


Generated by Claude Code

@github-actions

github-actions Bot commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/rest, touching 3 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/rest/src/rest-server.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via RESOURCE_NOT_FOUND (literal, a string literal in a comment on a changed line; a string literal in thrownAnswerIsBareNotFound))
  • content/docs/api/error-catalog.mdx (via RESOURCE_NOT_FOUND (literal, a string literal in a comment on a changed line; a string literal in thrownAnswerIsBareNotFound))
  • content/docs/api/error-handling-client.mdx (via RESOURCE_NOT_FOUND (literal, a string literal in a comment on a changed line; a string literal in thrownAnswerIsBareNotFound))
  • content/docs/automation/webhooks.mdx (via RESOURCE_NOT_FOUND (literal, a string literal in a comment on a changed line; a string literal in thrownAnswerIsBareNotFound))
  • content/docs/kernel/contracts/metadata-service.mdx (via RESOURCE_NOT_FOUND (literal, a string literal in a comment on a changed line; a string literal in thrownAnswerIsBareNotFound))
  • content/docs/protocol/kernel/error-handling.mdx (via RESOURCE_NOT_FOUND (literal, a string literal in a comment on a changed line; a string literal in thrownAnswerIsBareNotFound))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via RESOURCE_NOT_FOUND (literal, a string literal in a comment on a changed line; a string literal in thrownAnswerIsBareNotFound))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/rest/src/rest-server.ts) — pages documenting those are invisible to this run
  • 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 f8eaf670454a69ebb965d9ec94aeed31303b1f4b → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 0042c1cc75b3dc8efd9b912a67d47681f18e73de — the merge of head 6c1456fc56b5a88461aa2848c8fab5e368106736 into base f8eaf670454a69ebb965d9ec94aeed31303b1f4b, 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 0042c1cc75b3dc8efd9b912a67d47681f18e73de && git checkout 0042c1cc75b3dc8efd9b912a67d47681f18e73de
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f8eaf670454a69ebb965d9ec94aeed31303b1f4b 6c1456fc56b5a88461aa2848c8fab5e368106736 && git checkout -B drift-repro f8eaf670454a69ebb965d9ec94aeed31303b1f4b && git merge --no-ff 6c1456fc56b5a88461aa2848c8fab5e368106736

node scripts/docs-audit/affected-docs.mjs --json f8eaf670454a69ebb965d9ec94aeed31303b1f4b

⚠️ 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 f8eaf670454a69ebb965d9ec94aeed31303b1f4b → 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 17, 2026
@github-actions github-actions Bot added size/l and removed size/m labels Sep 17, 2026
@github-actions github-actions Bot added size/m and removed size/l labels Sep 17, 2026
@os-support-ai
os-support-ai marked this pull request as ready for review September 17, 2026 16:18
@os-support-ai
os-support-ai added this pull request to the merge queue Sep 17, 2026
Merged via the queue into main with commit df1b275 Sep 17, 2026
44 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-18402-meta-item-refusal-dialects branch September 17, 2026 16:37
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

2 participants