Skip to content

fix(objectql): insert answers a driver unique violation with the DUPLICATE_RECORD envelope, on every driver - #14405

Merged
os-musk merged 10 commits into
mainfrom
claude/issue-14095-insert-unique-violation-envelope
Sep 2, 2026
Merged

fix(objectql): insert answers a driver unique violation with the DUPLICATE_RECORD envelope, on every driver#14405
os-musk merged 10 commits into
mainfrom
claude/issue-14095-insert-unique-violation-envelope

Conversation

@os-musk

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

Copy link
Copy Markdown
Collaborator

Fixes #14095
Part of #14403

The platform recommends "declare a unique index, attempt the insert, swallow the violation" — it is what lets an idempotent writer be an ordinary job instead of needing a distributed lock, and this package's own autonumber-resync doc argues at length against the read-then-write alternative. An application could not complete that pattern, because the insert door rethrew the DRIVER's error verbatim.

Triage ruling 2026-09-01, verbatim: 「抛一个带既有词表码(DUPLICATE_RECORD 已在 ADR-0112 台账里)的平台错误,原驱动错误作 causeinsert 在每个驱动上有同一份契约」. Direction 2 (re-exporting the predicate from packages/spec) is not touched here.

What engine.insert now raises

DuplicateRecordError (packages/objectql/src/duplicate-record-error.ts), exported from the package index: code: 'DUPLICATE_RECORD', status: 409, the driver's error WHOLE on cause, plus object, developerMessage, and field when — and only when — uniqueViolationColumn determinably named the conflicting COLUMN. The verdict is the shared isUniqueViolationError predicate; this door adds no dialect knowledge and matches no message text of its own.

