Skip to content

fix(rest): type the REST door's author-side error responder to the closed ErrorCode union - #9222

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-9098-rest-door-typing
Aug 17, 2026
Merged

fix(rest): type the REST door's author-side error responder to the closed ErrorCode union#9222
os-project-manager merged 1 commit into
mainfrom
claude/issue-9098-rest-door-typing

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes#9098

The vocabulary half of this card was discharged by #8885 (PR #9177) and is not re-done here: no code is registered, the ledger is untouched. Verified on origin/main @ 88f97d528 before starting — FIELD_VISIBILITY_UNRESOLVED has its ledger row and its tier-3 emission is pinned. What was still standing is the structural half, and that is all this PR does.

The hole

packages/rest/src/error-response.ts exported sendError(res, error: any, object?). That name collided with the strict shared writer in @objectstack/typessendError(res, status, code: ErrorCode, message). The collision was not cosmetic. The cross-door parity note in packages/runtime cited "sendError's closed ErrorCode parameter" as the reason the REST door could not put an unregistered code on the wire. That sentence names a real, strict function and draws a false conclusion from it: route modules reached for the other one. The door read as closed while any handler could emit a fresh unregistered string silently.

Measured evidence that prose had already failed here: rest-server.ts imports the shared writer aliased as sendEnvelopeError, and carried four separate hand-written comments each warning the reader off the same conflation.

The fix — split the two responsibilities by name, type the author-side one

  • sendThrownError (renamed from sendError) — the CLASSIFICATION door. error: any is kept deliberately: this parameter binds a caught value, and narrowing what a thrown error may carry is what the card flags as the fork.
  • sendDeclaredFault (new) — the AUTHOR-side door, code: ErrorCode, for refusals this repo decides. It delegates to sendThrownError, so the wire answer is byte-identical.

All five author-declared emissions now route through it: sendFieldVisibilityFault (503) and the four UNAUTHENTICATED / PERMISSION_DENIED literals in rest-server.ts. Every one sits in compiled source, so tsc --noEmit checks them.

The stale citation in packages/runtime/src/package-door-error-parity.test.ts is corrected, and the CodeDoor note in dispatcher-error-vocabulary.ts now records the split. That citation is what made the hole invisible; leaving it would leave the next reader the same trap.

Fork clause — the public contract is NOT narrowed

Deliberately untouched: resolveErrorResponse's passthrough. A thrown error carrying an unregistered code still reaches the wire exactly as before. Narrowing that is an ADR-0112 public-contract decision (the dispatcher's equivalent needed a maintainer ruling); this PR narrows only what an author in this repo can hardcode, which is internal typing. No behaviour a client can observe changes.

Why not the two shapes the card offered, verbatim

The card's first shape — route sendFieldVisibilityFault through the closed-ErrorCode responder in @objectstack/typescannot be taken as written, and this is the main measurement of the round:

  1. That responder emits the nested{ success: false, error: { code, message } }. The REST door emits the flat{ error, code }. Switching moves the envelope POSITION — which is finding finding: rest-server.ts 里三个相邻 /meta handler 的错误信封是三种不同形状,其中两种不符合 ADR-0112 #7035, deliberately out of scope and still open.
  2. It applies no sanitization, so a declared 5xx would ship its own prose — re-opening the leak class sendError 的显式状态直通覆盖 400–599,5xx 的原始驱动报错绕过全部泄漏启发式直达客户端(metadata-protocol 有活体产出方) #5437 eliminated.
  3. It would break the rest-field-visibility-fault-envelope.test.ts pin that rest emits two error codes outside the closed ADR-0112 vocabulary — FIELD_VISIBILITY_UNRESOLVED has no ledger entry and no test anywhere #8885 just landed.

So the vocabulary narrowing and the dialect move are two separate decisions, and this PR takes only the first. sendDeclaredFault gets the closed union without touching the dialect.

The card's second shape (extend the #8087 gate) is taken, as the anti-regression half rather than the fix itself.

Gate extension

check:dispatcher-error-vocabulary gains a structural door-typing half. The vocabulary scan answers "is this code registered"; it cannot answer "could an unregistered one be written here tomorrow". Three assertions, each pinned in both directions by --self-test:

  1. sendDeclaredFault exists and narrows code to ErrorCode;
  2. nothing in that file exports the name sendError again;
  3. no author-declared literal is handed to the any door.

