Filed unassigned while implementing #11588. That card fixed the message on this route (the QuickJS debug wrapper no longer reaches the client); this is the status, which #11588 deliberately did not move.
Measured
On claude/issue-11588-sandbox-wrapper-bulk-routes at f93aa39e, driving the real POST /api/v1/analytics/dataset/query handler in process, with a hook throwing new Error('month-end close is in progress') and declaring nostatus and nocode:
| face | answer |
|---|
POST /api/v1/data/:object (and every other /data write) | 400 — month-end close is in progress |
POST /api/v1/analytics/dataset/query | 500 ANALYTICS_QUERY_FAILED — month-end close is in progress |
Pinned as measured (green on both sides of #11588) in packages/rest/src/rest-hook-refusal-message-parity.test.ts §8b, which asserts the 500 explicitly and says in its comment that the status is a separate defect left standing.
Why
The analytics catch has three arms. ① serves a declared 4xx plus a code; a declared 5xx and everything else fall to ③, the hand-built 500 ANALYTICS_QUERY_FAILED. Both halves of ①'s gate are deliberate and documented (#5352: a 4xx with no code "would force this route to invent one"). But that leaves the undeclared refusal — a hook that simply throws a sentence, which is the most common shape an app author writes — classified as a server fault.
classifyDataError's sandbox unwrap door answers exactly this case with 400 and the verbatim message, which is what hook-error-format.dogfood.test.ts pins end to end. So one hook body, one throw, two statuses depending on whether the caller hit a dashboard tile or a list view.
Why #11588 did not fix it
Its dispatch carried a hard stop on contract-shaped moves, and changing which arm an undeclared refusal lands in is a classification change on a shipped route, not a message repair. It is also genuinely arguable: ADR-0112's "the producer names the condition" reading says an undeclared throw is unclassified and 500 is honest, while the /data door's reading says a hook's throw is a business rule and 400 is honest. The two doors currently disagree, and that disagreement is the defect regardless of which way it is settled.
Not established here
- Which answer is right. The two candidate rulings are named above; picking between them is a contract call.
- Whether the sibling
/analytics/query face (through dispatcher-plugin.errorResponseBase) has the same split. Not exercised. - Severity not judged.
Region: packages/rest/src/rest-server.ts, the /analytics/dataset/query catch only.
Filed unassigned while implementing #11588. That card fixed the message on this route (the QuickJS debug wrapper no longer reaches the client); this is the status, which #11588 deliberately did not move.
Measured
On
claude/issue-11588-sandbox-wrapper-bulk-routesatf93aa39e, driving the realPOST /api/v1/analytics/dataset/queryhandler in process, with a hook throwingnew Error('month-end close is in progress')and declaring nostatusand nocode:POST /api/v1/data/:object(and every other/datawrite)400—month-end close is in progressPOST /api/v1/analytics/dataset/query500 ANALYTICS_QUERY_FAILED—month-end close is in progressPinned as measured (green on both sides of #11588) in
packages/rest/src/rest-hook-refusal-message-parity.test.ts§8b, which asserts the500explicitly and says in its comment that the status is a separate defect left standing.Why
The analytics catch has three arms. ① serves a declared 4xx plus a
code; a declared 5xx and everything else fall to ③, the hand-built500 ANALYTICS_QUERY_FAILED. Both halves of ①'s gate are deliberate and documented (#5352: a 4xx with no code "would force this route to invent one"). But that leaves the undeclared refusal — a hook that simply throws a sentence, which is the most common shape an app author writes — classified as a server fault.classifyDataError's sandbox unwrap door answers exactly this case with400and the verbatim message, which is whathook-error-format.dogfood.test.tspins end to end. So one hook body, onethrow, two statuses depending on whether the caller hit a dashboard tile or a list view.Why #11588 did not fix it
Its dispatch carried a hard stop on contract-shaped moves, and changing which arm an undeclared refusal lands in is a classification change on a shipped route, not a message repair. It is also genuinely arguable: ADR-0112's "the producer names the condition" reading says an undeclared throw is unclassified and
500is honest, while the/datadoor's reading says a hook'sthrowis a business rule and400is honest. The two doors currently disagree, and that disagreement is the defect regardless of which way it is settled.Not established here
/analytics/queryface (throughdispatcher-plugin.errorResponseBase) has the same split. Not exercised.Region:
packages/rest/src/rest-server.ts, the/analytics/dataset/querycatch only.