DUPLICATE_RECORD needed no packages/spec change: it is already a member of StandardErrorCode (the 409 conflict group), so ErrorCode.safeParse admits it and check:dispatcher-error-vocabulary demands no ledger row — its verdict line on this branch is check-dispatcher-error-vocabulary: OK — 56 unregistered code-stamping site(s), all classified; 1 awaiting a ledger entry (#8846), and that one is not this code.

Per-path verdict — every way a driver create failure leaves the door

path verdict
single row — createWithAutonumberResync, the "not our collision" exit WRAPPED. The seam a plain unique violation takes today.
single row — the resync's LAST-CHANCE driver.create (field vanished from the schema mid-flight) WRAPPED. It sits outside the loop's own try, so it is a second exit; given its own try/catch. Pinned.
single row — bounded re-issue exhausted ALREADY ENVELOPED, left alone: ERR_AUTONUMBER_COLLISION says something DUPLICATE_RECORD cannot ("re-seeded, re-issued, still refused"). Its cause is still the driver's error at ONE step, not two — pinned.
batch — driver.bulkCreate WRAPPED.
batch — the per-row fallback loop (driver with no bulkCreate) WRAPPED (same catch).
insertMany (partial-row mode) WRAPPED, by delegation: it calls insert with __partialRowErrors. Pinned directly, not by inspection.
ObjectRepository.insert / .create (what a hook reaches as ctx.api.object(name)) WRAPPED, by delegation to the same door. Pinned.
the insert door's outer catch (the operator log) NOT a wrap site. It logs and rethrows. Changed only so the LOG takes cause — see below.
secretDriver.create('sys_secret', …) (secret-field persistence) NOT WRAPPED — not a unique-violation path for a caller. It writes a sys_secret row under a generated handle id; a conflict there is an internal id collision, not a caller-visible duplicate. Reachable from update as well, so enveloping it here would put half a contract on a shared helper.
update / upsert OUT OF SCOPE, and MEASURED to still leak — filed as #14390.

REST, end to end (measured, not inferred)

Real engine, real drivers, the real mapDataError. BEFORE is the same conflict's raw driver error handed to the same boundary:

driver index status code field on the body
driver-sqlite-wasm single column 409 → 409 UNIQUE_VIOLATIONDUPLICATE_RECORD emailabsent
driver-sqlite-wasm composite (the card's own shape) 409 → 409 UNIQUE_VIOLATIONDUPLICATE_RECORD absent → absent
driver-memory single column 409 → 409 UNIQUE_VIOLATIONDUPLICATE_RECORD absent → absent
driver-memory composite 409 → 409 UNIQUE_VIOLATIONDUPLICATE_RECORD absent → absent

The declared status passes through on every case — no sanitised 500. classifyDataError's declared-status passthrough honours the envelope's status. Two things do move, and neither is patched here because packages/rest is another lane: the wire code changes to DUPLICATE_RECORD (both spellings registered), and the flat body loses the field key, because the passthrough arm ships no structured fields. The remedy is a dedicated DUPLICATE_RECORD arm placed with DELETE_RESTRICTED / CONCURRENT_UPDATE ahead of that passthrough — filed as #14389 with the readings and the two wire-contract questions it has to answer.

Import row reports were measured separately and IMPROVE: toFailedResult reads err.code, which was previously a dialect token (SQLITE_CONSTRAINT_UNIQUE, 11000) and is now DUPLICATE_RECORD; sanitizeRowError passes the platform sentence through unchanged. The envelope's wording is deliberately pinned NOT to open with a SQL verb, because that sanitiser's backstop discards any message starting with insert/update/delete/… as a leaked statement — measured, and a test holds it.

Which drivers this is OBSERVED on

Positive controls — the negative side is pinned, on identity

A NOT NULL violation, a deadlock, a missing table and an unreachable store each leave the door as the very object the driver threw — asserted with expect(failure).toBe(raw), in both the single-row and the batch path. Message-based assertions would have passed for a wrap that rebuilt the error, which is the point: this is the assertion a future "helpful" re-wrap of every driver error has to break. SQLite spells NOT NULL and UNIQUE with the same … constraint failed: t.c shape, so it is also the case a message-matching wrap gets wrong.

Ablation — the card's reproduction, both legs proven

envelopeUniqueViolation reduced to a pass-through, on a REAL sqlite store through dist/:

mutate leg   anchor matched exactly once; envelope construction sites 1 -> 0; marker 0 -> 1
             build rc=0 · ablation-dist-preflight (marker PRESENT in dist) rc=0
             pins: 18 failed / 88 passed of 106  <- the tests discriminate
             probe: { "name": "Error", "code": undefined, "status": undefined, "cause": undefined,
                      "message": "insert into `duly_note` (…) values ('a@b.example', …) - UNIQUE constraint f…" }
                      ^ the card's defect, reproduced verbatim

restore leg  git checkout HEAD -- THE_FILE; blob abc40bd3 == HEAD blob abc40bd3
             whole-tree `git status --porcelain` EMPTY
             rebuild rc=0 · ablation-dist-preflight --absent rc=0
             probe: { "name": "DuplicateRecordError", "code": "DUPLICATE_RECORD", "status": 409, "field": "email",
                      "cause.message": "insert into `duly_note` … - UNIQUE constraint f…" }
             pins: 106 passed / 106

Both legs rebuilt (the probe resolves through exports to dist/, not src/), the mutation confirmed on disk by anchored counts before the build, and the restore proven by blob hash plus a whole-tree status rather than a per-path diff.

The operator log keeps what the database said

The insert door's catch logs e instanceof DuplicateRecordError ? e.cause : e. The platform logger serializes an error's message and stack and nothing else, so logging the envelope in the driver error's place would have silently dropped the failing column and MySQL's index name — the exact diagnosis #8682 put that line there to keep. driver-fault-redaction.test.ts pins it; the caller's answer is unaffected, since e is what is rethrown.

Patch round — the consequence lands with the cause

The first round shipped the ObjectQL half and reported two packages/runtime pins going red. The PM ruled open question 1 as A (routing note on this card) — the consequence lands with the cause — so this round carries it. packages/runtime is fully green: 207 files / 3061 tests.

Why the pins moved at all

Both sinks are POSITIVE lists: they quote a caught sentence exactly when the producer DECLARES a client refusal (a 4xx status/statusCode, or the VALIDATION_FAILED shape) and withhold it otherwise. Neither rule moved. What moved is which side of it a duplicate row is on — the insert door now declares DUPLICATE_RECORD / 409, so the row is a declared refusal and the sink quotes it. That is the remedy those sinks document ("declaring is cheaper than the workaround"), taken by the producer.

The seed-loader fix — packages/metadata-protocol/src/seed-loader.ts

The seed channel has two halves by design: the payload quotes only a declared refusal, the log carries the caught sentence ALWAYS, because withholding text nothing else records is indistinguishable from deleting the diagnostic. seedFailureCause read err.message alone — complete while every producer put its whole diagnosis there, and incomplete the moment one started ENVELOPING. With the envelope on message and the driver's error on cause, the operator line printed the platform sentence and UNIQUE constraint failed: dt_acct.email reached neither the response nor the log.

So the log follows the hop: seedFailureCause walks the cause chain (bounded at 4, the depth @objectstack/types' predicate walks) and prints the DEEPEST non-empty sentence. Structural, never a type check — this package must not import @objectstack/objectql, and an envelope from any producer earns the same treatment.

seedCauseLabel moved with it, or the marker would have gone false. It asked "was this ERROR's text withheld?", identical to the right question while the printed sentence was always err.message. Now the two differ: the payload quotes the PLATFORM sentence, this line prints the DRIVER sentence, and the old question answered Cause — telling an operator the reporter saw words the reporter never saw. It now compares the sentence about to be printed against the one the payload quoted, so Cause means "these are the same words" and all three populations stay correct.

Pin retriage — assertion by assertion

file · assertion before after why the population moved
batch · row message 'The create of this record failed. The reason is in the server log.' the platform sentence, exact the row DECLARES 409, so clientFacingRowFailureText quotes it instead of withholding
batch · row code / httpStatus not asserted (was INTERNAL_ERROR, no status) DUPLICATE_RECORD / 409 the machine half an idempotent batch writer branches on now exists
batch · the four leak assertions insert into, dup@example.com, UNIQUE constraint failed absent UNCHANGED, plus SQLITE_CONSTRAINT added the platform sentence carries no statement, value or dialect text; the driver error stays whole on cause, which never reaches response data
seed · row message contains the data engine rejected the write; the reason is in the server log contains Duplicate record refused on 'dt_acct', the column clause, and the SAME record #1 (name=second) locator same positive-list rule, other side
seed · the four leak assertions UNIQUE constraint failed, SQLITE_CONSTRAINT, insert into, dup@example.com absent from the wire UNCHANGED, byte for byte same reason
seed · logged contains UNIQUE constraint failed passing UNCHANGED — and it is what the cause hop REPAIRS without the hop this is the assertion that dies; see the ablation
seed · logged contains Cause (withheld from the seed response) passing UNCHANGED — still TRUE, for a newly subtle reason the payload quoted the platform sentence, so the DRIVER sentence this line prints genuinely was withheld from the response

⛔ Not a re-baseline: no leak assertion and no operator assertion was relaxed, and the withheld population keeps a live control in each file — the batch file's deleteManyData FK case declares no status, still takes the withheld branch, and still says the generic sentence on the very next test.

The residual that is NOT fixed here, measured

packages/metadata-protocol/src/protocol.ts is not this card's surface (#14179 is in flight on it). Measured on the batch rig: with the row disclosed the sink returns before its console.warn, so the warn fires zero times and the driver's sentence reaches neither the response nor the console — disclosure removed the carrier without replacing it. Deliberately NOT asserted either way in the test (asserting the zero would pin the loss as correct); recorded there as a comment and tracked as the residual on #14403, which is why this PR says Part of #14403 rather than Fixes it.

Ablation for the cause hop

Direction predicted BEFORE the run: seed integration RED on the operator half only; batch integration GREEN; metadata-protocol's own seed-loader-driver-text.test.ts GREEN (its producers carry no cause).

mutate   anchor matched exactly once; cause-walk lines 1 -> 0; marker 0 -> 1
         build rc=0 · ablation-dist-preflight (marker PRESENT in dist) rc=0
         seed integration  rc=1  ->  AssertionError: expected '[SeedLoader] Failed to write dt_acct …'
                                     to contain 'UNIQUE constraint failed'      <- the predicted half
         batch integration rc=0  ->  3 passed
         metadata-protocol seed text suite rc=0 -> 10 passed
restore  blob c9b945e8 == HEAD blob c9b945e8 · whole-tree `git status --porcelain` EMPTY
         rebuild rc=0 · preflight --absent rc=0 · both runtime files 4 passed

All three predictions held. The runtime suites resolve @objectstack/metadata-protocol through dist/, so both legs rebuilt and both were proved against the built artifact, not the source.

Verification

Run on 683eeedf3, after the final commit, on the merged tree (origin/main merged via scripts/pm/os-regen-merge.sh; the census regenerated on the merged tree AND again after the seed-loader edit, never hand-edited):

  • pnpm --filter @objectstack/objectql test256 files / 4428 tests passed
  • pnpm --filter @objectstack/metadata-protocol test153 files passed / 2 skipped, 2107 tests passed
  • pnpm --filter @objectstack/runtime test207 files / 3061 tests passed (was 2 failed; the reason the patch round exists)
  • pnpm --filter @objectstack/rest test — 168 files / 2804 tests passed
  • pnpm --filter @objectstack/objectql --filter @objectstack/metadata-protocol typecheck — exit 0, check:test-typecheck: OK
  • pnpm lint (repo-wide eslint . --no-inline-config) — exit 0, no findings. A full sweep, not a narrowed run.
  • pnpm build (whole workspace) — 71/71 tasks
  • Gate family re-derived on the new head with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands = 70 commands — 57 at round 1, 62 once the merge widened the change set, and 70 now that the baseline file puts this PR under the scripts/ families. 68 green, 0 red. (Three — check:skill-examples, check:dual-build-cjs-loads, check:type-check-debt — first exited PREREQUISITE NOT MET in the freshly re-created worktree, were satisfied by pnpm build, and re-ran green; they are not in the NOT MEASURED set.) Verdict lines: check-dispatcher-error-vocabulary: OK — 56 unregistered code-stamping site(s), all classified; check-engine-double-contract: OK — 746 pinned, 134 in the DEBT ledger, 3 exempt; check-system-context-census: OK — 109 elevation read sites in 20 packages across 45 files, all anchored; query-options-erasure ratchet holds: 67 unswept non-test site(s), none new; check-type-check-coverage: OK — 67/78 workspace packages type-checked; check:entry-guard: 198 scripts/ file(s) — every entry guard goes through invoked-as.mjs.
  • NOT MEASURED, by the gates' own verdict text and neither red: check-test-completeness (exit 3 — needs the test-run log CI tees) and check-half-states (exit 3 — needs repo-scoped egress this container lacks).
  • ⚠️ check:error-status-conformance — an ALWAYS-RUNS gate the path derivation did not list at the previous head, so it is run explicitly. CI caught it red on e8131d679 (Lint & Repo Gates, job 100131896366): ✗ DUPLICATE_RECORD: baselined as unpinned, but a producer now declares its status — ratchet the baseline down with --update. DuplicateRecordError is the FIRST producer to declare this code's status, so the code leaves the unpinned census — the gate's own prescribed, shrink-only remedy. Baseline rewritten by node scripts/check-error-status-conformance.mjs --update: one line, one deletion, DUPLICATE_RECORD removed from scripts/error-status-unpinned-baseline.json's unpinned list and nothing else moved. The documented status was confirmed FIRST, and it agrees with the producer: content/docs/protocol/kernel/error-handling.mdx publishes HTTP Status: 409 for DUPLICATE_RECORD, and content/docs/api/error-catalog.mdx files it under | 409 | conflict | — so this was a baseline ratchet, not a doc/contract question. Now green: ✓ every derivable runtime status is documented, and every documented status is reachable. ⚠️ One reading differs from what was predicted for this round: the matched-pair count did NOT go 20 → 21. It reads reconciled: 19 code(s) with a derived producer, 20 (code, status) pair(s) matched both BEFORE and AFTER the baseline edit — the producer was already inside the reconciled set in the red run (which is what the red message itself asserts), and what moved was only unpinned: … (baselined: 33)(baselined: 32). Since the baseline file lives under scripts/, the gate is now also path-derived, and check:entry-guard (which names this script) is green.
  • ⚠️ check-system-context-census went RED mid-round on pure line rot — the seed-loader edit shifted anchors the page cites. Repaired by regenerating (3 anchors re-anchored) and committed; re-run green on the final head. Reported rather than quietly fixed, because it is the failure mode the census exists to catch.

Clause ② self-reading

Re-read on the FINAL diff: still yes.

  • packages/objectql — the accept/reject behaviour of a public data-API door changes: a raw driver error becomes a platform envelope carrying a registered code and a declared status, on every driver. Observable on the HTTP wire (the code axis) and on batch/seed response data. minor.
  • packages/metadata-protocol — the seed loader's OPERATOR line changes what it prints for an enveloped fault (the driver's sentence rather than the wrapper's), and Cause / Cause (withheld…) now labels the printed sentence rather than the error. Log-channel only: no payload, wire body or status moves, and a producer carrying no cause is byte-identical. patch.
  • packages/runtime — TEST-ONLY. Two pins retriaged; no source touched.
  • scripts/error-status-unpinned-baseline.json — a gate's own shrink-only census, one line removed. No contract, no runtime behaviour, no documented status: the doc already published 409 and the producer now agrees with it, which is the whole reason the code left the unpinned list.

No schema member is added or removed and no path surface widens. Both wire consequences are enumerated in the changesets rather than left to be discovered.

Authored in Claude Code session session_0112hMx9hjJ9BgB28X97DS68 (https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68) — recorded in the body prose because a body edit demotes the footer form.


🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68

Generated by Claude Code

…se; retriage the two runtime disclosure pins (#14095)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-protocol, @objectstack/objectql, touching 15 documentable anchor(s).

6 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 DUPLICATE_RECORD_CODE; a string literal in ObjectQL; a string literal on a changed line))
  • content/docs/data-modeling/drivers.mdx (via idx_email_unique (literal, a string literal on a changed line))
  • content/docs/deployment/troubleshooting.mdx (via developerMessage (symbol, a field of class DuplicateRecordError))
  • content/docs/protocol/kernel/error-handling.mdx (via DUPLICATE_RECORD (literal, a string literal in DUPLICATE_RECORD_CODE; a string literal in ObjectQL; a string literal on a changed line))
  • content/docs/protocol/kernel/http-protocol.mdx (via idx_email_unique (literal, a string literal on a changed line))
  • content/docs/protocol/objectql/types.mdx (via developerMessage (symbol, a field of class DuplicateRecordError))
What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 65 pages)
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 21 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 793065de2c03936d4dd88f7026a1530d4c52c462packageMentionDocs.

Which tree this was computed on

This run read content/docs from 383fab968c05d344e97bdb719939849ca6b0bc2e — the merge of head 683eeedf3327055d6459b917be06e607bac1bcb9 into base 793065de2c03936d4dd88f7026a1530d4c52c462, 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 383fab968c05d344e97bdb719939849ca6b0bc2e && git checkout 383fab968c05d344e97bdb719939849ca6b0bc2e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 793065de2c03936d4dd88f7026a1530d4c52c462 683eeedf3327055d6459b917be06e607bac1bcb9 && git checkout -B drift-repro 793065de2c03936d4dd88f7026a1530d4c52c462 && git merge --no-ff 683eeedf3327055d6459b917be06e607bac1bcb9

node scripts/docs-audit/affected-docs.mjs --json 793065de2c03936d4dd88f7026a1530d4c52c462

⚠️ 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 793065de2c03936d4dd88f7026a1530d4c52c462 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…RECORD (#14095)

`DuplicateRecordError` is the first producer to declare this code's status, so
the code leaves the unpinned census. Baseline written by
`check-error-status-conformance.mjs --update`; shrink-only, one line.

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

os-musk commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

Provenance (domain:engine seat, session session_0112hMx9hjJ9BgB28X97DS68, 06:28Z): flipped to ready and auto-merge (squash) armed on head 683eeedf3. Contract review PASS at tier on the card: 5504861826 (at e8131d679); the 683eeedf3 delta is one baseline deletion, re-read 5505287862; needs:contract-review verified clear on card and PR by API. Every check run on this head completed success or skipped (Lint & Repo Gates 06:25:44Z); mergeable_state: clean; governed-surface test on the exact thirteen-file list: NOT governed. Landing to-do at MERGED: verify by content on origin/main, strip pm:dispatched from #14095 (Fixes closes it); #14403 stays open (Part of) — residual = the batch-row console.warn half in protocol.ts; #14419 (services) unblocks; the engine.ts chain advances.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants