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.

Claim Reading
ApiErrorSchema declares declaredCode truepackages/spec/src/api/contract.zod.ts:47
declaredCode absent from sendError's Pick true — the four members above
passing it is a compile error true — 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 rebuilt dist/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-actions github-actions Bot 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 queue Aug 26, 2026
Merged via the queue into main with commit db8c288 Aug 26, 2026
34 checks passed
@os-litant
os-litant deleted the claude/issue-11719-senderror-declaredcode branch August 26, 2026 02:25
os-litant pushed a commit that referenced this pull request Aug 27, 2026
… `declaredCode` (#12510)

`respondSharingError` asks `classifiedRefusalAnswer` — the flat `/data` door's
own classification, which already holds the demoted producer spelling — and
re-dressed only `status`, `code` and the message into the nested ADR-0112 D5
envelope. The author's own code was dropped one line below the local that
carried it: nothing invalid shipped, since the closed `code` still carried the
member the status derives, which is what made the loss silent and
one-directional.

The pair is carried, not recomputed. `refusal.body.declaredCode` is the shared
rule's OUTPUT (`thrownCodeFields` = `resolveThrownHttpError` +
`demotedDeclaredCode`), so "presence means demotion" arrives with the value; the
resolver's RAW `declaredCode` is deliberately not read, because it is set for a
REGISTERED spelling too and forwarding it would put two spellings of one fact on
every registered refusal.

The comment that explained the omission is repaired in the same change. It said
`sendError`'s `extra` would not accept the field — true when written, false
since `db8c288` (PR #12403) added `declaredCode` to the writer's `Pick`. Left
alone it reads as "blocked upstream, nothing to do here" to the next author,
which is the direction that costs a repair rather than an understanding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 1, 2026
… `declaredCode` (objectstack-ai#12510) (objectstack-ai#12670)

`respondSharingError` asks `classifiedRefusalAnswer` — the flat `/data` door's
own classification, which already holds the demoted producer spelling — and
re-dressed only `status`, `code` and the message into the nested ADR-0112 D5
envelope. The author's own code was dropped one line below the local that
carried it: nothing invalid shipped, since the closed `code` still carried the
member the status derives, which is what made the loss silent and
one-directional.

The pair is carried, not recomputed. `refusal.body.declaredCode` is the shared
rule's OUTPUT (`thrownCodeFields` = `resolveThrownHttpError` +
`demotedDeclaredCode`), so "presence means demotion" arrives with the value; the
resolver's RAW `declaredCode` is deliberately not read, because it is set for a
REGISTERED spelling too and forwarding it would put two spellings of one fact on
every registered refusal.

The comment that explained the omission is repaired in the same change. It said
`sendError`'s `extra` would not accept the field — true when written, false
since `db8c288` (PR objectstack-ai#12403) added `declaredCode` to the writer's `Pick`. Left
alone it reads as "blocked upstream, nothing to do here" to the next author,
which is the direction that costs a repair rather than an understanding.


Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd

Co-authored-by: Claude <noreply@anthropic.com>
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

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