Found while implementing #14095, whose scope was explicitly the INSERT door. Filed rather than widened into that PR.
Measured
Real ObjectQL engine on a real driver-sqlite-wasm store, an object carrying a declared unique index on email. Two rows exist; the second is driven onto the first's value, so the UPDATE violates the index:
{
"threw": true,
"name": "Error",
"code": undefined,
"status": undefined,
"hasCause": false,
"message": "update `duly_note` set `id` = 'JrzMrU3fJnc5__tV', `email` = 'a@b.example', `updated_at` = '2026-09-02T03:54:04.936Z' where `id` = 'JrzMrU3fJnc5__tV' -"
}
No code, no status, no cause — and the message is the compiled UPDATE statement with its bound values, exactly the shape #14095 measured on the insert side.
Why it matters, and why it is not merely cosmetic
The platform now has ONE contract for this condition on insert and none on update, which is worse than having neither: an application that correctly branches on code === 'DUPLICATE_RECORD' for its create path silently falls through to the generic error branch on its edit path, on every driver. "Renaming a record onto a name someone else already took" is the ordinary form-submission case, and it is the one that stays dialect-coupled.
An application cannot close it locally for the same three reasons the insert card enumerated: branching on SQLITE_CONSTRAINT_UNIQUE couples it to one dialect, the message is a compiled statement, and isUniqueViolationError lives in @objectstack/types, which an application cannot resolve.
The shape of the work
The insert side landed as envelopeUniqueViolation(error, object) from packages/objectql/src/duplicate-record-error.ts, applied at each point a driver error leaves the insert door. The update door needs the same treatment at its own driver-error exits, with the same negative contract (only a recognised unique violation is enveloped; a NOT NULL, a deadlock, a missing table and an unreachable store leave untouched) and the same operator-log rule (the log takes cause, so the driver's diagnosis stays in the line).
Two things the insert card settled that this one has to decide for itself:
update can refuse a MULTI-row write. Which row conflicted is a question the insert envelope never had to answer, and the driver's error may not answer it either. The envelope's field half is unaffected (uniqueViolationColumn reads the same channels), but a caller may reasonably want to know it was one of N.
upsert is the same door's neighbour and is deliberately excluded here: on some dialects an upsert converts a conflict into a merge, so "a unique violation escaped" means something different there. It deserves its own measurement before any envelope is put on it.
Unassigned and untriaged.
Triage housekeeping (R+98): the Blocked-by: #14095 line has been removed from this body. #14095 closed as completed on 2026-09-02T06:52Z via PR #14405 — the reference pattern this card is asked to mirror (envelopeUniqueViolation) is on main now, so the line was inert and misrepresented the card as gated. This card is already pm:dispatched, so nothing else changes.
Generated by Claude Code
Found while implementing #14095, whose scope was explicitly the INSERT door. Filed rather than widened into that PR.
Measured
Real
ObjectQLengine on a realdriver-sqlite-wasmstore, an object carrying a declared unique index onemail. Two rows exist; the second is driven onto the first's value, so the UPDATE violates the index:{ "threw": true, "name": "Error", "code": undefined, "status": undefined, "hasCause": false, "message": "update `duly_note` set `id` = 'JrzMrU3fJnc5__tV', `email` = 'a@b.example', `updated_at` = '2026-09-02T03:54:04.936Z' where `id` = 'JrzMrU3fJnc5__tV' -" }No
code, nostatus, nocause— and the message is the compiled UPDATE statement with its bound values, exactly the shape #14095 measured on the insert side.Why it matters, and why it is not merely cosmetic
The platform now has ONE contract for this condition on
insertand none onupdate, which is worse than having neither: an application that correctly branches oncode === 'DUPLICATE_RECORD'for its create path silently falls through to the generic error branch on its edit path, on every driver. "Renaming a record onto a name someone else already took" is the ordinary form-submission case, and it is the one that stays dialect-coupled.An application cannot close it locally for the same three reasons the insert card enumerated: branching on
SQLITE_CONSTRAINT_UNIQUEcouples it to one dialect, the message is a compiled statement, andisUniqueViolationErrorlives in@objectstack/types, which an application cannot resolve.The shape of the work
The insert side landed as
envelopeUniqueViolation(error, object)frompackages/objectql/src/duplicate-record-error.ts, applied at each point a driver error leaves the insert door. The update door needs the same treatment at its own driver-error exits, with the same negative contract (only a recognised unique violation is enveloped; a NOT NULL, a deadlock, a missing table and an unreachable store leave untouched) and the same operator-log rule (the log takescause, so the driver's diagnosis stays in the line).Two things the insert card settled that this one has to decide for itself:
updatecan refuse a MULTI-row write. Which row conflicted is a question the insert envelope never had to answer, and the driver's error may not answer it either. The envelope'sfieldhalf is unaffected (uniqueViolationColumnreads the same channels), but a caller may reasonably want to know it was one of N.upsertis the same door's neighbour and is deliberately excluded here: on some dialects an upsert converts a conflict into a merge, so "a unique violation escaped" means something different there. It deserves its own measurement before any envelope is put on it.Unassigned and untriaged.
Triage housekeeping (R+98): the
Blocked-by: #14095line has been removed from this body. #14095 closed as completed on 2026-09-02T06:52Z via PR #14405 — the reference pattern this card is asked to mirror (envelopeUniqueViolation) is onmainnow, so the line was inert and misrepresented the card as gated. This card is alreadypm:dispatched, so nothing else changes.Generated by Claude Code