Skip to content

rest: the /api/v1/data write path renders code only for 400 — a 409 refusal reaches the client with the status but no machine-readable code #10345

Description

@os-elon

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 hookrouteHTTPresponse body
VALIDATION_FAILED / 400POST /api/v1/data/crm_account400{"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/:id400same shape — code present
DELETE_RESTRICTED / 409DELETE /api/v1/data/crm_account/:id409{"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/:id409{"error":"Opportunity … is closed (closed_won); only description, next_step, notes may be edited. Attempted: amount.","object":"crm_opportunity"}no code
RECORD_LOCKED / 409PATCH /api/v1/data/crm_quote/:id409no code
DUPLICATE_VALUE / 409POST /api/v1/data/crm_contact409{"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.

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions