Uh oh!
There was an error while loading. Please reload this page.
fix(rest,runtime,types): the direct-mount package door answers a coded refusal with its own status and code - #8088
Conversation
…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
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 3 package(s): 24 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also reference the affected code. These are read-only:
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#8016
/api/v1/packageshas two HTTP doors and they disagreed about what a thrown, coded, status-carrying error means. The dispatcher twin reads.statusfirst and answers409 DESTRUCTIVE_CHANGE; the direct-mount REST registrar had four catch-alls atpackage-routes.tsdoingsendError(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:
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.
errorFromThrownis a private method ofHttpDispatcherand is not importable frompackages/rest—@objectstack/runtimedepends on@objectstack/rest, so the arrow only points one way. The rule therefore moved to where both doors can reach it:resolveThrownHttpErrorin@objectstack/types— besidesendOk/sendErrorandlooksLikeInternalErrorLeak, which live there for exactly this argument ("a property of the boundary, not of one router").HttpDispatcher.errorFromThrownis now its other caller and is three lines.validation-failure.tsmoved with it for reachability and is re-exported from its old module path, so all 16 runtime import sites are untouched..status→.statusCode→ 400 if a validation failure → caller's fallbackVALIDATION_FAILEDif one → the declared.code→ derived from the status.messagewhen 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
registerPackageRoutesIt 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_FAILEDandDUPLICATEare 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 toStandardErrorCode ∪ ERROR_CODE_LEDGER. The REST door needs it:sendErrortakes the closedErrorCodetype, andpackage-envelope.conformance.test.tsparses 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
statusCode-spelled 409: its own status and its own code.GET /packagesis driven through the gate resolver rather than a service — both of its data sources sit in innertry/catchby design, so nothing below reaches its outer catch; the composition wires that resolver to theRestServer's identity/RBAC resolution, which is exactly the code that raises coded 401/403.package-routes-coded-error-mapping.test.ts, dispatcher inpackage-door-error-parity.test.ts, using the realdomainDeps.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.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 typecheckclean on all three.check:type-check-debtdoes not rise:@objectstack/restmeasures 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-casinggreen; ESLint clean on all changed files.Out of scope, filed
looksLikeInternalErrorLeakwithholding to 5xx bodies, unlike the dispatcher twin and unlikerest-server.ts. Behaviour is unchanged in both directions by this PR (the old 500 arm shipped the message verbatim too), so nothing here is a regression — the gap is simply now easier to reach deliberately.error.codevalues verbatim — three suites pin bodies thatApiErrorSchemawould reject #8087 — the dispatcher'serror.codeadmits unregistered codes; three suites pin bodiesApiErrorSchemawould reject.Wire impact
This changes HTTP status codes on a live surface — said plainly in the changeset. Requests that come back
500 INTERNAL_ERRORtoday will come back as the refusal they always were. A client keying on500to mean "platform down, retry later" for these routes must key oncode. No route, path, verb or success body changes.Generated by Claude Code