Skip to content

fix(types): let sendError's extra carry declaredCode - #12403

Merged
os-litant merged 1 commit into
mainfrom
claude/issue-11719-senderror-declaredcode
Aug 26, 2026
Merged

fix(types): let sendError's extra carry declaredCode#12403
os-litant merged 1 commit into
mainfrom
claude/issue-11719-senderror-declaredcode

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes#11719

ApiErrorSchema has declared declaredCode since the ADR-0112 amendment (#9106) — the open, author-authored channel that carries a metadata app's own .code verbatim when the spelling is not a member of the closed code vocabulary. #9232 extended the demote to the flat /data door, which emits the pair today. The shared nested-envelope writer could not: sendError's extra was typed Pick<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 code still 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 read declaredCode finding nothing there. Declared-but-unemittable is a declared = enforced gap; this closes it at the one writer rather than per module.

The change

declaredCode joins the Pick. 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's thrownCodeFields already does. That helper answers undefined when the producer's spelling is already the vocabulary member sitting in code, 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 @ cdbd920

The card was written 2026-08-24; every claim in it was re-measured here rather than reconciled.

ClaimReading
ApiErrorSchema declares declaredCodetruepackages/spec/src/api/contract.zod.ts:47
declaredCode absent from sendError's Picktrue — the four members above
passing it is a compile errortrue — probe returned TS2353: … 'declaredCode' does not exist in type 'Pick<…>', while a control line passing details in the same file compiled clean

The card's own quote of the signature is incomplete as stored: the body sanitizer ate the <…> type arguments, so it renders as extra?: 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 ThrownHttpError at its sendError call: packages/rest/src/package-routes.ts, whose sendThrownError already passes thrown.details through 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) construct code and message as literals at the call site and never consult the shared resolver — zero hits for resolveThrownHttpError / classifiedRefusalAnswer / demotedDeclaredCode, on an instrument that returned 4 hits on package-routes.ts in the same sweep. For those six the widened Pick is inert until they adopt the resolver.

The reachable producer the card names — a sandboxed hook on a sys_record_share write — answers through the record-share family in packages/rest/src/rest-server.ts, which records the gap in its own words at the respondError call:

⚠️ Measured and NOT repaired here: an UNREGISTERED producer code is demoted by the shared resolver to a declaredCode sibling (ADR-0112 #9232), and sendError's extra does not accept that field — so the author's own spelling is dropped on this family while /data carries it.

Both call sites are deliberately untouched.rest-server.ts is held by #11926 and this card is fenced out of it; package-routes.ts is 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 44012543

  • pnpm --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 testTest Files 13 passed (13) · Tests 370 passed (370).
  • Repo-wide eslint . --no-inline-config → exit 0 in 52s. Run whole; no narrowing claimed.
  • Gate families re-derived in-worktree with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, then run at this head: check:route-envelope (which names response-envelope.ts as 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.
  • The widening reaches consumers: the rebuiltdist/index.d.ts carries extra?: 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 moved 5d84c8947c058eb9):

  • tsc --noEmitexit 2, three errors at the new pins — TS2559 / TS2353 naming declaredCode against the four-member Pick.
  • vitestexit 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 main too. Restore leg verified byte-identical to the HEAD blob (git diff HEAD → 0 lines; hash back to 5d84c894).

The body assertions are still worth their place for a different reason: they parse with the real ApiErrorSchema and assert declaredCode is still on the object after the parse. ApiErrorSchema is a plain z.object that strips undeclared keys, so a .success assertion alone would pass against a schema declaring nothing — a sibling case pins that the instrument says no by showing an undeclared namespace being stripped from the same parse.


Generated by Claude Code

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/types, touching 6 documentable anchor(s).

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

  • content/docs/api/client-sdk.mdx(via httpStatus (literal), requestId (literal))
  • content/docs/api/error-catalog.mdx(via httpStatus (literal), requestId (literal))
  • content/docs/api/error-handling-client.mdx(via httpStatus (literal), requestId (literal))
  • content/docs/api/error-handling-server.mdx(via httpStatus (literal))
  • content/docs/api/index.mdx(via httpStatus (literal))
  • content/docs/api/wire-format.mdx(via httpStatus (literal))
  • content/docs/automation/approvals.mdx(via requestId (literal))
  • content/docs/automation/flows.mdx(via httpStatus (literal))
  • content/docs/deployment/production-readiness.mdx(via requestId (literal))
  • content/docs/kernel/runtime-services/audit-service.mdx(via requestId (literal))
  • content/docs/plugins/development.mdx(via httpStatus (literal))
  • content/docs/protocol/kernel/error-handling.mdx(via requestId (literal))
  • content/docs/protocol/kernel/http-protocol.mdx(via httpStatus (literal))

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

  • content/docs/releases/v17.mdx(via declaredCode (literal), httpStatus (literal), requestId (literal))

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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 — 1 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 2af5eacc5dfa9e1505544e23d62ff962dadca0capackageMentionDocs.

Which tree this was computed on

This run read content/docs from c80b8796065d21e50456e3b84f1d11a296d0867b — the merge of head 44012543e2d5492284c6200d509f51ef686af676 into base 2af5eacc5dfa9e1505544e23d62ff962dadca0ca, 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 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

⚠️ 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 2af5eacc5dfa9e1505544e23d62ff962dadca0ca → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 26, 2026
@os-litant
os-litant marked this pull request as ready for review August 26, 2026 02:06
@os-litant
os-litant added this pull request to the merge queueAug 26, 2026
Merged via the queue into main with commit db8c288Aug 26, 2026
34 checks passed
@os-litant
os-litant deleted the claude/issue-11719-senderror-declaredcode branch August 26, 2026 02:25
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

types: sendError's extra cannot carry declaredCode, so every nested-envelope route silently drops an unregistered producer's own code spelling

2 participants

@os-litant@claude