Uh oh!
There was an error while loading. Please reload this page.
fix(types): let sendError's extra carry declaredCode - #12403
Conversation
`ApiErrorSchema` has declared `declaredCode` since the ADR-0112 amendment (#9106) and the flat `/data` door emits it, but `sendError`'s `extra` was typed `Pick<ApiError, 'category' | 'httpStatus' | 'details' | 'requestId'>` — so passing a demoted producer spelling on a nested-envelope route was a compile error, and the author's own code was dropped while the derived closed member shipped in its place. Additive: `declaredCode` joins the `Pick`. No call site changes and no wire byte moves for any body already emitted; the contract's accept set is untouched, since the schema always permitted the field. Presence still means demotion, and the writer does not re-derive that — the caller passes `demotedDeclaredCode(thrown)`, exactly as the flat door's `thrownCodeFields` does. Pinned by driving the real resolver pipeline and parsing the emitted body with the real `ApiErrorSchema`, asserting the field survives the parse (a plain `z.object` strips undeclared keys, so `.success` alone would pass against a schema declaring nothing). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
📓 Docs Drift CheckThis PR changes 1 package(s): 13 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 — 1 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 c80b8796065d21e50456e3b84f1d11a296d0867b && git checkout c80b8796065d21e50456e3b84f1d11a296d0867b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2af5eacc5dfa9e1505544e23d62ff962dadca0ca 44012543e2d5492284c6200d509f51ef686af676 && git checkout -B drift-repro 2af5eacc5dfa9e1505544e23d62ff962dadca0ca && git merge --no-ff 44012543e2d5492284c6200d509f51ef686af676
node scripts/docs-audit/affected-docs.mjs --json 2af5eacc5dfa9e1505544e23d62ff962dadca0ca
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11719
ApiErrorSchemahas declareddeclaredCodesince the ADR-0112 amendment (#9106) — the open, author-authored channel that carries a metadata app's own.codeverbatim when the spelling is not a member of the closedcodevocabulary. #9232 extended the demote to the flat/datadoor, which emits the pair today. The shared nested-envelope writer could not:sendError'sextrawas typedPick<ApiError, 'category' | 'httpStatus' | 'details' | 'requestId'>, so passing a demoted spelling was a compile error and every route answering the nested envelope dropped it.Nothing invalid shipped — the closed
codestill carried the member derived from the status — which is precisely what made the loss silent and one-directional: the author's spelling gone, and a consumer told by the ADR to readdeclaredCodefinding nothing there. Declared-but-unemittable is adeclared = enforcedgap; this closes it at the one writer rather than per module.The change
declaredCodejoins thePick. Additive: no call site changes, no wire byte moves for any body already emitted, and the contract's accept set is untouched because the schema has always permitted the field.Presence still MEANS demotion, and the writer does not re-derive that — the caller passes
demotedDeclaredCode(thrown), exactly as the flat door'sthrownCodeFieldsalready does. That helper answersundefinedwhen the producer's spelling is already the vocabulary member sitting incode, so a registered refusal never carries two spellings of one fact. Vocabulary and position stay two decisions (#9232), so the demotion rule stays with the resolver that owns it.Premise re-measured on
origin/main@cdbd920The card was written 2026-08-24; every claim in it was re-measured here rather than reconciled.
ApiErrorSchemadeclaresdeclaredCodepackages/spec/src/api/contract.zod.ts:47declaredCodeabsent fromsendError'sPickTS2353: … 'declaredCode' does not exist in type 'Pick<…>', while a control line passingdetailsin the same file compiled cleanThe card's own quote of the signature is incomplete as stored: the body sanitizer ate the
<…>type arguments, so it renders asextra?: Pick,. The signature was read from the tree, not from the quote.Blast radius — the census the card left open
Of the seven modules named, exactly one holds a resolved
ThrownHttpErrorat itssendErrorcall:packages/rest/src/package-routes.ts, whosesendThrownErroralready passesthrown.detailsthrough and is therefore the cheapest place to actually put a spelling on the wire. The other six (storage-routes,settings-routes,admin-routes,external-datasource-routes,i18n-service-plugin,share-link-routes) constructcodeandmessageas literals at the call site and never consult the shared resolver — zero hits forresolveThrownHttpError/classifiedRefusalAnswer/demotedDeclaredCode, on an instrument that returned 4 hits onpackage-routes.tsin the same sweep. For those six the widenedPickis inert until they adopt the resolver.The reachable producer the card names — a sandboxed hook on a
sys_record_sharewrite — answers through the record-share family inpackages/rest/src/rest-server.ts, which records the gap in its own words at therespondErrorcall:⛔ Both call sites are deliberately untouched.
rest-server.tsis held by #11926 and this card is fenced out of it;package-routes.tsis outside this claim's declared file surface. So this PR restores the capability and puts no new field on any wire by itself — stated plainly rather than implied.Evidence — all at
44012543pnpm --filter @objectstack/types typecheck→ clean. Both edited files confirmed inside the tsc program via--listFiles(1 hit each), so "typecheck clean" is a reading about this diff and not about source nothing compiled.pnpm --filter @objectstack/types test→Test Files 13 passed (13) · Tests 370 passed (370).eslint . --no-inline-config→ exit 0 in 52s. Run whole; no narrowing claimed.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, then run at this head:check:route-envelope(which namesresponse-envelope.tsas its own gate source),check:engine-double-contract,check:where-matcher,check:slot-lookup,check:cross-package-test-inputs,check:nul-bytes,check:test-source-alias,check:type-source-resolution,check:published-files,check:page-declaration-shape,check:empty-changeset,check:changeset-gate-self-tests,check:objectui-changeset,check-adr-0087-registration,check-changeset-no-major,release-rehearsal-clone --self-test— all exit 0.dist/index.d.tscarriesextra?: Pick<ApiError, 'category' | 'httpStatus' | 'details' | 'requestId' | 'declaredCode'>, so the reading is off a fresh artifact rather than a cache.Reverse verification
Reverting only the
Pick(mutation proven on disk: pre-fix form present 1, fixed form present 0, blob hash moved5d84c894→7c058eb9):tsc --noEmit→ exit 2, three errors at the new pins —TS2559/TS2353namingdeclaredCodeagainst the four-memberPick.vitest→ exit 0, 13 passed.The second half is the honest and load-bearing one: the repair is type-only, so the runtime suite is blind to it and stays green in both states. These pins are a typecheck pin, not a runtime pin, and the assertions about the emitted body would pass on unpatched
maintoo. Restore leg verified byte-identical to theHEADblob (git diff HEAD→ 0 lines; hash back to5d84c894).The body assertions are still worth their place for a different reason: they parse with the real
ApiErrorSchemaand assertdeclaredCodeis still on the object after the parse.ApiErrorSchemais a plainz.objectthat strips undeclared keys, so a.successassertion alone would pass against a schema declaring nothing — a sibling case pins that the instrument says no by showing an undeclarednamespacebeing stripped from the same parse.Generated by Claude Code