fix(objectql): insert answers a driver unique violation with the DUPLICATE_RECORD envelope, on every driver - #14405
Conversation
…14095) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
… the resync pins (#14095) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…4095) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…es the new suite (#14095) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…sert-unique-violation-envelope
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…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
📓 Docs Drift CheckThis PR changes 2 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 21 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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
|
…#14095) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…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
|
Provenance ( Generated by Claude Code |
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 台账里)的平台错误,原驱动错误作cause⇒insert在每个驱动上有同一份契约」. Direction 2 (re-exporting the predicate frompackages/spec) is not touched here.What
engine.insertnow raisesDuplicateRecordError(packages/objectql/src/duplicate-record-error.ts), exported from the package index:code: 'DUPLICATE_RECORD',status: 409, the driver's error WHOLE oncause, plusobject,developerMessage, andfieldwhen — and only when —uniqueViolationColumndeterminably named the conflicting COLUMN. The verdict is the sharedisUniqueViolationErrorpredicate; this door adds no dialect knowledge and matches no message text of its own.DUPLICATE_RECORDneeded nopackages/specchange: it is already a member ofStandardErrorCode(the 409 conflict group), soErrorCode.safeParseadmits it andcheck:dispatcher-error-vocabularydemands no ledger row — its verdict line on this branch ischeck-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
createWithAutonumberResync, the "not our collision" exitdriver.create(field vanished from the schema mid-flight)try, so it is a second exit; given its owntry/catch. Pinned.ERR_AUTONUMBER_COLLISIONsays somethingDUPLICATE_RECORDcannot ("re-seeded, re-issued, still refused"). Itscauseis still the driver's error at ONE step, not two — pinned.driver.bulkCreatebulkCreate)insertMany(partial-row mode)insertwith__partialRowErrors. Pinned directly, not by inspection.ObjectRepository.insert/.create(what a hook reaches asctx.api.object(name))catch(the operator log)cause— see below.secretDriver.create('sys_secret', …)(secret-field persistence)sys_secretrow under a generated handle id; a conflict there is an internal id collision, not a caller-visible duplicate. Reachable fromupdateas well, so enveloping it here would put half a contract on a shared helper.update/upsertREST, end to end (measured, not inferred)
Real engine, real drivers, the real
mapDataError.BEFOREis the same conflict's raw driver error handed to the same boundary:fieldon the bodydriver-sqlite-wasmUNIQUE_VIOLATION→DUPLICATE_RECORDemail→ absentdriver-sqlite-wasmUNIQUE_VIOLATION→DUPLICATE_RECORDdriver-memoryUNIQUE_VIOLATION→DUPLICATE_RECORDdriver-memoryUNIQUE_VIOLATION→DUPLICATE_RECORDThe declared status passes through on every case — no sanitised 500.
classifyDataError's declared-status passthrough honours the envelope'sstatus. Two things do move, and neither is patched here becausepackages/restis another lane: the wirecodechanges toDUPLICATE_RECORD(both spellings registered), and the flat body loses thefieldkey, because the passthrough arm ships no structured fields. The remedy is a dedicatedDUPLICATE_RECORDarm placed withDELETE_RESTRICTED/CONCURRENT_UPDATEahead 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:
toFailedResultreadserr.code, which was previously a dialect token (SQLITE_CONSTRAINT_UNIQUE,11000) and is nowDUPLICATE_RECORD;sanitizeRowErrorpasses 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 withinsert/update/delete/… as a leaked statement — measured, and a test holds it.Which drivers this is OBSERVED on
driver-sqlite-wasm, real store): ENFORCED and OBSERVED — single-column and the card's composite index, both answeringDUPLICATE_RECORD/ 409 with the compiled INSERT preserved oncause.driver-memory, real store): ENFORCED and OBSERVED, oncesyncSchemahas run — that is the pass where the driver learns declared constraints.main: the card measured published 17.2.0, and driver-memory enforces field-leveluniquebut not object-level declaredindexes[]— a composite unique is a real constraint on driver-sql and nothing at all in memory #13239 (landed asb7f645a8a) taught this driver object-level declaredindexes[]uniqueness on top of [finding] driver-memory enforces no uniqueness, so an out-of-process duplicate autonumber lands SILENTLY and the write succeeds — the #5499 freeze that deferred the fix dissolved 2026-08-11 #13197's field-levelunique. Its refusal already declaredUNIQUE_VIOLATION/ 409; the envelope normalises it so an application reads ONE code across drivers.engine-autonumber-resync.test.tsalready measured. Their shapes are covered by unit fixtures (23505 with its DETAIL line,ER_DUP_ENTRYnaming an index,E11000), not by a live server.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.cshape, so it is also the case a message-matching wrap gets wrong.Ablation — the card's reproduction, both legs proven
envelopeUniqueViolationreduced to a pass-through, on a REAL sqlite store throughdist/:Both legs rebuilt (the probe resolves through
exportstodist/, notsrc/), 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
catchlogse instanceof DuplicateRecordError ? e.cause : e. The platform logger serializes an error'smessageandstackand 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.tspins it; the caller's answer is unaffected, sinceeis what is rethrown.Patch round — the consequence lands with the cause
The first round shipped the ObjectQL half and reported two
packages/runtimepins 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/runtimeis 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 theVALIDATION_FAILEDshape) and withhold it otherwise. Neither rule moved. What moved is which side of it a duplicate row is on — the insert door now declaresDUPLICATE_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.tsThe 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.
seedFailureCausereaderr.messagealone — complete while every producer put its whole diagnosis there, and incomplete the moment one started ENVELOPING. With the envelope onmessageand the driver's error oncause, the operator line printed the platform sentence andUNIQUE constraint failed: dt_acct.emailreached neither the response nor the log.So the log follows the hop:
seedFailureCausewalks thecausechain (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.seedCauseLabelmoved 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 alwayserr.message. Now the two differ: the payload quotes the PLATFORM sentence, this line prints the DRIVER sentence, and the old question answeredCause— 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, soCausemeans "these are the same words" and all three populations stay correct.Pin retriage — assertion by assertion
message'The create of this record failed. The reason is in the server log.'clientFacingRowFailureTextquotes it instead of withholdingcode/httpStatusINTERNAL_ERROR, no status)DUPLICATE_RECORD/409insert into,dup@example.com,UNIQUE constraint failedabsentSQLITE_CONSTRAINTaddedcause, which never reaches response datamessagethe data engine rejected the write; the reason is in the server logDuplicate record refused on 'dt_acct', the column clause, and the SAMErecord #1 (name=second)locatorUNIQUE constraint failed,SQLITE_CONSTRAINT,insert into,dup@example.comabsent from the wireloggedcontainsUNIQUE constraint failedcausehop REPAIRSloggedcontainsCause (withheld from the seed 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
deleteManyDataFK 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.tsis not this card's surface (#14179 is in flight on it). Measured on the batch rig: with the row disclosed the sink returns before itsconsole.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 saysPart of #14403rather thanFixesit.Ablation for the
causehopDirection predicted BEFORE the run: seed integration RED on the operator half only; batch integration GREEN; metadata-protocol's own
seed-loader-driver-text.test.tsGREEN (its producers carry nocause).All three predictions held. The runtime suites resolve
@objectstack/metadata-protocolthroughdist/, 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/mainmerged viascripts/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 test— 256 files / 4428 tests passedpnpm --filter @objectstack/metadata-protocol test— 153 files passed / 2 skipped, 2107 tests passedpnpm --filter @objectstack/runtime test— 207 files / 3061 tests passed (was 2 failed; the reason the patch round exists)pnpm --filter @objectstack/rest test— 168 files / 2804 tests passedpnpm --filter @objectstack/objectql --filter @objectstack/metadata-protocol typecheck— exit 0,check:test-typecheck: OKpnpm lint(repo-wideeslint . --no-inline-config) — exit 0, no findings. A full sweep, not a narrowed run.pnpm build(whole workspace) — 71/71 tasksnode 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 thescripts/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 bypnpm 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.check-test-completeness(exit 3 — needs the test-run log CI tees) andcheck-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 one8131d679(Lint & Repo Gates, job 100131896366):✗ DUPLICATE_RECORD: baselined as unpinned, but a producer now declares its status — ratchet the baseline down with --update.DuplicateRecordErroris 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 bynode scripts/check-error-status-conformance.mjs --update: one line, one deletion,DUPLICATE_RECORDremoved fromscripts/error-status-unpinned-baseline.json'sunpinnedlist and nothing else moved. The documented status was confirmed FIRST, and it agrees with the producer:content/docs/protocol/kernel/error-handling.mdxpublishes HTTP Status: 409 forDUPLICATE_RECORD, andcontent/docs/api/error-catalog.mdxfiles 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.reconciled: 19 code(s) with a derived producer, 20 (code, status) pair(s) matchedboth 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 onlyunpinned: … (baselined: 33)→(baselined: 32). Since the baseline file lives underscripts/, the gate is now also path-derived, andcheck:entry-guard(which names this script) is green.check-system-context-censuswent 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 (thecodeaxis) 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), andCause/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 nocauseis 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