Repository navigation
fix(rest): GET /meta/:type/:name answers absence in one envelope, whichever arm produced it - #18691
Conversation
…its one emitter Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude <noreply@anthropic.com>
…on every arm Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
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 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
|
…ot the flat one Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude <noreply@anthropic.com>
…s minor (#18402) Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude <noreply@anthropic.com>
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.
sendDeclaredFaulthas 4 emission sites (all inrest-server.ts: two onGET /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 fromresolveErrorResponse, shared withsendThrownError(7 sites) andhandleRouteError(37 sites). Consumers reading the flatcode: 496 assertions across the repo's test files, plus live readers inplugin-auth(e?.body?.code, 2 sites),rest-server.ts:11935, and 9 internal reads inerror-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.stringifyoutput before and after, inmeta-item-absent-404.test.ts§2 and §5. They were identical at551139bb7and 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/mainat551139bb7Not copied from the card. Every refusal arm of this handler driven, wire bytes dumped:
body.error.code{"error":{"code":"RESOURCE_NOT_FOUND","message":"Metadata item not found or access denied."}}{"success":false,"error":{"code":"PERMISSION_DENIED","message":"…"}}{"error":"This documentation is limited to holders…","code":"PERMISSION_DENIED"}{"error":"This documentation requires sign-in","code":"UNAUTHENTICATED"}{"error":"Metadata item view/no_such_view not found","code":"RESOURCE_NOT_FOUND"}{"error":"Metadata item object/acct not found","code":"RESOURCE_NOT_FOUND"}{"error":"Internal server error","code":"SERVICE_UNAVAILABLE"}{"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/:nameroutes a bare404 RESOURCE_NOT_FOUNDtosendMetaItemAbsent— 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 thehandleRouteErrorit 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 foundprose 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_DRAFTis a 404 on this same route — the Studio designer's?state=draftprobe — and it says the item is there and its draft is not. It is pinned byte-for-byte inrest-expected-error-logging.test.tsandrest-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 indeclaredCode, 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_FOUNDderived into its body.thrownCodeFieldsanswers{}— 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-envelopepinsrest-server.tsatstringError 44 / siblingCode 69, ratchet#9559 (option 1: convert onto the shared sendOk/sendError)). Converting two ofsendDeclaredFault's four emissions here would mint a new divergence: the same audience refusal answering two shapes depending on whether/meta/:type/:nameor/meta/book/:name/treeserved it. Same for thesuccessflag — the nested-with-no-successshape is already a named, ratcheted row coveringrest-server.ts,query-allowlist.tsandquery-multiplicity.ts.Evidence
Reverse verification — the two source files reverted to
551139bb7(mutation proven on disk by blob hash6e37390…/91e3cf9…, not by exit code), the pins re-run, restore re-verified by blob hash againstHEADandgit diff HEADempty:The 19 that stay green under ablation are the controls: §2's byte-identity, the #8013 403 partition, and the
NO_DRAFTpins 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 ownVERDICT command-exitline):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.codeas 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.mdxalready 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 inapi/index.mdxand the accessor prose inwire-format.mdx(positive, 2), and returns nothing for a nonsense token (negative).What moves for consumers
A caller that branched on
body.codefor this route's absence readsbody.error.codenow. Every other refusal on this route (400, 401, 403,NO_DRAFT's 404, 503) is byte-identical to before.Acceptance notes
successflag split on this handler.sendMetaItemAbsentemits{error:{…}}and the app-permission 403 emits{success:false,error:{…}};BaseResponseSchemarequiressuccess. 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.appmiss would keep the flat body while the unpublished app answers the emitter's, so the pair would differ. The in-repometadata-protocolnever throws forapp(it resolves item-less), so this is unreachable today, and closing it would require destroyingdeclaredCode— which ADR-0112 declares. Carrier: [tracking] Envelope-position convergence line for packages/rest's flat dialect — the live ratchet owner #9559.ObjectSchemaMaskEvaluationError, so that row answered500 INTERNAL_ERRORin the rig rather than the 503 the code declares. It is outside this diff either way —sendFieldVisibilityFaultis 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