Filed unassigned while root-causing #10345 (the missing code on the same routes). Different half of the same envelope, different branch — recorded separately rather than fixed there, because the repair changes the message text on routes that card did not touch.
The rule this violates
classifyDataError's sandbox unwrap door exists precisely to keep the QuickJS debug wrapper off the wire. Its own docblock: a hook's throw new Error('删除被阻断…') is a deliberate business rule, "End users must see only the business message", and the <kind> '<name>' threw: <msg> prefix "belongs in server logs". packages/qa/dogfood/test/hook-error-format.dogfood.test.ts pins that end to end — but only for a single-row DELETE, which is the one route family that reaches the door.
Measured
In-process against the real route handlers (RestServer.registerRoutes(), mocked protocol, a SandboxError with .message = the wrapper, .innerMessage = the business text, .status = 409, .code = 'RECORD_LOCKED'), on main at bcfe215d:
| route | body error / message |
|---|
PATCH /api/v1/data/:obj/:id | Opportunity is closed. — unwrapped, correct |
POST /api/v1/data/:obj/batch | hook 'guard' threw: Error: Opportunity is closed. |
POST /api/v1/data/:obj/createMany | hook 'guard' threw: Error: dup |
POST /api/v1/data/:obj/updateMany | hook 'guard' threw: Error: locked |
POST /api/v1/data/:obj/deleteMany | hook 'guard' threw: Error: restricted |
POST /api/v1/data/:obj/:id/clone | hook 'guard' threw: Error: locked |
POST /api/v1/analytics/dataset/query | hook 'guard' threw: Error: locked |
Why
Two distinct branches, both sitting above the unwrap:
- batch / bulk / clone exit through
handleRouteError → resolveErrorResponse, whose declared-status passthrough is checked before it delegates to mapDataError. So a refusal that declares its status as status (the spelling the QuickJS side-channel uses) is answered from error.message — the wrapper — and never reaches the unwrap. A refusal declaring statusCode falls through to mapDataError and is unwrapped, so the same hook produces two different message shapes on one route depending on which spelling its author picked. /analytics/dataset/query builds its own { code, message } envelope inline and reads error.message directly. It shares no branch with either of the above.
The single-row POST / PATCH / DELETE / query routes call mapDataError directly, which is why they are the only ones that unwrap.
Not established here
Reproduction lives in the harness shape of packages/rest/src/rest-hook-refusal-code-parity.test.ts (added by #10345's PR) — the same sandboxRefusal() fixture and BULK_ROUTES table, with the assertion moved from body.code to body.error.
Generated by Claude Code
Filed unassigned while root-causing #10345 (the missing
codeon the same routes). Different half of the same envelope, different branch — recorded separately rather than fixed there, because the repair changes the message text on routes that card did not touch.The rule this violates
classifyDataError's sandbox unwrap door exists precisely to keep the QuickJS debug wrapper off the wire. Its own docblock: a hook'sthrow new Error('删除被阻断…')is a deliberate business rule, "End users must see only the business message", and the<kind> '<name>' threw: <msg>prefix "belongs in server logs".packages/qa/dogfood/test/hook-error-format.dogfood.test.tspins that end to end — but only for a single-rowDELETE, which is the one route family that reaches the door.Measured
In-process against the real route handlers (
RestServer.registerRoutes(), mocked protocol, aSandboxErrorwith.message= the wrapper,.innerMessage= the business text,.status = 409,.code = 'RECORD_LOCKED'), onmainatbcfe215d:error/messagePATCH /api/v1/data/:obj/:idOpportunity is closed.— unwrapped, correctPOST /api/v1/data/:obj/batchhook 'guard' threw: Error: Opportunity is closed.POST /api/v1/data/:obj/createManyhook 'guard' threw: Error: dupPOST /api/v1/data/:obj/updateManyhook 'guard' threw: Error: lockedPOST /api/v1/data/:obj/deleteManyhook 'guard' threw: Error: restrictedPOST /api/v1/data/:obj/:id/clonehook 'guard' threw: Error: lockedPOST /api/v1/analytics/dataset/queryhook 'guard' threw: Error: lockedWhy
Two distinct branches, both sitting above the unwrap:
handleRouteError→resolveErrorResponse, whose declared-status passthrough is checked before it delegates tomapDataError. So a refusal that declares its status asstatus(the spelling the QuickJS side-channel uses) is answered fromerror.message— the wrapper — and never reaches the unwrap. A refusal declaringstatusCodefalls through tomapDataErrorand is unwrapped, so the same hook produces two different message shapes on one route depending on which spelling its author picked./analytics/dataset/querybuilds its own{ code, message }envelope inline and readserror.messagedirectly. It shares no branch with either of the above.The single-row
POST/PATCH/DELETE/queryroutes callmapDataErrordirectly, which is why they are the only ones that unwrap.Not established here
resolveErrorResponse's passthrough is the obvious shape, but that passthrough is load-bearing for sendError 的显式状态直通覆盖 400–599,5xx 的原始驱动报错绕过全部泄漏启发式直达客户端(metadata-protocol 有活体产出方) #5437/mapDataError 的显式状态直通只覆盖 4xx,数据路由上一个声明了 502/503 的生产者拿不回自己的状态码(与 resolveErrorResponse 不对等) #5582 (the 5xx prose withhold) and the ordering was argued deliberately in its docblock — settling it is a contract question, not a mechanical move.Reproduction lives in the harness shape of
packages/rest/src/rest-hook-refusal-code-parity.test.ts(added by #10345's PR) — the samesandboxRefusal()fixture andBULK_ROUTEStable, with the assertion moved frombody.codetobody.error.Generated by Claude Code