Skip to content

fix(rest,runtime,types): the direct-mount package door answers a coded refusal with its own status and code - #8088

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-8016-package-door-coded-error-mapping
Aug 12, 2026
Merged

fix(rest,runtime,types): the direct-mount package door answers a coded refusal with its own status and code#8088
hotlong merged 1 commit into
mainfrom
claude/issue-8016-package-door-coded-error-mapping

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Fixes#8016

/api/v1/packages has two HTTP doors and they disagreed about what a thrown, coded, status-carrying error means. The dispatcher twin reads .status first and answers 409 DESTRUCTIVE_CHANGE; the direct-mount REST registrar had four catch-alls at package-routes.ts doing sendError(res, 500, 'INTERNAL_ERROR', (error as Error).message) regardless. That registrar mounts first in the production stack, so the status-blind answer was the live one — a caller who was refused was told the platform had broken.

The defect, reproduced (not read from code)

The card recorded honestly that the mechanism had never been driven end to end. It has now. Reverting one site and running the new suite:

× POST /packages/publish: the established 409 (`status`)
AssertionError: expected 500 to be 409
× POST /packages/publish: answers exactly what resolveThrownHttpError says for "coded 409"
AssertionError: expected { status: 500, code: 'INTERNAL_ERROR' } to deeply equal { status: 409, …(1) }
Tests 9 failed | 38 passed (47)

Nine failures, all on the one reverted site, every one of them the issue's claim. The other three sites stayed green, which is what attributes the failures to that site rather than to the harness.

One mapping, two doors

The ruling was to reuse the dispatcher's mapper, not hand-write a second ladder. errorFromThrown is a private method of HttpDispatcher and is not importable from packages/rest@objectstack/runtime depends on @objectstack/rest, so the arrow only points one way. The rule therefore moved to where both doors can reach it:

resolveThrownHttpError in @objectstack/types — beside sendOk/sendError and looksLikeInternalErrorLeak, which live there for exactly this argument ("a property of the boundary, not of one router"). HttpDispatcher.errorFromThrown is now its other caller and is three lines. validation-failure.ts moved with it for reachability and is re-exported from its old module path, so all 16 runtime import sites are untouched.

QuestionAnswer
status.status.statusCode → 400 if a validation failure → caller's fallback
codeVALIDATION_FAILED if one → the declared .code → derived from the status
message.message when a string → String(error)

The 500 survives. A throw declaring neither status nor a registered code is a genuine fault and still answers 500 INTERNAL_ERROR — pinned per site.

The comment at the doc block above registerPackageRoutes

