Skip to content

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

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 twinerrorFromThrown (packages/runtime/src/http-dispatcher.ts:722-728) reads e.statusfirst, so a protocol throw carrying .status = 400 and a ledger-declared .code surfaces as a 400 with that code.
  • Direct-mount REST registrarpackages/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.

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions