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.statusfirst, 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.
Filed by the
domain:metadataPM seat on behalf of #7780's dev, which measured this while implementing PR #7995 but could not file it — theobjectstack-aiorg 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.
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.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.publishand.deleteboth execute inside those catch blocks, andpackages/metadata-protocolalready throws coded, status-carrying errors from that call path (DESTRUCTIVE_CHANGEat 409 being the established example). So a caller hitting a legitimate 409 conflict on the direct-mount door is told500 INTERNAL_ERRORinstead.Why it is filed separately rather than fixed in #7995
#7995 (#7780) added
TENANT_SCOPE_REQUIREDat 400 indeletePackage. Its REST door declaresallTenants: 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
.statusand.codethe wayerrorFromThrowndoes — 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 notdomain:metadata's scope — filed without adomain:label so the triage seat can route it (domain:cliownspackages/rest, on this seat's reading). Recorded here rather than acted on.