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-trumpos-trump commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14389

What was wrong

Since #14095engine.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):

driverindexstatuscodefielderror
driver-sqlite-wasmsingle409 → 409UNIQUE_VIOLATIONDUPLICATE_RECORDemailabsentcurated → engine sentence
driver-sqlite-wasmcomposite409 → 409UNIQUE_VIOLATIONDUPLICATE_RECORDabsent → absentcurated (unnamed) → engine sentence
driver-sql (better-sqlite3)single409 → 409UNIQUE_VIOLATIONDUPLICATE_RECORDemailabsentcurated → engine sentence
driver-memorysingle409 → 409UNIQUE_VIOLATIONDUPLICATE_RECORDabsent → absentthe driver's own message, quoting the offending value as JSON → engine sentence (no value)
driver-memorycomposite409 → 409UNIQUE_VIOLATIONDUPLICATE_RECORDabsent → absentsame, 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'andname === '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.

keybefore (base, envelope through the passthrough)afterdriver-sqlite-wasm / driver-sql singlecomposite (any SQL dialect)driver-memoryMySQL / index-naming dialects
codeDUPLICATE_RECORDUNIQUE_VIOLATION (unchanged from the pre-#14095 wire)
declaredCodeabsentabsent (registered member — already in-process; ADR-0112)
fieldabsentthe column the engine resolved through uniqueViolationColumnemailabsent (by contract)absent (the driver's grammar names none)absent (an index name is never reported as a column)
errorthe engine's sentence (Duplicate record refused on 'x': …)curated: A record with this email already exists / A record with this value already existsnamedunnamedunnamedunnamed
developerMessageabsentthe engine's own sentence (message) — names object and column, carries no value
objectthe route's objectthe object the engine refused (error.object), falling back to the route's
offending value on the wirenone (the envelope carries none)none — pinned on the driver-memory shape whose raw refusal used to echo it as JSONpinned

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 head6d080822a6 = 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_143890 → 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-actionsBot 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 freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-trump@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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-trumpos-trump commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14389

What was wrong

Since #14095engine.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):

driverindexstatuscodefielderror
driver-sqlite-wasmsingle409 → 409UNIQUE_VIOLATIONDUPLICATE_RECORDemailabsentcurated → engine sentence
driver-sqlite-wasmcomposite409 → 409UNIQUE_VIOLATIONDUPLICATE_RECORDabsent → absentcurated (unnamed) → engine sentence
driver-sql (better-sqlite3)single409 → 409UNIQUE_VIOLATIONDUPLICATE_RECORDemailabsentcurated → engine sentence
driver-memorysingle409 → 409UNIQUE_VIOLATIONDUPLICATE_RECORDabsent → absentthe driver's own message, quoting the offending value as JSON → engine sentence (no value)
driver-memorycomposite409 → 409UNIQUE_VIOLATIONDUPLICATE_RECORDabsent → absentsame, 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'andname === '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.

keybefore (base, envelope through the passthrough)afterdriver-sqlite-wasm / driver-sql singlecomposite (any SQL dialect)driver-memoryMySQL / index-naming dialects
codeDUPLICATE_RECORDUNIQUE_VIOLATION (unchanged from the pre-#14095 wire)
declaredCodeabsentabsent (registered member — already in-process; ADR-0112)
fieldabsentthe column the engine resolved through uniqueViolationColumnemailabsent (by contract)absent (the driver's grammar names none)absent (an index name is never reported as a column)
errorthe engine's sentence (Duplicate record refused on 'x': …)curated: A record with this email already exists / A record with this value already existsnamedunnamedunnamedunnamed
developerMessageabsentthe engine's own sentence (message) — names object and column, carries no value
objectthe route's objectthe object the engine refused (error.object), falling back to the route's
offending value on the wirenone (the envelope carries none)none — pinned on the driver-memory shape whose raw refusal used to echo it as JSONpinned

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 head6d080822a6 = 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_143890 → 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-actionsBot 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 freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-trump@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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-trumpos-trump commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14389

What was wrong

Since #14095engine.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):

driverindexstatuscodefielderror
driver-sqlite-wasmsingle409 → 409UNIQUE_VIOLATIONDUPLICATE_RECORDemailabsentcurated → engine sentence
driver-sqlite-wasmcomposite409 → 409UNIQUE_VIOLATIONDUPLICATE_RECORDabsent → absentcurated (unnamed) → engine sentence
driver-sql (better-sqlite3)single409 → 409UNIQUE_VIOLATIONDUPLICATE_RECORDemailabsentcurated → engine sentence
driver-memorysingle409 → 409UNIQUE_VIOLATIONDUPLICATE_RECORDabsent → absentthe driver's own message, quoting the offending value as JSON → engine sentence (no value)
driver-memorycomposite409 → 409UNIQUE_VIOLATIONDUPLICATE_RECORDabsent → absentsame, 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'andname === '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.

keybefore (base, envelope through the passthrough)afterdriver-sqlite-wasm / driver-sql singlecomposite (any SQL dialect)driver-memoryMySQL / index-naming dialects
codeDUPLICATE_RECORDUNIQUE_VIOLATION (unchanged from the pre-#14095 wire)
declaredCodeabsentabsent (registered member — already in-process; ADR-0112)
fieldabsentthe column the engine resolved through uniqueViolationColumnemailabsent (by contract)absent (the driver's grammar names none)absent (an index name is never reported as a column)
errorthe engine's sentence (Duplicate record refused on 'x': …)curated: A record with this email already exists / A record with this value already existsnamedunnamedunnamedunnamed
developerMessageabsentthe engine's own sentence (message) — names object and column, carries no value
objectthe route's objectthe object the engine refused (error.object), falling back to the route's
offending value on the wirenone (the envelope carries none)none — pinned on the driver-memory shape whose raw refusal used to echo it as JSONpinned

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 head6d080822a6 = 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_143890 → 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-actionsBot 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 freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-trump@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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-trumpos-trump commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14389

What was wrong

Since #14095engine.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):

driverindexstatuscodefielderror
driver-sqlite-wasmsingle409 → 409UNIQUE_VIOLATIONDUPLICATE_RECORDemailabsentcurated → engine sentence
driver-sqlite-wasmcomposite409 → 409UNIQUE_VIOLATIONDUPLICATE_RECORDabsent → absentcurated (unnamed) → engine sentence
driver-sql (better-sqlite3)single409 → 409UNIQUE_VIOLATIONDUPLICATE_RECORDemailabsentcurated → engine sentence
driver-memorysingle409 → 409UNIQUE_VIOLATIONDUPLICATE_RECORDabsent → absentthe driver's own message, quoting the offending value as JSON → engine sentence (no value)
driver-memorycomposite409 → 409UNIQUE_VIOLATIONDUPLICATE_RECORDabsent → absentsame, 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'andname === '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.

keybefore (base, envelope through the passthrough)afterdriver-sqlite-wasm / driver-sql singlecomposite (any SQL dialect)driver-memoryMySQL / index-naming dialects
codeDUPLICATE_RECORDUNIQUE_VIOLATION (unchanged from the pre-#14095 wire)
declaredCodeabsentabsent (registered member — already in-process; ADR-0112)
fieldabsentthe column the engine resolved through uniqueViolationColumnemailabsent (by contract)absent (the driver's grammar names none)absent (an index name is never reported as a column)
errorthe engine's sentence (Duplicate record refused on 'x': …)curated: A record with this email already exists / A record with this value already existsnamedunnamedunnamedunnamed
developerMessageabsentthe engine's own sentence (message) — names object and column, carries no value
objectthe route's objectthe object the engine refused (error.object), falling back to the route's
offending value on the wirenone (the envelope carries none)none — pinned on the driver-memory shape whose raw refusal used to echo it as JSONpinned

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 head6d080822a6 = 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_143890 → 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-actionsBot 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 freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-trump@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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-trumpos-trump commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14389

What was wrong

Since #14095engine.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):

driverindexstatuscodefielderror
driver-sqlite-wasmsingle409 → 409UNIQUE_VIOLATIONDUPLICATE_RECORDemailabsentcurated → engine sentence
driver-sqlite-wasmcomposite409 → 409UNIQUE_VIOLATIONDUPLICATE_RECORDabsent → absentcurated (unnamed) → engine sentence
driver-sql (better-sqlite3)single409 → 409UNIQUE_VIOLATIONDUPLICATE_RECORDemailabsentcurated → engine sentence
driver-memorysingle409 → 409UNIQUE_VIOLATIONDUPLICATE_RECORDabsent → absentthe driver's own message, quoting the offending value as JSON → engine sentence (no value)
driver-memorycomposite409 → 409UNIQUE_VIOLATIONDUPLICATE_RECORDabsent → absentsame, 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'andname === '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.

keybefore (base, envelope through the passthrough)afterdriver-sqlite-wasm / driver-sql singlecomposite (any SQL dialect)driver-memoryMySQL / index-naming dialects
codeDUPLICATE_RECORDUNIQUE_VIOLATION (unchanged from the pre-#14095 wire)
declaredCodeabsentabsent (registered member — already in-process; ADR-0112)
fieldabsentthe column the engine resolved through uniqueViolationColumnemailabsent (by contract)absent (the driver's grammar names none)absent (an index name is never reported as a column)
errorthe engine's sentence (Duplicate record refused on 'x': …)curated: A record with this email already exists / A record with this value already existsnamedunnamedunnamedunnamed
developerMessageabsentthe engine's own sentence (message) — names object and column, carries no value
objectthe route's objectthe object the engine refused (error.object), falling back to the route's
offending value on the wirenone (the envelope carries none)none — pinned on the driver-memory shape whose raw refusal used to echo it as JSONpinned

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 head6d080822a6 = 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_143890 → 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-actionsBot 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 freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-trump@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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-trumpos-trump commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14389

What was wrong

Since #14095engine.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):

driverindexstatuscodefielderror
driver-sqlite-wasmsingle409 → 409UNIQUE_VIOLATIONDUPLICATE_RECORDemailabsentcurated → engine sentence
driver-sqlite-wasmcomposite409 → 409UNIQUE_VIOLATIONDUPLICATE_RECORDabsent → absentcurated (unnamed) → engine sentence
driver-sql (better-sqlite3)single409 → 409UNIQUE_VIOLATIONDUPLICATE_RECORDemailabsentcurated → engine sentence
driver-memorysingle409 → 409UNIQUE_VIOLATIONDUPLICATE_RECORDabsent → absentthe driver's own message, quoting the offending value as JSON → engine sentence (no value)
driver-memorycomposite409 → 409UNIQUE_VIOLATIONDUPLICATE_RECORDabsent → absentsame, 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'andname === '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.

keybefore (base, envelope through the passthrough)afterdriver-sqlite-wasm / driver-sql singlecomposite (any SQL dialect)driver-memoryMySQL / index-naming dialects
codeDUPLICATE_RECORDUNIQUE_VIOLATION (unchanged from the pre-#14095 wire)
declaredCodeabsentabsent (registered member — already in-process; ADR-0112)
fieldabsentthe column the engine resolved through uniqueViolationColumnemailabsent (by contract)absent (the driver's grammar names none)absent (an index name is never reported as a column)
errorthe engine's sentence (Duplicate record refused on 'x': …)curated: A record with this email already exists / A record with this value already existsnamedunnamedunnamedunnamed
developerMessageabsentthe engine's own sentence (message) — names object and column, carries no value
objectthe route's objectthe object the engine refused (error.object), falling back to the route's
offending value on the wirenone (the envelope carries none)none — pinned on the driver-memory shape whose raw refusal used to echo it as JSONpinned

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 head6d080822a6 = 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_143890 → 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-actionsBot 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 freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-trump@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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-trumpos-trump commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14389

What was wrong

Since #14095engine.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):

driverindexstatuscodefielderror
driver-sqlite-wasmsingle409 → 409UNIQUE_VIOLATIONDUPLICATE_RECORDemailabsentcurated → engine sentence
driver-sqlite-wasmcomposite409 → 409UNIQUE_VIOLATIONDUPLICATE_RECORDabsent → absentcurated (unnamed) → engine sentence
driver-sql (better-sqlite3)single409 → 409UNIQUE_VIOLATIONDUPLICATE_RECORDemailabsentcurated → engine sentence
driver-memorysingle409 → 409UNIQUE_VIOLATIONDUPLICATE_RECORDabsent → absentthe driver's own message, quoting the offending value as JSON → engine sentence (no value)
driver-memorycomposite409 → 409UNIQUE_VIOLATIONDUPLICATE_RECORDabsent → absentsame, 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'andname === '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.

keybefore (base, envelope through the passthrough)afterdriver-sqlite-wasm / driver-sql singlecomposite (any SQL dialect)driver-memoryMySQL / index-naming dialects
codeDUPLICATE_RECORDUNIQUE_VIOLATION (unchanged from the pre-#14095 wire)
declaredCodeabsentabsent (registered member — already in-process; ADR-0112)
fieldabsentthe column the engine resolved through uniqueViolationColumnemailabsent (by contract)absent (the driver's grammar names none)absent (an index name is never reported as a column)
errorthe engine's sentence (Duplicate record refused on 'x': …)curated: A record with this email already exists / A record with this value already existsnamedunnamedunnamedunnamed
developerMessageabsentthe engine's own sentence (message) — names object and column, carries no value
objectthe route's objectthe object the engine refused (error.object), falling back to the route's
offending value on the wirenone (the envelope carries none)none — pinned on the driver-memory shape whose raw refusal used to echo it as JSONpinned

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 head6d080822a6 = 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_143890 → 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-actionsBot 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 freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-trump@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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-trumpos-trump commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14389

What was wrong

Since #14095engine.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):

driverindexstatuscodefielderror
driver-sqlite-wasmsingle409 → 409UNIQUE_VIOLATIONDUPLICATE_RECORDemailabsentcurated → engine sentence
driver-sqlite-wasmcomposite409 → 409UNIQUE_VIOLATIONDUPLICATE_RECORDabsent → absentcurated (unnamed) → engine sentence
driver-sql (better-sqlite3)single409 → 409UNIQUE_VIOLATIONDUPLICATE_RECORDemailabsentcurated → engine sentence
driver-memorysingle409 → 409UNIQUE_VIOLATIONDUPLICATE_RECORDabsent → absentthe driver's own message, quoting the offending value as JSON → engine sentence (no value)
driver-memorycomposite409 → 409UNIQUE_VIOLATIONDUPLICATE_RECORDabsent → absentsame, 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'andname === '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.

keybefore (base, envelope through the passthrough)afterdriver-sqlite-wasm / driver-sql singlecomposite (any SQL dialect)driver-memoryMySQL / index-naming dialects
codeDUPLICATE_RECORDUNIQUE_VIOLATION (unchanged from the pre-#14095 wire)
declaredCodeabsentabsent (registered member — already in-process; ADR-0112)
fieldabsentthe column the engine resolved through uniqueViolationColumnemailabsent (by contract)absent (the driver's grammar names none)absent (an index name is never reported as a column)
errorthe engine's sentence (Duplicate record refused on 'x': …)curated: A record with this email already exists / A record with this value already existsnamedunnamedunnamedunnamed
developerMessageabsentthe engine's own sentence (message) — names object and column, carries no value
objectthe route's objectthe object the engine refused (error.object), falling back to the route's
offending value on the wirenone (the envelope carries none)none — pinned on the driver-memory shape whose raw refusal used to echo it as JSONpinned

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 head6d080822a6 = 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_143890 → 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-actionsBot 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 freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-trump@claude