fix(rest): answer the engine's DUPLICATE_RECORD insert-conflict envelope with the structured 409 UNIQUE_VIOLATION body - #14544
Conversation
…-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
…st-duplicate-record-arm
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 13 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
…test.json compiles the file Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…st-duplicate-record-arm
… — 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
…st-duplicate-record-arm
Fixes #14389
What was wrong
Since #14095
engine.insertrefuses a driver's unique violation with theDuplicateRecordErrorenvelope (code: 'DUPLICATE_RECORD',status: 409,object,fieldwhen the dialect determinably named the column, the driver error whole oncause). Because the envelope declares a status,classifyDataErroranswered it from the generic declared-status passthrough — 409, butcode: 'DUPLICATE_RECORD', nofield, and the engine's own sentence inerror. TheisUniqueViolationErrorarm 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, realdriver-memory/driver-sqlite-wasm/driver-sql(better-sqlite3), and the realmapDataError— the same conflict handed to the boundary as the raw driver error (BEFORE) and as the envelope now carrying it (AFTER):codefielderrorUNIQUE_VIOLATION→DUPLICATE_RECORDemail→ absentUNIQUE_VIOLATION→DUPLICATE_RECORDUNIQUE_VIOLATION→DUPLICATE_RECORDemail→ absentUNIQUE_VIOLATION→DUPLICATE_RECORDUNIQUE_VIOLATION→DUPLICATE_RECORDThe fix
One arm in
classifyDataError(packages/rest/src/error-response.ts), placed withDELETE_RESTRICTEDandCONCURRENT_UPDATEahead of the declared-status passthrough. Nothing else in the file moves: the passthrough is not refactored, theisUniqueViolationErrorarm is untouched (it still serves raw driver errors that reach the boundary without the engine, and theupdatedoor is #14390), the importer owes no fix and gets none.The arm is gated on the engine's envelope —
code === 'DUPLICATE_RECORD'andname === 'DuplicateRecordError'— not on the code alone as its two siblings are. The difference is load-bearing: the siblings relayerror.message, this arm replaces it with a curated sentence. A sandbox body that deliberately throws the registeredDUPLICATE_RECORDis a different producer and keeps the answer the sandbox unwrap door gives it today (its own sentence, its own code verbatim perrest-thrown-code-vocabulary.test.ts§2), rather than having its sentence swapped and the QuickJS debug wrapper shipped asdeveloperMessage. 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 throughmapDataErrordirectly). Status is 409 before and after on every row.codeDUPLICATE_RECORDUNIQUE_VIOLATION(unchanged from the pre-#14095 wire)declaredCodefielduniqueViolationColumnemailerrorDuplicate record refused on 'x': …)A record with this email already exists/A record with this value already existsdeveloperMessagemessage) — names object and column, carries no valueobjecterror.object), falling back to the route'sWhich engine sentence rides on
developerMessage: the envelope'smessage(the diagnostic the card measured moving intoerror), not the envelope's owndeveloperMessagefield. That field addresses the in-process caller ofengine.insert— "attached ascause", "branch oncode === 'DUPLICATE_RECORD'" — and neither statement holds on this wire (nocauseis shipped; the wire code isUNIQUE_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 comment5511703122) adjudicated the field's declared semantics instead:DUPLICATE_RECORDis aStandardErrorCodemember (packages/spec/src/api/errors.zod.ts), andApiErrorSchema.declaredCode(packages/spec/src/api/contract.zod.ts— "the producer-declared code, verbatim, when it is not a member of the closedcodevocabulary") 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-writtendeclaredCodeemission 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 theisUniqueViolationErrorarm. §0 of the pin file keeps the control that both codes parse asErrorCode— now the reason the field is absent — §1 pins the absence (not.toHaveProperty('declaredCode')), and the three whole-bodytoEqualliterals no longer list it. The wirecodewas never in question.Measured and deliberately not changed here
POST …/createMany,…/batchand the other routes that exit throughhandleRouteError→resolveErrorResponseanswer the same envelope from that function's.statuspassthrough beforemapDataErroris ever consulted — measured on the base:409 {"error":"Duplicate record refused on 'duly_note': …","code":"DUPLICATE_RECORD"}, nofield, noobject. This arm cannot reach them (the scope fence forbids touching that passthrough, andDELETE_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 oforigin/main@90ff957e6e; closure rebuilt first, 25 packages): the pin file +rest-unique-violation-dialects.test.ts+rest-thrown-code-vocabulary.test.ts→Test Files 3 passed (3)·Tests 139 passed (139);pnpm --filter @objectstack/rest typecheckexit 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 atcdca42fbe1(node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands): 36 commands, byte-identical to the6d080822a6derivation, 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 nopackages/restchange (it movedobjectql/specsources inside the closure, hence the rebuild), so the full package suite, whole-repo lint and the union runs below stand as measured on6d080822a6.Full verification head
6d080822a6= the pins (ad0eca2c07) + the arm (583879db34) + a merge oforigin/main@04ee9f884c(da77b964df) + one type fix in the pin file (0511bab27f) + a merge oforigin/main@db7f3e4924. Every command below ran on6d080822a6after its closure was rebuilt (pnpm --filter "@objectstack/rest..." build, 26 packages,pnpm install --frozen-lockfilefirst because the lockfile moved); exit codes were captured after redirection; the verdict lines quoted are the gates' own.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',fieldabsent, the engine sentence inerror); §0 controls, the §5 gate pins and the no-value-echo pins were green on the base by design.rest-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).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 text1 → 0, markerABLATED_143890 → 1, blob49404f90…→1086ae66…— the pins then answeredTests 18 failed | 15 passed (33), the base red set exactly; restored withgit checkout HEAD --on the absolute path undertrap … EXIT INT TERM, blob back to49404f90…= the HEAD blob,git diff HEADempty. No rebuild leg is owed for the subject: the pins import./error-response.jsfrom the same package; the engine/driver dependencies are dist-resolved and were built before every run.pnpm --filter @objectstack/rest exec vitest run --maxWorkers=2:Test Files 171 passed (171)·Tests 2870 passed (2870).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). (The run onda77b964dfreported one type error in the new pin file —Errorhas nocode— fixed in0511bab27f; the ledger was not touched.)pnpm 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).node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsat6d080822a6(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-loadsandcheck:type-check-debt(both require the fullturbo run buildof every package: "Runpnpm buildfirst. ⛔ This is NOT a pass: nothing was measured"). CI owns those four. The deriver printed a STALE TREE note at that run (origin/mainhad moved on; the two changed inputs werescripts/check-docs-section-name.mjs, a docs family this diff never touches, andscripts/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.driver-memory/driver-sqlite-wasm/driver-sql, realmapDataError, 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 wirecodeand the status are unchanged.Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
🤖 Generated with Claude Code