It read "an unexpected throw is INTERNAL_ERROR". It was right, and the code had drifted wider than it: the four catch-alls treated every throw as unexpected. The word doing the work is "unexpected" — a throw that declares its own status and a registered code is a refusal, not a fault. The sentence is unchanged because it was never what was wrong; a [#8016] paragraph now records which of the two was drifting.

Two spellings of the code — a constraint found in flight, not a design choice

Narrowing both doors to the closed ADR-0112 vocabulary turned four existing runtime tests red: STORAGE_FAILURE, FLOW_FAILED and DUPLICATE are registered nowhere and are pinned as reaching the wire verbatim. Rewriting those pins is a contract decision, not this card's.

So the resolver returns both, from one function:

  • code — narrowed to StandardErrorCode ∪ ERROR_CODE_LEDGER. The REST door needs it: sendError takes the closed ErrorCode type, and package-envelope.conformance.test.ts parses this module's bodies against the ledger, so an unregistered code there is a failing test rather than a wire answer.
  • declaredCode — verbatim, what the dispatcher has always emitted.

The doors therefore agree on status unconditionally and on code for every registered code, differing only where a producer emits one the ledger does not know — already a contract violation on either door. That difference is documented at the resolver and pinned explicitly, so it stays stated rather than drifting. Filed as #8087.

Pins

  • Four sites, both halves (ADR-0112). Each route, for a coded 400, the established 409, and a statusCode-spelled 409: its own status and its own code. GET /packages is driven through the gate resolver rather than a service — both of its data sources sit in inner try/catch by design, so nothing below reaches its outer catch; the composition wires that resolver to the RestServer's identity/RBAC resolution, which is exactly the code that raises coded 401/403.
  • Agreement, by comparison rather than literals. Each door is asserted equal to the shared resolver's answer from its own side — REST in package-routes-coded-error-mapping.test.ts, dispatcher in package-door-error-parity.test.ts, using the real domainDeps.errorFromThrown. The halves compose, and either door growing a mapping of its own turns its own half red. A one-file two-door comparison is not writable: neither package can see the other's door.
  • Anti-vacuity, three ways. Every case asserts its throwing seam was actually called; the shapes are asserted to produce more than three distinct answers (so the comparison is not two constants); and the same mounts are shown answering 200 and 404 normally.
  • Default arm — an uncoded throw still answers 500 INTERNAL_ERROR, per site.

Verification

  • @objectstack/rest — 98 files / 1613 passed. @objectstack/runtime — 143 files / 2179 passed. @objectstack/types — 9 files / 230 passed.
  • pnpm typecheck clean on all three.
  • check:type-check-debt does not rise: @objectstack/rest measures 155 (recorded 155), @objectstack/runtime227 (recorded 227) — both re-measured with the test exclusion removed. Zero errors attributable to either new test file.
  • check:nul-bytes, check:route-envelope, check:error-code-casing green; ESLint clean on all changed files.

Out of scope, filed

Wire impact

This changes HTTP status codes on a live surface — said plainly in the changeset. Requests that come back 500 INTERNAL_ERROR today will come back as the refusal they always were. A client keying on 500 to mean "platform down, retry later" for these routes must key on code. No route, path, verb or success body changes.


Generated by Claude Code

…h its own status and code (#8016)
`/api/v1/packages` has two HTTP doors and they disagreed. The runtime
dispatcher's `errorFromThrown` reads a thrown error's own `.status`/`.code`
and answers with them; the direct-mount REST registrar had four catch-alls
doing `sendError(res, 500, 'INTERNAL_ERROR', …)` regardless. That registrar
mounts first in the production stack, so the status-blind answer was the live
one: a caller who was refused (`409 DESTRUCTIVE_CHANGE` from the metadata
protocol, reached through `packageService.publish`/`.delete`) was told the
platform had broken.
The mapping is not reimplemented at the second door — two copies is how the
divergence arose. It moved to `resolveThrownHttpError` in `@objectstack/types`,
beside the envelope writer and the leak predicate that live there for the same
reason, and the dispatcher's `errorFromThrown` is now its other caller. It
could not live in `@objectstack/runtime`: that package depends on
`@objectstack/rest`, so the import can only point one way.
The 500 default arm survives: a throw declaring neither status nor a
registered code is a genuine fault and still answers 500 INTERNAL_ERROR.
The resolver returns two spellings of the code — `code` narrowed to the
declared ADR-0112 vocabulary (what `sendError`'s closed `ErrorCode` parameter
and the REST door's conformance suite require) and `declaredCode` verbatim
(what the dispatcher has always emitted; `STORAGE_FAILURE`, `FLOW_FAILED` and
`DUPLICATE` are unregistered and pinned by three existing suites). Both come
from one function, so that difference is stated rather than drifting. Whether
the dispatcher's `error.code` should be closed too is filed separately.
`validation-failure.ts` moved to `@objectstack/types` for reachability and is
re-exported from its old module path; every import site is unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3Kurx8qufrDzNjk4rag7V
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 12, 2026 3:00pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

24 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/connect-mcp.mdx(via @objectstack/rest)
  • content/docs/api/client-sdk.mdx(via packages/runtime)
  • content/docs/api/error-handling-server.mdx(via @objectstack/rest)
  • content/docs/api/index.mdx(via @objectstack/rest, @objectstack/runtime)
  • content/docs/api/wire-format.mdx(via @objectstack/runtime)
  • content/docs/automation/hook-bodies.mdx(via @objectstack/runtime)
  • content/docs/concepts/metadata-lifecycle.mdx(via @objectstack/runtime)
  • content/docs/concepts/north-star.mdx(via packages/runtime)
  • content/docs/data-modeling/drivers.mdx(via @objectstack/runtime)
  • content/docs/deployment/index.mdx(via @objectstack/runtime)
  • content/docs/deployment/production-readiness.mdx(via @objectstack/runtime)
  • content/docs/deployment/single-project-mode.mdx(via @objectstack/runtime)
  • content/docs/deployment/vercel.mdx(via @objectstack/runtime)
  • content/docs/getting-started/your-first-project.mdx(via @objectstack/runtime)
  • content/docs/kernel/cluster.mdx(via @objectstack/runtime)
  • content/docs/permissions/authentication.mdx(via @objectstack/rest, @objectstack/runtime)
  • content/docs/permissions/authorization.mdx(via packages/runtime)
  • content/docs/permissions/system-context.mdx(via packages/rest, packages/runtime)
  • content/docs/plugins/index.mdx(via @objectstack/rest)
  • content/docs/plugins/packages.mdx(via @objectstack/rest, @objectstack/runtime, @objectstack/types)
  • content/docs/protocol/kernel/http-protocol.mdx(via @objectstack/rest, @objectstack/runtime)
  • content/docs/protocol/kernel/i18n-standard.mdx(via packages/rest)
  • content/docs/protocol/kernel/index.mdx(via @objectstack/runtime)
  • content/docs/protocol/kernel/lifecycle.mdx(via @objectstack/runtime)

3 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/rest, @objectstack/runtime)
  • content/docs/releases/v12.mdx(via @objectstack/rest)
  • content/docs/releases/v17.mdx(via @objectstack/rest, @objectstack/runtime)

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.

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.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The direct-mount REST package door answers 500 INTERNAL_ERROR for coded 4xx errors the dispatcher twin maps correctly

2 participants

@hotlong@claude