Skip to content

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

Closed

Description

@huangyiirene

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) reads e.status first, so a protocol throw carrying .status = 400 and a ledger-declared .code surfaces as a 400 with that code.
  • Direct-mount REST registrar — packages/rest/src/package-routes.ts has four catch-alls (around :295, :365, :411, :498) that do sendError(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.

⚠️ The 500 is the one production actually returns: that registrar is registered first and matches first in the production stack.

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.

⚠️ Honest limit on the evidence, recorded by the measuring dev: "I did not do an end-to-end reproduction; the mechanism is read from the code." Whoever picks this up should reproduce before fixing, and pair any zero-hit with a control that exists now.

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.

⚠️ The pin should be an AGREEMENT pin, not a presence pin: assert that both doors map the same throw to the same status and code, across throws that genuinely differ (a coded 4xx, a coded 409, and a genuinely unexpected error that should be a 500), plus an anti-vacuity case proving the doors are actually exercised. Asserting only "the REST door returns 409 for a DESTRUCTIVE_CHANGE" passes again the day someone special-cases that one code — the same defect one layer over.

Routing

⚠️ The fix lands in 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

  1. added
    bugSomething isn't working
    pm:queueReady for the PM dispatch loop
    on Aug 12, 2026
  2. added
    domain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they land
    on Aug 12, 2026
  3. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    Triage: lands in packages/rest/src/package-routes.ts (the four status-blind catch-alls) → domain:cli; queued pm: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

  4. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 12, 2026
  5. self-assigned this
    on Aug 12, 2026
  6. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    Claimed by the domain:cli PM seat (#6024, session session_01B3Kurx8qufrDzNjk4rag7V).
    Branch: claude/issue-8016-package-door-coded-error-mapping · dispatch: mode:subagent, model: claude-opus-5.

    Anchor verified: packages/rest/src/package-routes.ts has four catch sites — :295, :365, :411, :498 — all sendError(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/rest say so with the reason — that is a finding and it changes the shape of the fix.

    ⚠️ Read :225 before you touch anything

    That 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.

    ⚠️ Message leakage: 5xx bodies on this repo's HTTP boundaries run through looksLikeInternalErrorLeak before 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

    • Each of the four sites: a coded 4xx thrown from below arrives with its own status and its own code — assert both (ADR-0112).
    • Parity with the dispatcher twin: the same thrown error answers the same status + code on both doors, asserted by comparing the two rather than by two hand-written literals. That is the pin that keeps them converged.
    • The default arm still answers 500 INTERNAL_ERROR for an uncoded throw.
    • Reverse-verify: revert one site, confirm its test fails by getting the 500 — the defect reproduced, not a compile error.

    packages/rest TEST_DEBT is 155 with no margin. ⛔ Never raise the ledger.


    Generated by Claude Code

  7. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor
    {
      "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

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

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingdomain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they landpm:dispatchedDispatched to a dev agent by /pm-dispatch

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions