Skip to content

fix(rest): answer the engine's DUPLICATE_RECORD insert-conflict envelope with the structured 409 UNIQUE_VIOLATION body - #14544

Merged
os-trump merged 7 commits into
mainfrom
claude/issue-14389-rest-duplicate-record-arm
Sep 2, 2026
Merged

fix(rest): answer the engine's DUPLICATE_RECORD insert-conflict envelope with the structured 409 UNIQUE_VIOLATION body#14544
os-trump merged 7 commits into
mainfrom
claude/issue-14389-rest-duplicate-record-arm

Conversation

@os-trump

@os-trump os-trump commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes #14389

What was wrong

Since #14095 engine.insert refuses a driver's unique violation with the DuplicateRecordError envelope (code: 'DUPLICATE_RECORD', status: 409, object, field when the dialect determinably named the column, the driver error whole on cause). Because the envelope declares a status, classifyDataError answered it from the generic declared-status passthrough — 409, but code: 'DUPLICATE_RECORD', no field, and the engine's own sentence in error. The isUniqueViolationError arm that curates the end-user sentence and names the column (#6250 / #7821) was never reached for an insert conflict any more.

Measured on the untouched base (ed44512199) with the real engine, real driver-memory / driver-sqlite-wasm / driver-sql (better-sqlite3), and the real mapDataError — the same conflict handed to the boundary as the raw driver error (BEFORE) and as the envelope now carrying it (AFTER):

driver index status code field error
driver-sqlite-wasm single 409 → 409 UNIQUE_VIOLATIONDUPLICATE_RECORD emailabsent curated → engine sentence
driver-sqlite-wasm composite 409 → 409 UNIQUE_VIOLATIONDUPLICATE_RECORD absent → absent curated (unnamed) → engine sentence
driver-sql (better-sqlite3) single 409 → 409 UNIQUE_VIOLATIONDUPLICATE_RECORD emailabsent curated → engine sentence
driver-memory single 409 → 409 UNIQUE_VIOLATIONDUPLICATE_RECORD absent → absent the driver's own message, quoting the offending value as JSON → engine sentence (no value)
driver-memory composite 409 → 409 UNIQUE_VIOLATIONDUPLICATE_RECORD absent → absent same, quoting every key value → engine sentence (no value)

The fix

