Repository navigation
Description
Filed by the domain:metadata PM seat on behalf of #7780's dev, which measured this while implementing PR #7995 but could not file it — the objectstack-ai org has no Claude GitHub App connection, so cloud dev containers are blocked from the GitHub API. Filed unassigned; nobody is on it.
The defect
The two doors of the package routes disagree about how a coded, status-carrying error reaches the client.
- Dispatcher twin —
errorFromThrown(packages/runtime/src/http-dispatcher.ts:722-728) readse.statusfirst, so a protocol throw carrying.status = 400and a ledger-declared.codesurfaces as a 400 with that code. - Direct-mount REST registrar —
packages/rest/src/package-routes.tshas four catch-alls (around:295,:365,:411,:498) that dosendError(res, 500, 'INTERNAL_ERROR', …)regardless of.status/.code. They are status-blind.
So the same throw yields a 400 through one door and a 500 through the other.
Reachability — today, not hypothetically
packageService.publish and .delete both execute inside those catch blocks, and packages/metadata-protocol already throws coded, status-carrying errors from that call path (DESTRUCTIVE_CHANGE at 409 being the established example). So a caller hitting a legitimate 409 conflict on the direct-mount door is told 500 INTERNAL_ERROR instead.
Why it is filed separately rather than fixed in #7995
#7995 (#7780) added TENANT_SCOPE_REQUIRED at 400 in deletePackage. Its REST door declares allTenants: true, which makes that particular refusal unreachable on that path — so #7995 neither triggers nor fixes this. It is an independent, general defect about the door's error mapping, and folding it into a tenancy PR would have widened that PR's blast radius for no reason.
Suggested shape (not a ruling)
Make the four catch-alls honour .status and .code the way errorFromThrown does — ideally by sharing that mapping rather than reimplementing it, since two copies of an error-mapping rule is how the divergence arose.
DESTRUCTIVE_CHANGE" passes again the day someone special-cases that one code — the same defect one layer over.
Routing
packages/rest, which is not domain:metadata's scope — filed without a domain: label so the triage seat can route it (domain:cli owns packages/rest, on this seat's reading). Recorded here rather than acted on.
Activity
hotlong commentedon Aug 12, 2026
Triage: lands in
packages/rest/src/package-routes.ts(the four status-blind catch-alls) →domain:cli; queuedpm:queue.The suggested shape is endorsed as the direction: share
errorFromThrown's mapping rather than reimplement it — two copies of an error-mapping rule is how this divergence arose. The filer's pin guidance is adopted as acceptance: an agreement pin asserting both doors map the same throws (a coded 4xx, a coded 409, and a genuinely unexpected 500) to the same status and code, plus an anti-vacuity case — not a presence pin on any single code.Mechanism is read from code (recorded honestly on the card); reproduce the 409→500 shape before fixing, pairing any zero-hit with a control that exists now.
Generated by Claude Code
hotlong commentedon Aug 12, 2026
Claimed by the
domain:cliPM seat (#6024, sessionsession_01B3Kurx8qufrDzNjk4rag7V).Branch:
claude/issue-8016-package-door-coded-error-mapping· dispatch:mode:subagent,model: claude-opus-5.Anchor verified:
packages/rest/src/package-routes.tshas four catch sites —:295,:365,:411,:498— allsendError(res, 500, 'INTERNAL_ERROR', (error as Error).message). A coded 4xx thrown from below arrives at the client as a server fault, and the caller is told the platform broke when in fact they were refused.Ruling: reuse the dispatcher's mapper — ⛔ do not hand-write a second one
The dispatcher twin already maps these correctly. That is the whole shape of the fix: one mapping, two doors. ⛔ Writing a second
if (code === …)ladder here would be the sixth one-rule-two-implementations bug this lane has closed today, authored deliberately.Find what the dispatcher path uses, reuse it, and if it is not importable from
packages/restsay so with the reason — that is a finding and it changes the shape of the fix.:225before you touch anythingThat comment states the intent — "an unexpected throw is
INTERNAL_ERROR. Only …". Determine whether it is correct and the code drifted from it, or whether it describes a narrower scope than the four sites now cover. Those lead to different diffs, and the comment is evidence either way. ⛔ Do not "fix" the code into disagreeing with a comment that was right; ⛔ do not leave a comment that is now false.The 500 fallback must survive
A genuinely unexpected throw still answers
500 INTERNAL_ERROR. ⛔ Do not map everything and leave nothing on the default arm — that would trade one wrong answer for another, and it would hide real faults.looksLikeInternalErrorLeakbefore shipping (packages/types/src/error-leak.ts). Check whether this door applies it; if it does not, that is a separate finding — record it, ⛔ do not fix it here.Pins
code— assert both (ADR-0112).status+codeon both doors, asserted by comparing the two rather than by two hand-written literals. That is the pin that keeps them converged.500 INTERNAL_ERRORfor an uncoded throw.packages/restTEST_DEBT is 155 with no margin. ⛔ Never raise the ledger.Generated by Claude Code
hotlong commentedon Aug 12, 2026
{ "issue": 8016, "status": "done", "branch": "claude/issue-8016-package-door-coded-error-mapping", "pr_url": "https://github.com/objectstack-ai/objectstack/pull/8088", "premise_still_valid": true, "mapper_reused": "Yes, but NOT by importing the dispatcher's. `errorFromThrown` is a private method of `HttpDispatcher` in `@objectstack/runtime`, and `@objectstack/runtime` DEPENDS ON `@objectstack/rest` — the import can only point one way, so it is genuinely unreachable from the REST door. Per the ruling that this is a finding which changes the fix: the rule was extracted to `resolveThrownHttpError` in `@objectstack/types` (which depends only on `@objectstack/spec`, and already hosts `sendOk`/`sendError` and `looksLikeInternalErrorLeak` for exactly this argument), and `HttpDispatcher.errorFromThrown` was rewritten to be its OTHER CALLER — now three lines. No second `if (code === …)` ladder exists. `validation-failure.ts` moved with it for reachability and is re-exported from its old runtime module path, so all 16 import sites are untouched.", "comment_225_verdict": "CORRECT, and the code had drifted WIDER than it. The sentence is 'an unexpected throw is INTERNAL_ERROR'; the four catch-alls treated EVERY throw as unexpected. The word doing the work is 'unexpected' — a throw declaring its own status and a registered code is a refusal, not a fault. The fix aligned the code to the comment, so the sentence is left VERBATIM (it was never what was wrong) with a `[#8016]` paragraph appended recording which of the two was drifting and where a coded refusal now exits.", "sites_fixed": "All four — :295 (POST /packages/publish), :365 (GET /packages), :411 (GET /packages/:id), :498 (DELETE /packages/:id). All four now call one local `sendThrownError(res, error)` which delegates to the shared resolver.", "default_arm_preserved": "Yes. A throw declaring neither status nor a registered code resolves to the caller's fallback 500, code derived as INTERNAL_ERROR. Pinned per site ('an uncoded throw still answers 500 INTERNAL_ERROR', 4 cases) and again in the runtime half. Reverse verification confirms it was never the defect: that case stayed GREEN on the reverted site while the coded cases went red.", "leak_filter_applied": "NO — confirmed and filed as #8086, not fixed here. `package-routes.ts` has zero call sites for `looksLikeInternalErrorLeak`, and the shared `sendError` (`packages/types/src/response-envelope.ts`) does not apply it either. Both siblings DO: `HttpDispatcher.error` (#3867) and `rest-server.ts` (:1355, :9019). Not a regression from this PR — the old 500 arm shipped `(error as Error).message` verbatim too — but a coded 5xx now arrives here as a 5xx with its own code, which makes the gap easier to reach deliberately. #5437 / PR #5464 fixed this class one seam over and did not reach this registrar.", "tests_added": "Two files, 47 + 15 cases. `packages/rest/src/package-routes-coded-error-mapping.test.ts`: each of the four sites x (coded 400 `status`, coded 409 `status`, coded 409 `statusCode`) asserting BOTH status and code (ADR-0112), plus per-site uncoded-throw-is-500 and unregistered-code cases, plus a 6-shape comparison against the shared resolver, plus anti-vacuity (200 publish, 404 get). `packages/runtime/src/package-door-error-parity.test.ts`: the dispatcher half, through the REAL `dispatcher.domainDeps.errorFromThrown` (the exact function `domains/packages.ts` calls), same shapes, plus an explicit pin on the one code difference. Every case asserts the throwing seam was actually invoked; both files assert the shapes produce >3 distinct answers so the comparison is not two constants.", "reverse_verification": "Direction predicted BEFORE running: coded cases red with an observed 500/INTERNAL_ERROR; the uncoded-throw case GREEN (never the defect). Reverted ONE site (POST /packages/publish) to `sendError(res, 500, 'INTERNAL_ERROR', (error as Error).message)`. Observed: `Tests 9 failed | 38 passed (47)`, ALL nine on that site, e.g. `AssertionError: expected 500 to be 409` (the established DESTRUCTIVE_CHANGE case) and `expected { status: 500, code: 'INTERNAL_ERROR' } to deeply equal { status: 409, …(1) }`. Also `expected 500 to be 400` (TENANT_SCOPE_REQUIRED) and `expected 500 to be 403` (statusCode spelling). The uncoded-throw case and the three other sites stayed green — a real defect reproduction on the live handler, not a compile error. This also discharges the card's 'reproduce before fixing' instruction: the issue was filed with the mechanism read from code, and the 409-to-500 shape is now driven.", "tests": "@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 — re-measured with the test exclusion removed: @objectstack/rest = 155 (recorded 155), @objectstack/runtime = 227 (recorded 227), 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. Changeset added, stating the wire status-code change plainly.", "out_of_scope_findings": [ "filed as #8086: the direct-mount package door ships 5xx messages verbatim — no `looksLikeInternalErrorLeak` withholding, unlike the dispatcher twin and unlike rest-server's own exits (the leak-filter finding this card asked me to record and not fix)", "filed as #8087: the dispatcher emits unregistered `error.code` values verbatim (`STORAGE_FAILURE`, `FLOW_FAILED`, `DUPLICATE`) — three suites pin bodies that `ApiErrorSchema` would reject" ], "risks": [ "WIRE BEHAVIOUR CHANGE on a live surface, by design: requests to /api/v1/packages that return 500 INTERNAL_ERROR today will return the refusal they always were (409 / 400 / 403). A client keying on 500 to mean 'platform down, retry' must key on `code`. Said plainly in the changeset.", "The dispatcher's `errorFromThrown` was rewritten to delegate. Output is wire-identical by construction (same precedence table, `details` assembly preserved including the non-string-`code`-stays-context rule from #3842) and all 143 runtime test files pass — but this is a shared exit used by /meta, /actions, /mcp, /ui and endpoint-executor, so its blast radius is wider than the card's surface.", "Attempting full code parity FIRST turned four existing runtime tests red (STORAGE_FAILURE / FLOW_FAILED / DUPLICATE are unregistered and pinned as reaching the wire). Rather than rewrite those pins, the resolver returns two spellings from one function — `code` (narrowed, REST door) and `declaredCode` (verbatim, dispatcher). Doors agree on status ALWAYS and on code for every REGISTERED code; they differ only for a code the ledger does not know. Documented at the resolver, pinned explicitly, and escalated as #8087. If the PM wants full parity instead, that is #8087's decision and a one-line change here.", "The two-door comparison had to be SPLIT across two files: `registerPackageRoutes` is not part of `@objectstack/rest`'s public surface and `rest` cannot import `runtime`, so no single file can see both doors. Each door is pinned to the shared resolver from its own side and the halves compose; each is independently falsifiable. A literal one-file two-door assertion would have needed a new public export on `@objectstack/rest`, which is scope creep on that package's API." ], "open_questions": [] }Generated by Claude Code
Generated by Claude Code
fix(rest,runtime,types): the package door answers a coded refusal wit…
fix(rest): the direct-mount package door withholds a leaky 5xx message (
fix(rest): answer a package DELETE driver fault as 5xx, stop swallowi…
test(rest): re-point the publish-classification sentinel at ThrownHtt…
fix(service-datasource): refuse an importObject name override that vi…
fix(security): explain answers 404 OBJECT_NOT_FOUND for an object tha…
docs(types): re-anchor the dead tracker citations in packages/types/s…
fix(service-package, metadata-protocol): sys_packages is created and …
fix(spec): the error-code waiver reasons and the auth-feature registr…