Assertion 3 is the one that earns its keep: FIELD_VISIBILITY_UNRESOLVEDis registered now, so the vocabulary scan is silent about it — yet reverting only its routing still reddens the gate. An anchor the gate cannot find is a finding, never a pass.

Verification — all at 592bd5e30 (final commit, clean tree)

checkresult
pnpm --filter '@objectstack/rest^...' buildpass (dependency closure, prefix filter)
pnpm --filter @objectstack/rest typecheckpass
pnpm --filter @objectstack/rest test122 files / 2011 tests passed
pnpm --filter @objectstack/runtime typecheckpass
pnpm --filter @objectstack/runtime test165 files / 2463 tests passed
check:dispatcher-error-vocabulary (+ --self-test)pass — 4 shapes + 37 assertions
check:route-envelope · check:authz-resolver · check:cross-package-test-inputspass
check:nul-bytes · check:error-code-casingpass
check:engine-double-contract · check:where-matcher · check:query-options-erasurepass
check:type-check-coverage · check:type-check-debt --re-measurepass — 33 entries re-measured, none above recorded
scripts/docs-audit/check-affected-docs.mjspass

Families derived with node scripts/pm/dispatch-gates.mjs against the actual changed paths. The type-check-debt ratchet refuses without a built workspace closure, so the full closure was built first and it is genuinely measured, not skipped.

Reverse verification — direction decided before running, and all three landed as predicted (red):

  • an unregistered code at a sendDeclaredFault call site:
    error-response.ts(1137,9): error TS2820: Type '"FIELD_VISIBILITY_UNRESOLVED_X"' is not assignable to type 'ErrorCode'. Did you mean '"FIELD_VISIBILITY_UNRESOLVED"'?
  • widening code: ErrorCode to code: string — gate exit 1, names the widening;
  • routing the fault back through sendThrownError — gate exit 1, names the bypass.

Each was run from the committed state and restored with git checkout; the tree was confirmed clean afterwards.

Changeset

None — skip-changeset label applied. The rename is entirely package-internal: packages/rest publishes a single . entry built from src/index.ts, which exports neither sendError nor anything else from error-response.ts. Wire answers are byte-identical. Nothing a consumer can observe changes.

Out of scope, untouched


Generated by Claude Code

…osed ErrorCode union
The REST door exported `sendError(res, error: any, object?)` — a name that
collided with the strict shared writer `sendError(res, status, code: ErrorCode,
message)` in `@objectstack/types`. A cross-door parity note cited the strict
one's closed parameter as the reason the REST door could not put an unregistered
code on the wire; route modules used the loose one. The door read as closed
while any handler could emit a fresh unregistered code silently.
Split the two responsibilities by name and type the author-side one:
- `sendThrownError` (renamed) keeps `error: any` deliberately — narrowing what
a CAUGHT error may carry is an ADR-0112 public-contract decision, not an
internal typing one.
- `sendDeclaredFault` is new: `code: ErrorCode`, for refusals this repo
DECIDES. It delegates to `sendThrownError`, so the wire answer is
byte-identical — same #5437 5xx prose-withholding, same #5423 4xx truncation,
same flat `{ error, code }` dialect (#7035 is untouched).
All five author-declared emissions now route through it and are checked by
`tsc --noEmit`. `check:dispatcher-error-vocabulary` gains a structural
door-typing half so the narrowing cannot be widened back silently.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26DJEHSBhhAQ6wwfsHNza
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/rest, @objectstack/runtime.

22 hand-written doc(s) reference the affected code — list omitted above 15 rows. Re-derive: node scripts/docs-audit/affected-docs.mjs --json origin/main.

4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@os-project-manageros-project-manager added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 17, 2026 — with Claude
@github-actionsgithub-actionsBot added tests and removed skip-changeset PR has no user-facing published change; bypasses the changeset gate labels Aug 17, 2026
@os-project-manageros-project-manager added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 17, 2026 — with Claude
@os-project-manager
os-project-manager marked this pull request as ready for review August 17, 2026 03:23
@os-project-manager
os-project-manager added this pull request to the merge queueAug 17, 2026
Merged via the queue into main with commit 0668f02Aug 17, 2026
32 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-9098-rest-door-typing branch August 17, 2026 03:34
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/mskip-changesetPR has no user-facing published change; bypasses the changeset gatetests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The REST door's own sendError overload does not narrow, so an unregistered error.code reaches the wire there too

2 participants

@os-project-manager@claude