One arm in classifyDataError (packages/rest/src/error-response.ts), placed with DELETE_RESTRICTED and CONCURRENT_UPDATE ahead of the declared-status passthrough. Nothing else in the file moves: the passthrough is not refactored, the isUniqueViolationError arm is untouched (it still serves raw driver errors that reach the boundary without the engine, and the update door is #14390), the importer owes no fix and gets none.

The arm is gated on the engine's envelope — code === 'DUPLICATE_RECORD' and name === 'DuplicateRecordError' — not on the code alone as its two siblings are. The difference is load-bearing: the siblings relay error.message, this arm replaces it with a curated sentence. A sandbox body that deliberately throws the registered DUPLICATE_RECORD is a different producer and keeps the answer the sandbox unwrap door gives it today (its own sentence, its own code verbatim per rest-thrown-code-vocabulary.test.ts §2), rather than having its sentence swapped and the QuickJS debug wrapper shipped as developerMessage. Pinned in §5.

Contract change — every body key, before/after, per driver

Wire door: POST /api/v1/data/:object (the single-record create route exits through mapDataError directly). Status is 409 before and after on every row.

key before (base, envelope through the passthrough) after driver-sqlite-wasm / driver-sql single composite (any SQL dialect) driver-memory MySQL / index-naming dialects
code DUPLICATE_RECORD UNIQUE_VIOLATION (unchanged from the pre-#14095 wire)
declaredCode absent absent (registered member — already in-process; ADR-0112)
field absent the column the engine resolved through uniqueViolationColumn email absent (by contract) absent (the driver's grammar names none) absent (an index name is never reported as a column)
error the engine's sentence (Duplicate record refused on 'x': …) curated: A record with this email already exists / A record with this value already exists named unnamed unnamed unnamed
developerMessage absent the engine's own sentence (message) — names object and column, carries no value
object the route's object the object the engine refused (error.object), falling back to the route's
offending value on the wire none (the envelope carries none) none — pinned on the driver-memory shape whose raw refusal used to echo it as JSON pinned

Which engine sentence rides on developerMessage: the envelope's message (the diagnostic the card measured moving into error), not the envelope's own developerMessage field. That field addresses the in-process caller of engine.insert — "attached as cause", "branch on code === 'DUPLICATE_RECORD'" — and neither statement holds on this wire (no cause is shipped; the wire code is UNIQUE_VIOLATION). Relaying it would put a false instruction on the body. Stated here because the ruling's phrase "the engine's diagnostic" admits both readings.

declaredCode — settled by the contract review (reading B: absent)

The triage ruling asked to carry the producer's spelling beside the wire code in declaredCode. The in-seat contract review (card comment 5511703122) adjudicated the field's declared semantics instead: DUPLICATE_RECORD is a StandardErrorCode member (packages/spec/src/api/errors.zod.ts), and ApiErrorSchema.declaredCode (packages/spec/src/api/contract.zod.ts — "the producer-declared code, verbatim, when it is not a member of the closed code vocabulary") together with ADR-0112's 2026-08-16 amendment ("presence means demotion — the field is absent when the producer's code IS a vocabulary member") define the field as the demoted spelling of an UNREGISTERED code. demotedDeclaredCode (packages/types/src/thrown-http-error.ts) is the one definition of that rule, and ADR-0112's 2026-08-29 scope correction declares the hand-written declaredCode emission population to be exactly one site — a second would have falsified it silently.

So the body carries no declaredCode; the engine's spelling stays in-process, as every dialect code (SQLITE_CONSTRAINT_UNIQUE, 23505, ER_DUP_ENTRY) always has at the isUniqueViolationError arm. §0 of the pin file keeps the control that both codes parse as ErrorCode — now the reason the field is absent — §1 pins the absence (not.toHaveProperty('declaredCode')), and the three whole-body toEqual literals no longer list it. The wire code was never in question.

Measured and deliberately not changed here

POST …/createMany, …/batch and the other routes that exit through handleRouteErrorresolveErrorResponse answer the same envelope from that function's .status passthrough before mapDataError is ever consulted — measured on the base: 409 {"error":"Duplicate record refused on 'duly_note': …","code":"DUPLICATE_RECORD"}, no field, no object. This arm cannot reach them (the scope fence forbids touching that passthrough, and DELETE_RESTRICTED's structured fields are dropped on those doors today by the same mechanism). Filed as #14541.

Verification

Patch round (reading B; head cdca42fbe1 = 3fbbe68f97 + a merge of origin/main @ 90ff957e6e; closure rebuilt first, 25 packages): the pin file + rest-unique-violation-dialects.test.ts + rest-thrown-code-vocabulary.test.tsTest Files 3 passed (3) · Tests 139 passed (139); pnpm --filter @objectstack/rest typecheck exit 0 → check:test-typecheck: OK — @objectstack/rest's test layer compiles under packages/rest/tsconfig.test.json; 0 file(s) / 0 error(s); gate union re-derived at cdca42fbe1 (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands): 36 commands, byte-identical to the 6d080822a6 derivation, no STALE TREE note at derivation time; pnpm check:nul-bytes: OK (scanned 7984 text file(s) -- 7984 tracked … no raw ASCII control bytes). That merge brought no packages/rest change (it moved objectql/spec sources inside the closure, hence the rebuild), so the full package suite, whole-repo lint and the union runs below stand as measured on 6d080822a6.

Full verification head 6d080822a6 = the pins (ad0eca2c07) + the arm (583879db34) + a merge of origin/main @ 04ee9f884c (da77b964df) + one type fix in the pin file (0511bab27f) + a merge of origin/main @ db7f3e4924. Every command below ran on 6d080822a6 after its closure was rebuilt (pnpm --filter "@objectstack/rest..." build, 26 packages, pnpm install --frozen-lockfile first because the lockfile moved); exit codes were captured after redirection; the verdict lines quoted are the gates' own.

  • Red-first on the untouched base (ed44512199, pins only): Tests 18 failed | 15 passed (33) — the red set is §1/§2/§3/§4 (AssertionError: expected 'DUPLICATE_RECORD' to be 'UNIQUE_VIOLATION', field absent, the engine sentence in error); §0 controls, the §5 gate pins and the no-value-echo pins were green on the base by design.
  • Green with the armrest-duplicate-record-arm.test.ts + rest-unique-violation-dialects.test.ts + rest-thrown-code-vocabulary.test.ts: Test Files 3 passed (3) · Tests 139 passed (139).
  • Ablation (on 583879db34, the arm's own commit, so the restore leg had the arm in HEAD): the arm's gate was made never-true on disk — occurrence of the gate text 1 → 0, marker ABLATED_14389 0 → 1, blob 49404f90…1086ae66… — the pins then answered Tests 18 failed | 15 passed (33), the base red set exactly; restored with git checkout HEAD -- on the absolute path under trap … EXIT INT TERM, blob back to 49404f90… = the HEAD blob, git diff HEAD empty. No rebuild leg is owed for the subject: the pins import ./error-response.js from the same package; the engine/driver dependencies are dist-resolved and were built before every run.
  • Package suitepnpm --filter @objectstack/rest exec vitest run --maxWorkers=2: Test Files 171 passed (171) · Tests 2870 passed (2870).
  • Typecheckpnpm --filter @objectstack/rest typecheck: exit 0; check:test-typecheck: OK — @objectstack/rest's test layer compiles under packages/rest/tsconfig.test.json; 0 file(s) / 0 error(s). (The run on da77b964df reported one type error in the new pin file — Error has no code — fixed in 0511bab27f; the ledger was not touched.)
  • Whole-repo lintpnpm lint (eslint . --no-inline-config): exit 0.
  • pnpm check:nul-bytes: OK (scanned 7979 text file(s) -- 7979 tracked, 0 untracked-not-ignored; skipped 7 binary; no raw ASCII control bytes).
  • Gate unionnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 6d080822a6 (change set: the three paths of this PR; 36 commands): 32 exit 0; 4 exit 3 = NOT MEASURED, each naming its unmet prerequisite in its own text — check-test-completeness (consumes a vitest summary CI produces), pm/check-half-states (GitHub API, refused in this session class), check:dual-build-cjs-loads and check:type-check-debt (both require the full turbo run build of every package: "Run pnpm build first. ⛔ This is NOT a pass: nothing was measured"). CI owns those four. The deriver printed a STALE TREE note at that run (origin/main had moved on; the two changed inputs were scripts/check-docs-section-name.mjs, a docs family this diff never touches, and scripts/pm/dispatch-gates.mjs, perf(pm): memoise the source maskers dispatch-gates re-runs per family (843s to 303s under the verify lock) #14584 memoisation); the patch-round derivation above, on the newer tree, produced the identical list.
  • Base measurement (real engine, real driver-memory / driver-sqlite-wasm / driver-sql, real mapDataError, a scratch script over the untouched source): the table at the top.

Changeset

@objectstack/rest: patch — a restoration of the shipped body's keys and wording; the wire code and the status are unchanged.

Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

🤖 Generated with Claude Code

…-conflict envelope

Red-first pins for the classifyDataError arm: UNIQUE_VIOLATION stays on
the wire, field is restored when the dialect named the column, the
curated sentence returns on error with the engine's sentence on
developerMessage, and no offending value reaches the body (the
driver-memory control). Changeset for @objectstack/rest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…ope with the structured 409 UNIQUE_VIOLATION body

One arm in classifyDataError, placed with DELETE_RESTRICTED and
CONCURRENT_UPDATE ahead of the declared-status passthrough: the wire code
stays UNIQUE_VIOLATION with the producer's spelling on declaredCode, field
is restored when the dialect named the column, the curated end-user
sentence returns on error with the engine's sentence on developerMessage,
and no offending value reaches the body. Gated on the engine's envelope
(name and code), so a sandbox body speaking the registered code keeps
today's answer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/rest, touching 3 documentable anchor(s).

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/error-catalog.mdx (via DUPLICATE_RECORD (literal, a string literal in classifyDataError))
  • content/docs/data-modeling/drivers.mdx (via UNIQUE_VIOLATION (literal, a string literal in classifyDataError))
  • content/docs/protocol/kernel/error-handling.mdx (via DUPLICATE_RECORD (literal, a string literal in classifyDataError))
  • content/docs/protocol/kernel/http-protocol.mdx (via UNIQUE_VIOLATION (literal, a string literal in classifyDataError))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx (via UNIQUE_VIOLATION (literal, a string literal in classifyDataError))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 13 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 2514d49f388e898e666ae04f19ba376d04db5422packageMentionDocs.

Which tree this was computed on

This run read content/docs from 23db6b0474cd45c7ddc85384816d50ba4573631d — the merge of head cdca42fbe17a8b0cd61b004489efb9c90ad257b1 into base 2514d49f388e898e666ae04f19ba376d04db5422, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 23db6b0474cd45c7ddc85384816d50ba4573631d && git checkout 23db6b0474cd45c7ddc85384816d50ba4573631d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2514d49f388e898e666ae04f19ba376d04db5422 cdca42fbe17a8b0cd61b004489efb9c90ad257b1 && git checkout -B drift-repro 2514d49f388e898e666ae04f19ba376d04db5422 && git merge --no-ff cdca42fbe17a8b0cd61b004489efb9c90ad257b1

node scripts/docs-audit/affected-docs.mjs --json 2514d49f388e898e666ae04f19ba376d04db5422

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 2514d49f388e898e666ae04f19ba376d04db5422 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

… — a vocabulary member is not a demotion

Contract review on the card (reading B): DUPLICATE_RECORD is a
StandardErrorCode member, and ApiErrorSchema.declaredCode plus ADR-0112's
presence-means-demotion amendment define the field as the demoted spelling
of an UNREGISTERED code, so it stays absent here and the engine's spelling
stays in-process like every dialect code. Pins updated accordingly; the
§0 control that both codes parse as ErrorCode now states the reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants