Measured on a booted server while landing the refusal-envelope sweep in the HotCRM app (objectstack-ai/hotcrm#1075, PR objectstack-ai/hotcrm#1221). Filed unassigned for triage. Nothing here is currently red — this is a user-visible gap, not a failing gate.
Platform: @objectstack/*17.1.0, real server (objectstack start), authenticated session, real SQLite datasource. Not a harness.
What was measured
An app hook throws an error carrying code (a member of the platform ErrorCode enum) and a finite numeric status. Both survive the QuickJS sandbox — verified on the same run: the allowlist marshals code / status / fields and the host-side SandboxError carries them. resolveThrownHttpError in isolation resolves the pair correctly:
code: 'DELETE_RESTRICTED', status: 409
-> { status: 409, declaredStatus: 409, code: 'DELETE_RESTRICTED', declaredCode: 'DELETE_RESTRICTED' }
code: 'RECORD_LOCKED', status: 409
-> { status: 409, declaredStatus: 409, code: 'RECORD_LOCKED', declaredCode: 'RECORD_LOCKED' }
But what the HTTP client actually receives depends on the route and on the status:
| refusal thrown by the hook | route | HTTP | response body |
|---|
VALIDATION_FAILED / 400 | POST /api/v1/data/crm_account | 400 | {"error":"hook 'account_protection' threw: Error: Website must start with http:// or https://","code":"VALIDATION_FAILED","fields":[],"object":"crm_account"} |
VALIDATION_FAILED / 400 | PATCH /api/v1/data/crm_account/:id | 400 | same shape — code present |
DELETE_RESTRICTED / 409 | DELETE /api/v1/data/crm_account/:id | 409 | {"error":"hook 'account_protection' threw: Error: Cannot delete customer account: 1 open opportunity still references it. …","code":"DELETE_RESTRICTED","object":"crm_account"} |
RECORD_LOCKED / 409 | PATCH /api/v1/data/crm_opportunity/:id | 409 | {"error":"Opportunity … is closed (closed_won); only description, next_step, notes may be edited. Attempted: amount.","object":"crm_opportunity"} — no code |
RECORD_LOCKED / 409 | PATCH /api/v1/data/crm_quote/:id | 409 | no code |
DUPLICATE_VALUE / 409 | POST /api/v1/data/crm_contact | 409 | {"error":"Another contact (…) with email … already exists.","object":"crm_contact"} — no code |
So on the insert/update row-level write path, code is rendered for 400 (alongside fields) and dropped for 409. The DELETE route renders code on 409 without trouble, which is what makes this look like a branch in the write path rather than a policy about conflicts.
A second, correlated difference on the same rows: where code is dropped, the message is also the unwrapped sentence (err.innerMessage), whereas the rows that carry code show the sandbox-rewritten hook 'NAME' threw: Error: …. The two always move together in the readings above, which suggests one branch renders from the resolved envelope and the other from the raw error.
Why it matters
status alone is not the branchable channel the envelope exists to provide. A client that must distinguish "this record is frozen, do not retry" from "this value is already taken, offer a merge" gets 409 for both and has to fall back to substring-matching prose — which is exactly the failure mode the ErrorCode enum is there to remove, and the prose is localised and deliberately reworded over time.
It also silently wastes correct authoring. An app that does everything right — enum-member code, finite numeric status, nothing riding a fourth key — still cannot be branched on for the conflict classes. That is the kind of gap an app author has no way to see without booting a server and reading raw HTTP, and the tempting local workaround (teach the client to read the message, or alias something app-side) is the wrong repair in the consumer.
What this does NOT establish
- No claim about which function is responsible. The app-side reading cannot see whether this is
toRowApiError, the generic sendThrownError, or a branch above them; the correlation with innerMessage is evidence, not a diagnosis. - Only the
/api/v1/data/... single-row insert / update / delete routes were exercised. Batch and bulk routes were not, nor were the analytics or action routes. 403 was observed (FORBIDDEN, Do Not Call on POST /api/v1/data/crm_task) and also rendered without code, consistent with "non-400 on the write path", but only one sample was taken.- Whether the demotion is intentional. If the write path deliberately narrows its response shape, that is a defensible design — but then
code appearing on 400 and on DELETE is the inconsistency to settle.
Repro
Any hook that throws Object.assign(new Error(msg), { code: 'RECORD_LOCKED', status: 409 }) from beforeUpdate, driven through PATCH /api/v1/data/<object>/<id> on a booted server. The HotCRM guards in objectstack-ai/hotcrm#1221 are a ready-made fixture: opportunity_lifecycle and quote_workflow refuse this way on any closed/accepted record.
Refs objectstack-ai/hotcrm#1075, objectstack-ai/hotcrm#1167, PR objectstack-ai/hotcrm#1221.
Measured on a booted server while landing the refusal-envelope sweep in the HotCRM app (objectstack-ai/hotcrm#1075, PR objectstack-ai/hotcrm#1221). Filed unassigned for triage. Nothing here is currently red — this is a user-visible gap, not a failing gate.
Platform:
@objectstack/*17.1.0, real server (objectstack start), authenticated session, real SQLite datasource. Not a harness.What was measured
An app hook throws an error carrying
code(a member of the platformErrorCodeenum) and a finite numericstatus. Both survive the QuickJS sandbox — verified on the same run: the allowlist marshalscode/status/fieldsand the host-sideSandboxErrorcarries them.resolveThrownHttpErrorin isolation resolves the pair correctly:But what the HTTP client actually receives depends on the route and on the status:
VALIDATION_FAILED/ 400POST /api/v1/data/crm_account{"error":"hook 'account_protection' threw: Error: Website must start with http:// or https://","code":"VALIDATION_FAILED","fields":[],"object":"crm_account"}VALIDATION_FAILED/ 400PATCH /api/v1/data/crm_account/:idcodepresentDELETE_RESTRICTED/ 409DELETE /api/v1/data/crm_account/:id{"error":"hook 'account_protection' threw: Error: Cannot delete customer account: 1 open opportunity still references it. …","code":"DELETE_RESTRICTED","object":"crm_account"}RECORD_LOCKED/ 409PATCH /api/v1/data/crm_opportunity/:id{"error":"Opportunity … is closed (closed_won); only description, next_step, notes may be edited. Attempted: amount.","object":"crm_opportunity"}— nocodeRECORD_LOCKED/ 409PATCH /api/v1/data/crm_quote/:idcodeDUPLICATE_VALUE/ 409POST /api/v1/data/crm_contact{"error":"Another contact (…) with email … already exists.","object":"crm_contact"}— nocodeSo on the insert/update row-level write path,
codeis rendered for400(alongsidefields) and dropped for409. TheDELETEroute renderscodeon409without trouble, which is what makes this look like a branch in the write path rather than a policy about conflicts.A second, correlated difference on the same rows: where
codeis dropped, the message is also the unwrapped sentence (err.innerMessage), whereas the rows that carrycodeshow the sandbox-rewrittenhook 'NAME' threw: Error: …. The two always move together in the readings above, which suggests one branch renders from the resolved envelope and the other from the raw error.Why it matters
statusalone is not the branchable channel the envelope exists to provide. A client that must distinguish "this record is frozen, do not retry" from "this value is already taken, offer a merge" gets409for both and has to fall back to substring-matching prose — which is exactly the failure mode theErrorCodeenum is there to remove, and the prose is localised and deliberately reworded over time.It also silently wastes correct authoring. An app that does everything right — enum-member
code, finite numericstatus, nothing riding a fourth key — still cannot be branched on for the conflict classes. That is the kind of gap an app author has no way to see without booting a server and reading raw HTTP, and the tempting local workaround (teach the client to read the message, or alias something app-side) is the wrong repair in the consumer.What this does NOT establish
toRowApiError, the genericsendThrownError, or a branch above them; the correlation withinnerMessageis evidence, not a diagnosis./api/v1/data/...single-row insert / update / delete routes were exercised. Batch and bulk routes were not, nor were the analytics or action routes.403was observed (FORBIDDEN, Do Not Call onPOST /api/v1/data/crm_task) and also rendered withoutcode, consistent with "non-400 on the write path", but only one sample was taken.codeappearing on400and onDELETEis the inconsistency to settle.Repro
Any hook that throws
Object.assign(new Error(msg), { code: 'RECORD_LOCKED', status: 409 })frombeforeUpdate, driven throughPATCH /api/v1/data/<object>/<id>on a booted server. The HotCRM guards in objectstack-ai/hotcrm#1221 are a ready-made fixture:opportunity_lifecycleandquote_workflowrefuse this way on any closed/accepted record.Refs objectstack-ai/hotcrm#1075, objectstack-ai/hotcrm#1167, PR objectstack-ai/hotcrm#1221.