fix(objectql): update answers a driver unique violation with the DUPLICATE_RECORD envelope, on every driver (#14390) - #14603

Merged
os-musk merged 6 commits into
mainfrom
claude/issue-14390-update-unique-violation-envelope
Sep 2, 2026
Merged

fix(objectql): update answers a driver unique violation with the DUPLICATE_RECORD envelope, on every driver (#14390)#14603
os-musk merged 6 commits into
mainfrom
claude/issue-14390-update-unique-violation-envelope

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

Fixes#14390

engine.update now answers a driver's unique-constraint refusal with the ADR-0112 envelope the insert door has carried since #14095DuplicateRecordError: code: 'DUPLICATE_RECORD', status: 409, the driver's own error whole on cause, object, developerMessage, and field only when uniqueViolationColumn determinably named the conflicting column. Both driver exits of the door are covered — the by-id driver.update call and the predicate (multi: true) driver.updateMany call — so the scoped-repository facade (ctx.api.object(name).update(...) / .updateById(...)) inherits it. Everything that is not a recognised unique violation passes through untouched, on identity. The Update operation failed log line keeps the driver's own diagnosis through the envelope's cause.

Triage's asymmetry sentence, which the changeset carries: the same user action now answers 409 DUPLICATE_RECORD on create and 500 INTERNAL_ERROR on edit.

Measured — before and after, real engine on real drivers

Rig: a real ObjectQL, engine.registerDriver(driver, true) + init(), an object with indexes: [{ fields: ['email'], unique: true }], driven through engine.update. Temporary vitest rig in packages/runtime (has both drivers as deps), run against the built @objectstack/objectql dist before and after the change; the rig was not committed. Raw JSON in the report comment's tests field.

driverdoorBEFORE (origin/main 9c7d9d4)AFTER
driver-sqlite-wasmby-idError, code/status/cause all absent, keys=[], message = the compiled UPDATE with bound values (update \duly_note` set `id` = '…', `email` = 'a@b.example', … where `id` = '…' - UNIQUE constraint failed: duly_note.email`)DuplicateRecordErrorDUPLICATE_RECORD / 409, field: 'email', object: 'duly_note', cause = that same raw error
driver-sqlite-wasmpredicate (where: {title}, multi: true, 2 matched rows onto one value)same raw shape, message = update … set \email` = 'z@z.example' … where `title` = 'grp' - UNIQUE constraint failed: duly_note.email`envelope, field: 'email', cause = raw; the matched rows are untouched (both still hold their old emails)
driver-memoryby-idits own envelope UNIQUE_VIOLATION / 409, no cause, declared-index sentence (… over (\email`) …`)DUPLICATE_RECORD / 409, cause = the UNIQUE_VIOLATION error (cause.code === 'UNIQUE_VIOLATION'), no fielduniqueViolationColumn answers undefined for that sentence, measured, so none is invented
driver-memorypredicatesame UNIQUE_VIOLATION / 409same envelope, no field
bothcontrol (update onto a free value)succeedssucceeds (non-vacuity)

REST boundary status resolution (resolveThrownHttpError(e, 500).status from @objectstack/types, the passthrough the REST layer uses): raw driver error → 500; DuplicateRecordError409. The wire code is not decided here (#14389's lane, untouched; packages/rest not edited).

Placement — Zone 2 assumption 1: measured, and the outer catch is NOT equivalent

update() has one try { (engine.ts, before the branch on isByIdWrite) whose catch (e) logs Update operation failed and rethrows. Measured contents of that try: the value-shape/validation passes, assertReferencesResolve, both driver exits, and the after phase — triggerHooks('afterUpdate', …) (per-row and record forms), recomputeSummaries (which re-enters this.update(desc.parentObject, …) on the master object), hydrateWriteFormulas, stripSearchCompanion. The before-phase hooks (beforeUpdate, per-row before hooks, unscoped-multi dispatch) run BEFORE that try. So an envelope at the outer catch would also run on errors thrown by afterUpdate hook code: a nested ctx.api write already envelopes on its own door with its own object name (idempotent at a second seam), but a raw driver error from a hook that reaches a store directly would be re-attributed to THIS object. The narrowest placement that wraps exactly the two driver exits was chosen (two four-line try { … } catch (driverError) { throw envelopeUniqueViolation(driverError, object); } blocks); the outer catch was changed only in what it logs. Pinned: engine-update-duplicate-record.test.ts › "a raw unique violation thrown INSIDE an afterUpdate hook is not attributed to this object" (the caller receives the hook's raw SQLITE_CONSTRAINT_UNIQUE error on identity, not a doc envelope).

Operator log — Zone 2 assumption 2: measured before and after

Before (real sqlite-wasm): Update operation failed carried err.message = "UNIQUE constraint failed: duly_note.email [statement and bound values redacted]", meta { object: 'duly_note' }. After: byte-identical on both doors — the catch logs e instanceof DuplicateRecordError ? e.cause : e through the same redactBoundStatement, exactly as the insert door does. The envelope's own message (Duplicate record refused on 'duly_note': a unique constraint on 'email' already holds this value. No record was written.) opens with no SQL verb and carries no statement or bound value; pinned (the raw message DOES open with update, which sanitizeRowError's backstop would discard).

Recognisability across drivers — Zone 2 assumption 3

Measured live on the two in-process stores above (sqlite-wasm: by-id and predicate; driver-memory: by-id and predicate). driver-sqlite-wasm overrides neither update nor updateMany (SqlDriver's builder.update(...) propagates the knex error raw, no wrapping — read at sql-driver.tsupdate/updateMany), so the SQL family's shapes are the dialect fixtures sql-driver-unique-violation-predicate.test.ts records: Postgres (23505, column in detail) and MySQL (ER_DUP_ENTRY/1062, index name only) are pinned through fixtures in the new pin file. NOT MEASURED on a live Postgres, MySQL, MongoDB or Turso store — no such store is reachable from this container.

Multi-row — Zone 2 assumption 4

Driven for real (two matched rows onto one value through updateMany, table above) and pinned: the predicate envelope carries exactly the own-keys of the by-id envelope (Object.keys set-equal), no rows/count/index property, no "one of N" wording, the same message; field is present only when the dialect named a column (sqlite fixture → email; mysql fixture → absent; driver-memory live → absent). No row attribution is invented, per triage.

Facades — Zone 2 assumption 5

ObjectRepository.update(data) and updateById(id, data) (what a hook reaches as ctx.api.object(name)) both delegate to engine.update — read at engine.ts class ObjectRepository; there is no second update door. Both are pinned reaching the envelope.

Changeset level — Zone 2 assumption 6

@objectstack/objectql: minor, the level #14095 shipped for the same envelope on insert (.changeset/insert-unique-violation-duplicate-record-envelope.md): callers observe a different error object on a public data-API door. node scripts/check-changeset-no-major.mjs✓ This diff introduces no major bump. (exit 0; the pnpm check:changeset-no-major spelling does not exist as a script — exit 254 "Command not found", i.e. that spelling is NOT MEASURED; the derived node scripts/… spelling is the one run). check:empty-changeset exit 0. The changeset carries triage's asymmetry sentence.

Clause-② self-reading

Yes, read from this diff. The accept/reject set of engine.update is unchanged: every input that was accepted is still accepted, every driver refusal is still a refusal, and the negative controls (NOT NULL, deadlock, missing table, unreachable store) are pinned on identity on both exits. What changes is the SHAPE of one existing refusal on a published verb: a recognised unique violation gains code (DUPLICATE_RECORD) and status (409) where it had neither, with the driver error moved one step down onto cause, so the REST answer for that refusal moves 500 → 409 (measured through resolveThrownHttpError). No new public export (DuplicateRecordError / DUPLICATE_RECORD_CODE were exported by #14095); no packages/spec change; no signature change on DuplicateRecordError. needs:contract-review is hung on this PR for the in-seat contract review; the PR stays draft.

The negative contract, verbatim, one pin per class per exit

engine-update-duplicate-record.test.ts (29 cases): NOT NULL, missing table, deadlock, unreachable store — each asserted toBe(raw) (identity), not a DuplicateRecordError, status undefined, on the by-id door AND on the predicate door (8 pins), plus the predicate-level control that SQLite's NOT NULL sentence is not a unique violation. Refusal pins assert code AND status, never a bare toThrow().

Ablation

Reverted ONLY the two envelope throws (throw envelopeUniqueViolation(driverError, object);throw driverError;, 2 occurrences) from the committed state, with a trap … EXIT INT TERM restore to HEAD. Predicted before the run: the update pin file goes red on every positive-contract case — 16 of 29 (the idempotency pin, the 9 negative controls, the placement pin, the two raw-reader pins and the non-enveloped log pin stay green) — and the insert pin file stays green (its door is untouched). Observed: 16 failed / 13 passed in engine-update-duplicate-record.test.ts, engine-insert-duplicate-record.test.ts 24/24 passed. Mutation proven on disk by grep counts (envelope throws 2 → 0, ABLATION-14390 marker 0 → 2) and blob hash (36bec3e6…85c62f46…, ≠ HEAD). Restore proven: git hash-object = HEAD:packages/objectql/src/engine.ts (36bec3e6…), git diff HEAD --stat empty, marker count 0, throws 2. No dist/ leg applies: both pin files import ./engine relatively from src, so no exports resolution to dist/ is on the path.

Validation

git rev-parse --short HEAD = b488ecc44 (the last commit; the union below was derived and run at this head — node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands with no path list, 63 commands).

Gate union at b488ecc44: 63 derived commands run, 58 exit 0, 5 NOT MEASURED in their own words (exit codes captured after a redirect, never through a pipe):

  • node scripts/check-test-completeness.mjs → exit 3: "Nothing was measured … pass a saved turbo run test log — or, running the family locally, record this gate as NOT MEASURED."
  • pnpm check:dual-build-cjs-loads → exit 3: "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/ … ⛔ This is NOT a pass: nothing was measured." (53 unbuilt packages; a whole-repo pnpm build is CI's run.)
  • node scripts/pm/check-half-states.mjs → exit 3: needs GitHub read access this container does not have ("Treat this exit as an unread instrument").
  • pnpm --filter @objectstack/spec run check:skill-examples → exit 1, prerequisite text: "Build first, then re-run: pnpm --filter @objectstack/client-react build" — the client-react closure build never acquired the shared verification lock (see below), so this is NOT MEASURED, not a red.
  • pnpm check:type-check-debt → exit 3: "Build the closure first … ⛔ This is NOT a pass and NOT a finding: nothing was measured."

Every other derived gate exit 0 at b488ecc44, including the families the seed named: check:engine-double-contract (the pin file's driver double is a DRIVER shape — primary key second — and sits out of scan scope; no engine-double-contract.pinned.json rows were asked for), check:objectql-double-limit ("305 double(s) graded, 106 apply the caller's bound or refuse it loudly"), check:system-context-census ("109 elevation read sites … all anchored; 145 anchors resolve"), check:changeset-no-major ("✓ This diff introduces no major bump."), check-empty-changeset, check:test-source-alias, check:cross-package-test-inputs, check:swallow-census-controls, check:durability-log-level, check:where-matcher, check:type-check-coverage, spec check:docs / check:liveness / check:empty-state.

Always-runs: pnpm lint (whole repo, eslint . --no-inline-config) exit 0 at b488ecc44; pnpm check:nul-bytes exit 0 ("scanned 7923 text file(s) … no raw ASCII control bytes"); pnpm check:error-status-conformance exit 0 ("51 code(s) reconciled … 140 producer site(s) derived").

Declared narrowing on the post-merge head. After the second git merge origin/main (28 commits, moving packages/spec, packages/types, packages/metadata, packages/metadata-protocol), the dependency-closure rebuild could not be completed on this shared box: two attempts acquired scripts/pm/os-verify-lock.sh and were killed by the container's 10-minute foreground cap during packages/spec's DTS leg (exit 143), and five attempts (spec build ×3, spec DTS-only leg, pin suites) ended queue-timeout (exit 99) · never acquired behind other seats' whole-repo turbo run build/typecheck and closure-build holds (holders named in the report's tests). The six adjacent suites WERE measured at b488ecc44 on a later acquisition: Test Files 6 passed (6) · Tests 222 passed (222) (held the lock 18s · waited 160s), against the merged tree's JS dist — spec's ESM/CJS legs had completed before the cap kill; only its .d.ts leg is missing, which vitest does not read. So at b488ecc44 the objectql typecheck and the full objectql vitest run are NOT MEASUREDpackages/spec/dist/*.d.ts is absent in this worktree. The last measured readings are at eb144eaf6 (one merge earlier, closure freshly built from that tree): pnpm --filter @objectstack/objectql typecheck exit 0 (check:test-typecheck: OK — … 44 file(s) / 242 error(s) / 69 pinned signature(s), i.e. the debt ledger unchanged and the new pin file compiling with zero errors), pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2Test Files 260 passed (260) · Tests 4513 passed (4513) (held the lock 200s), and the six adjacent suites (engine-update-duplicate-record, engine-insert-duplicate-record, engine-autonumber-resync, driver-fault-redaction, engine-update-dispatch, hook-input-shape-contract) → 222/222. Between eb144eaf6 and b488ecc44 the only commits are the merge and the census regeneration; the four hand-edited files are byte-identical at both heads (git diff eb144eaf6 b488ecc44 -- … empty). CI's TypeScript Type Check and Test Core jobs are the authority on the merged head.

Deviations and notes

  • packages/objectql/src/duplicate-record-error.ts: prose-only, no signature change — the module header and envelopeUniqueViolation's doc now name both doors, and two words in string literals: the object param doc ("refused insert" → "refused write") and developerMessage ("refused this insert" → "refused this write"), because the envelope is now raised by update too and a developerMessage calling an update an insert is a wrong attribution handed to the application author. No pin asserted the old word (git grep "refused this insert" → only the source line); the insert pin file's developerMessage assertions (DUPLICATE_RECORD, cause) are unchanged and green.
  • The message sentence (… No record was written.) is unchanged: it is pinned by the insert file and is true of a refused update as well (nothing was persisted); making it verb-aware would be a constructor signature change, which is out.
  • upsert: there is no engine-level upsert doorObjectQL has no upsert method (update.options.upsert is a retired-key tombstone, [finding] options.upsert is accepted by engine.update()'s option surface and never read — a declared-but-unenforced key (ADR-0049) #8057), update() never reaches a driver upsert, and the driver-level upsert (driver-sql, driver-memory, driver-mongodb) shares no code path with either update exit. No stop condition was met and nothing was widened.
  • content/docs/permissions/system-context.mdx re-anchored twice with check-system-context-census.mjs --fix (never hand-edited): once for the engine.ts line shifts, once per merge of origin/main (the os-regen merge driver deferred it; the discharge commit follows each merge).
  • Measurements were taken on the tree at 12a0c1dc8 (before merging origin/main); engine.ts and duplicate-record-error.ts are byte-identical in the final head to that commit for the changed hunks (the merges brought no change to either file).
  • Two git merge origin/main (28 + 6 commits); never rebased or force-pushed.

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68


Generated by Claude Code

…DUPLICATE_RECORD (#14390)
Both driver exits of engine.update — the by-id driver.update call and the
predicate driver.updateMany call — now answer a recognised unique violation
with the ADR-0112 DuplicateRecordError envelope the insert door has carried
since #14095. Everything that is not a unique violation passes through
untouched; the Update operation failed log line keeps the driver's own
diagnosis through the envelope's cause.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…shifts (#14390)
Regenerated with node scripts/check-system-context-census.mjs --fix; never hand-edited.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Discharges the os-regen deferral the merge of origin/main recorded for
content/docs/permissions/system-context.mdx; regenerated with
node scripts/check-system-context-census.mjs --fix, never hand-edited.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
)
Discharges the os-regen deferral recorded by the second merge of origin/main
for content/docs/permissions/system-context.mdx; regenerated with
node scripts/check-system-context-census.mjs --fix, never hand-edited.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

2 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 ObjectQL))
  • content/docs/protocol/kernel/error-handling.mdx(via DUPLICATE_RECORD (literal, a string literal in ObjectQL))
What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 65 pages)
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 16 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 2a265361968a435a9303ad08aacb16f497118556packageMentionDocs.

Which tree this was computed on

This run read content/docs from 86458eddd7e2ca21870730662a91548b608f7d32 — the merge of head b488ecc44b0ddb03896a0bcc66f1ef365ade81e2 into base 2a265361968a435a9303ad08aacb16f497118556, 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 86458eddd7e2ca21870730662a91548b608f7d32 && git checkout 86458eddd7e2ca21870730662a91548b608f7d32
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2a265361968a435a9303ad08aacb16f497118556 b488ecc44b0ddb03896a0bcc66f1ef365ade81e2 && git checkout -B drift-repro 2a265361968a435a9303ad08aacb16f497118556 && git merge --no-ff b488ecc44b0ddb03896a0bcc66f1ef365ade81e2
node scripts/docs-audit/affected-docs.mjs --json 2a265361968a435a9303ad08aacb16f497118556

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@os-muskos-musk added needs:contract-review and removed documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026 — with Claude
@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Validation addendum at b488ecc44 (supersedes one sentence of the body's "Declared narrowing" paragraph). After the body was written, packages/spec's DTS leg finally acquired the shared verification lock (ACQUIRED after 15s, held the lock 261s): DTS ⚡️ Build success in 224025ms, check-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present, and then, chained in the same hold, pnpm --filter @objectstack/objectql typecheck → exit 0, check:test-typecheck: OK — @objectstack/objectql's test layer compiles under packages/objectql/tsconfig.test.json; 44 file(s) / 242 error(s) / 69 pinned signature(s) held in test-typecheck-debt.json. So the objectql typecheck IS measured at b488ecc44 (green, debt ledger unchanged). What remains NOT MEASURED at this head is the full objectql vitest run — the rest of the closure (types/core/metadata*/formula, all moved by the merge) could not be rebuilt (queue-timeout (exit 99) · never acquired · never reached the head of the queue, holder pnpm check:type-check-debt of another seat, held 231s); the six adjacent suites at this head are 222/222 as the body says, and the last full run is 260 files / 4513 tests green at eb144eaf6. CI's Test Core job is the authority on the merged head.

Generated by Claude Code


Generated by Claude Code

@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Landing provenance (engine execution seat, session session_0112hMx9hjJ9BgB28X97DS68): flipped ready and armed auto-merge (squash) at 14:57Z on head b488ecc44.


Generated by Claude Code

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

Labels

Projects

None yet

2 participants

@os-musk@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(objectql): update answers a driver unique violation with the DUPLICATE_RECORD envelope, on every driver (#14390) - #14603

Merged
os-musk merged 6 commits into
mainfrom
claude/issue-14390-update-unique-violation-envelope
Sep 2, 2026
Merged

fix(objectql): update answers a driver unique violation with the DUPLICATE_RECORD envelope, on every driver (#14390)#14603
os-musk merged 6 commits into
mainfrom
claude/issue-14390-update-unique-violation-envelope

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

Fixes#14390

engine.update now answers a driver's unique-constraint refusal with the ADR-0112 envelope the insert door has carried since #14095DuplicateRecordError: code: 'DUPLICATE_RECORD', status: 409, the driver's own error whole on cause, object, developerMessage, and field only when uniqueViolationColumn determinably named the conflicting column. Both driver exits of the door are covered — the by-id driver.update call and the predicate (multi: true) driver.updateMany call — so the scoped-repository facade (ctx.api.object(name).update(...) / .updateById(...)) inherits it. Everything that is not a recognised unique violation passes through untouched, on identity. The Update operation failed log line keeps the driver's own diagnosis through the envelope's cause.

Triage's asymmetry sentence, which the changeset carries: the same user action now answers 409 DUPLICATE_RECORD on create and 500 INTERNAL_ERROR on edit.

Measured — before and after, real engine on real drivers

Rig: a real ObjectQL, engine.registerDriver(driver, true) + init(), an object with indexes: [{ fields: ['email'], unique: true }], driven through engine.update. Temporary vitest rig in packages/runtime (has both drivers as deps), run against the built @objectstack/objectql dist before and after the change; the rig was not committed. Raw JSON in the report comment's tests field.

driverdoorBEFORE (origin/main 9c7d9d4)AFTER
driver-sqlite-wasmby-idError, code/status/cause all absent, keys=[], message = the compiled UPDATE with bound values (update \duly_note` set `id` = '…', `email` = 'a@b.example', … where `id` = '…' - UNIQUE constraint failed: duly_note.email`)DuplicateRecordErrorDUPLICATE_RECORD / 409, field: 'email', object: 'duly_note', cause = that same raw error
driver-sqlite-wasmpredicate (where: {title}, multi: true, 2 matched rows onto one value)same raw shape, message = update … set \email` = 'z@z.example' … where `title` = 'grp' - UNIQUE constraint failed: duly_note.email`envelope, field: 'email', cause = raw; the matched rows are untouched (both still hold their old emails)
driver-memoryby-idits own envelope UNIQUE_VIOLATION / 409, no cause, declared-index sentence (… over (\email`) …`)DUPLICATE_RECORD / 409, cause = the UNIQUE_VIOLATION error (cause.code === 'UNIQUE_VIOLATION'), no fielduniqueViolationColumn answers undefined for that sentence, measured, so none is invented
driver-memorypredicatesame UNIQUE_VIOLATION / 409same envelope, no field
bothcontrol (update onto a free value)succeedssucceeds (non-vacuity)

REST boundary status resolution (resolveThrownHttpError(e, 500).status from @objectstack/types, the passthrough the REST layer uses): raw driver error → 500; DuplicateRecordError409. The wire code is not decided here (#14389's lane, untouched; packages/rest not edited).

Placement — Zone 2 assumption 1: measured, and the outer catch is NOT equivalent

update() has one try { (engine.ts, before the branch on isByIdWrite) whose catch (e) logs Update operation failed and rethrows. Measured contents of that try: the value-shape/validation passes, assertReferencesResolve, both driver exits, and the after phase — triggerHooks('afterUpdate', …) (per-row and record forms), recomputeSummaries (which re-enters this.update(desc.parentObject, …) on the master object), hydrateWriteFormulas, stripSearchCompanion. The before-phase hooks (beforeUpdate, per-row before hooks, unscoped-multi dispatch) run BEFORE that try. So an envelope at the outer catch would also run on errors thrown by afterUpdate hook code: a nested ctx.api write already envelopes on its own door with its own object name (idempotent at a second seam), but a raw driver error from a hook that reaches a store directly would be re-attributed to THIS object. The narrowest placement that wraps exactly the two driver exits was chosen (two four-line try { … } catch (driverError) { throw envelopeUniqueViolation(driverError, object); } blocks); the outer catch was changed only in what it logs. Pinned: engine-update-duplicate-record.test.ts › "a raw unique violation thrown INSIDE an afterUpdate hook is not attributed to this object" (the caller receives the hook's raw SQLITE_CONSTRAINT_UNIQUE error on identity, not a doc envelope).

Operator log — Zone 2 assumption 2: measured before and after

Before (real sqlite-wasm): Update operation failed carried err.message = "UNIQUE constraint failed: duly_note.email [statement and bound values redacted]", meta { object: 'duly_note' }. After: byte-identical on both doors — the catch logs e instanceof DuplicateRecordError ? e.cause : e through the same redactBoundStatement, exactly as the insert door does. The envelope's own message (Duplicate record refused on 'duly_note': a unique constraint on 'email' already holds this value. No record was written.) opens with no SQL verb and carries no statement or bound value; pinned (the raw message DOES open with update, which sanitizeRowError's backstop would discard).

Recognisability across drivers — Zone 2 assumption 3

Measured live on the two in-process stores above (sqlite-wasm: by-id and predicate; driver-memory: by-id and predicate). driver-sqlite-wasm overrides neither update nor updateMany (SqlDriver's builder.update(...) propagates the knex error raw, no wrapping — read at sql-driver.tsupdate/updateMany), so the SQL family's shapes are the dialect fixtures sql-driver-unique-violation-predicate.test.ts records: Postgres (23505, column in detail) and MySQL (ER_DUP_ENTRY/1062, index name only) are pinned through fixtures in the new pin file. NOT MEASURED on a live Postgres, MySQL, MongoDB or Turso store — no such store is reachable from this container.

Multi-row — Zone 2 assumption 4

Driven for real (two matched rows onto one value through updateMany, table above) and pinned: the predicate envelope carries exactly the own-keys of the by-id envelope (Object.keys set-equal), no rows/count/index property, no "one of N" wording, the same message; field is present only when the dialect named a column (sqlite fixture → email; mysql fixture → absent; driver-memory live → absent). No row attribution is invented, per triage.

Facades — Zone 2 assumption 5

ObjectRepository.update(data) and updateById(id, data) (what a hook reaches as ctx.api.object(name)) both delegate to engine.update — read at engine.ts class ObjectRepository; there is no second update door. Both are pinned reaching the envelope.

Changeset level — Zone 2 assumption 6

@objectstack/objectql: minor, the level #14095 shipped for the same envelope on insert (.changeset/insert-unique-violation-duplicate-record-envelope.md): callers observe a different error object on a public data-API door. node scripts/check-changeset-no-major.mjs✓ This diff introduces no major bump. (exit 0; the pnpm check:changeset-no-major spelling does not exist as a script — exit 254 "Command not found", i.e. that spelling is NOT MEASURED; the derived node scripts/… spelling is the one run). check:empty-changeset exit 0. The changeset carries triage's asymmetry sentence.

Clause-② self-reading

Yes, read from this diff. The accept/reject set of engine.update is unchanged: every input that was accepted is still accepted, every driver refusal is still a refusal, and the negative controls (NOT NULL, deadlock, missing table, unreachable store) are pinned on identity on both exits. What changes is the SHAPE of one existing refusal on a published verb: a recognised unique violation gains code (DUPLICATE_RECORD) and status (409) where it had neither, with the driver error moved one step down onto cause, so the REST answer for that refusal moves 500 → 409 (measured through resolveThrownHttpError). No new public export (DuplicateRecordError / DUPLICATE_RECORD_CODE were exported by #14095); no packages/spec change; no signature change on DuplicateRecordError. needs:contract-review is hung on this PR for the in-seat contract review; the PR stays draft.

The negative contract, verbatim, one pin per class per exit

engine-update-duplicate-record.test.ts (29 cases): NOT NULL, missing table, deadlock, unreachable store — each asserted toBe(raw) (identity), not a DuplicateRecordError, status undefined, on the by-id door AND on the predicate door (8 pins), plus the predicate-level control that SQLite's NOT NULL sentence is not a unique violation. Refusal pins assert code AND status, never a bare toThrow().

Ablation

Reverted ONLY the two envelope throws (throw envelopeUniqueViolation(driverError, object);throw driverError;, 2 occurrences) from the committed state, with a trap … EXIT INT TERM restore to HEAD. Predicted before the run: the update pin file goes red on every positive-contract case — 16 of 29 (the idempotency pin, the 9 negative controls, the placement pin, the two raw-reader pins and the non-enveloped log pin stay green) — and the insert pin file stays green (its door is untouched). Observed: 16 failed / 13 passed in engine-update-duplicate-record.test.ts, engine-insert-duplicate-record.test.ts 24/24 passed. Mutation proven on disk by grep counts (envelope throws 2 → 0, ABLATION-14390 marker 0 → 2) and blob hash (36bec3e6…85c62f46…, ≠ HEAD). Restore proven: git hash-object = HEAD:packages/objectql/src/engine.ts (36bec3e6…), git diff HEAD --stat empty, marker count 0, throws 2. No dist/ leg applies: both pin files import ./engine relatively from src, so no exports resolution to dist/ is on the path.

Validation

git rev-parse --short HEAD = b488ecc44 (the last commit; the union below was derived and run at this head — node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands with no path list, 63 commands).

Gate union at b488ecc44: 63 derived commands run, 58 exit 0, 5 NOT MEASURED in their own words (exit codes captured after a redirect, never through a pipe):

  • node scripts/check-test-completeness.mjs → exit 3: "Nothing was measured … pass a saved turbo run test log — or, running the family locally, record this gate as NOT MEASURED."
  • pnpm check:dual-build-cjs-loads → exit 3: "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/ … ⛔ This is NOT a pass: nothing was measured." (53 unbuilt packages; a whole-repo pnpm build is CI's run.)
  • node scripts/pm/check-half-states.mjs → exit 3: needs GitHub read access this container does not have ("Treat this exit as an unread instrument").
  • pnpm --filter @objectstack/spec run check:skill-examples → exit 1, prerequisite text: "Build first, then re-run: pnpm --filter @objectstack/client-react build" — the client-react closure build never acquired the shared verification lock (see below), so this is NOT MEASURED, not a red.
  • pnpm check:type-check-debt → exit 3: "Build the closure first … ⛔ This is NOT a pass and NOT a finding: nothing was measured."

Every other derived gate exit 0 at b488ecc44, including the families the seed named: check:engine-double-contract (the pin file's driver double is a DRIVER shape — primary key second — and sits out of scan scope; no engine-double-contract.pinned.json rows were asked for), check:objectql-double-limit ("305 double(s) graded, 106 apply the caller's bound or refuse it loudly"), check:system-context-census ("109 elevation read sites … all anchored; 145 anchors resolve"), check:changeset-no-major ("✓ This diff introduces no major bump."), check-empty-changeset, check:test-source-alias, check:cross-package-test-inputs, check:swallow-census-controls, check:durability-log-level, check:where-matcher, check:type-check-coverage, spec check:docs / check:liveness / check:empty-state.

Always-runs: pnpm lint (whole repo, eslint . --no-inline-config) exit 0 at b488ecc44; pnpm check:nul-bytes exit 0 ("scanned 7923 text file(s) … no raw ASCII control bytes"); pnpm check:error-status-conformance exit 0 ("51 code(s) reconciled … 140 producer site(s) derived").

Declared narrowing on the post-merge head. After the second git merge origin/main (28 commits, moving packages/spec, packages/types, packages/metadata, packages/metadata-protocol), the dependency-closure rebuild could not be completed on this shared box: two attempts acquired scripts/pm/os-verify-lock.sh and were killed by the container's 10-minute foreground cap during packages/spec's DTS leg (exit 143), and five attempts (spec build ×3, spec DTS-only leg, pin suites) ended queue-timeout (exit 99) · never acquired behind other seats' whole-repo turbo run build/typecheck and closure-build holds (holders named in the report's tests). The six adjacent suites WERE measured at b488ecc44 on a later acquisition: Test Files 6 passed (6) · Tests 222 passed (222) (held the lock 18s · waited 160s), against the merged tree's JS dist — spec's ESM/CJS legs had completed before the cap kill; only its .d.ts leg is missing, which vitest does not read. So at b488ecc44 the objectql typecheck and the full objectql vitest run are NOT MEASUREDpackages/spec/dist/*.d.ts is absent in this worktree. The last measured readings are at eb144eaf6 (one merge earlier, closure freshly built from that tree): pnpm --filter @objectstack/objectql typecheck exit 0 (check:test-typecheck: OK — … 44 file(s) / 242 error(s) / 69 pinned signature(s), i.e. the debt ledger unchanged and the new pin file compiling with zero errors), pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2Test Files 260 passed (260) · Tests 4513 passed (4513) (held the lock 200s), and the six adjacent suites (engine-update-duplicate-record, engine-insert-duplicate-record, engine-autonumber-resync, driver-fault-redaction, engine-update-dispatch, hook-input-shape-contract) → 222/222. Between eb144eaf6 and b488ecc44 the only commits are the merge and the census regeneration; the four hand-edited files are byte-identical at both heads (git diff eb144eaf6 b488ecc44 -- … empty). CI's TypeScript Type Check and Test Core jobs are the authority on the merged head.

Deviations and notes

  • packages/objectql/src/duplicate-record-error.ts: prose-only, no signature change — the module header and envelopeUniqueViolation's doc now name both doors, and two words in string literals: the object param doc ("refused insert" → "refused write") and developerMessage ("refused this insert" → "refused this write"), because the envelope is now raised by update too and a developerMessage calling an update an insert is a wrong attribution handed to the application author. No pin asserted the old word (git grep "refused this insert" → only the source line); the insert pin file's developerMessage assertions (DUPLICATE_RECORD, cause) are unchanged and green.
  • The message sentence (… No record was written.) is unchanged: it is pinned by the insert file and is true of a refused update as well (nothing was persisted); making it verb-aware would be a constructor signature change, which is out.
  • upsert: there is no engine-level upsert doorObjectQL has no upsert method (update.options.upsert is a retired-key tombstone, [finding] options.upsert is accepted by engine.update()'s option surface and never read — a declared-but-unenforced key (ADR-0049) #8057), update() never reaches a driver upsert, and the driver-level upsert (driver-sql, driver-memory, driver-mongodb) shares no code path with either update exit. No stop condition was met and nothing was widened.
  • content/docs/permissions/system-context.mdx re-anchored twice with check-system-context-census.mjs --fix (never hand-edited): once for the engine.ts line shifts, once per merge of origin/main (the os-regen merge driver deferred it; the discharge commit follows each merge).
  • Measurements were taken on the tree at 12a0c1dc8 (before merging origin/main); engine.ts and duplicate-record-error.ts are byte-identical in the final head to that commit for the changed hunks (the merges brought no change to either file).
  • Two git merge origin/main (28 + 6 commits); never rebased or force-pushed.

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68


Generated by Claude Code

…DUPLICATE_RECORD (#14390)
Both driver exits of engine.update — the by-id driver.update call and the
predicate driver.updateMany call — now answer a recognised unique violation
with the ADR-0112 DuplicateRecordError envelope the insert door has carried
since #14095. Everything that is not a unique violation passes through
untouched; the Update operation failed log line keeps the driver's own
diagnosis through the envelope's cause.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…shifts (#14390)
Regenerated with node scripts/check-system-context-census.mjs --fix; never hand-edited.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Discharges the os-regen deferral the merge of origin/main recorded for
content/docs/permissions/system-context.mdx; regenerated with
node scripts/check-system-context-census.mjs --fix, never hand-edited.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
)
Discharges the os-regen deferral recorded by the second merge of origin/main
for content/docs/permissions/system-context.mdx; regenerated with
node scripts/check-system-context-census.mjs --fix, never hand-edited.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

2 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 ObjectQL))
  • content/docs/protocol/kernel/error-handling.mdx(via DUPLICATE_RECORD (literal, a string literal in ObjectQL))
What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 65 pages)
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 16 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 2a265361968a435a9303ad08aacb16f497118556packageMentionDocs.

Which tree this was computed on

This run read content/docs from 86458eddd7e2ca21870730662a91548b608f7d32 — the merge of head b488ecc44b0ddb03896a0bcc66f1ef365ade81e2 into base 2a265361968a435a9303ad08aacb16f497118556, 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 86458eddd7e2ca21870730662a91548b608f7d32 && git checkout 86458eddd7e2ca21870730662a91548b608f7d32
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2a265361968a435a9303ad08aacb16f497118556 b488ecc44b0ddb03896a0bcc66f1ef365ade81e2 && git checkout -B drift-repro 2a265361968a435a9303ad08aacb16f497118556 && git merge --no-ff b488ecc44b0ddb03896a0bcc66f1ef365ade81e2
node scripts/docs-audit/affected-docs.mjs --json 2a265361968a435a9303ad08aacb16f497118556

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@os-muskos-musk added needs:contract-review and removed documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026 — with Claude
@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Validation addendum at b488ecc44 (supersedes one sentence of the body's "Declared narrowing" paragraph). After the body was written, packages/spec's DTS leg finally acquired the shared verification lock (ACQUIRED after 15s, held the lock 261s): DTS ⚡️ Build success in 224025ms, check-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present, and then, chained in the same hold, pnpm --filter @objectstack/objectql typecheck → exit 0, check:test-typecheck: OK — @objectstack/objectql's test layer compiles under packages/objectql/tsconfig.test.json; 44 file(s) / 242 error(s) / 69 pinned signature(s) held in test-typecheck-debt.json. So the objectql typecheck IS measured at b488ecc44 (green, debt ledger unchanged). What remains NOT MEASURED at this head is the full objectql vitest run — the rest of the closure (types/core/metadata*/formula, all moved by the merge) could not be rebuilt (queue-timeout (exit 99) · never acquired · never reached the head of the queue, holder pnpm check:type-check-debt of another seat, held 231s); the six adjacent suites at this head are 222/222 as the body says, and the last full run is 260 files / 4513 tests green at eb144eaf6. CI's Test Core job is the authority on the merged head.

Generated by Claude Code


Generated by Claude Code

@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Landing provenance (engine execution seat, session session_0112hMx9hjJ9BgB28X97DS68): flipped ready and armed auto-merge (squash) at 14:57Z on head b488ecc44.


Generated by Claude Code

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

Labels

Projects

None yet

2 participants

@os-musk@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(objectql): update answers a driver unique violation with the DUPLICATE_RECORD envelope, on every driver (#14390) - #14603

Merged
os-musk merged 6 commits into
mainfrom
claude/issue-14390-update-unique-violation-envelope
Sep 2, 2026
Merged

fix(objectql): update answers a driver unique violation with the DUPLICATE_RECORD envelope, on every driver (#14390)#14603
os-musk merged 6 commits into
mainfrom
claude/issue-14390-update-unique-violation-envelope

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

Fixes#14390

engine.update now answers a driver's unique-constraint refusal with the ADR-0112 envelope the insert door has carried since #14095DuplicateRecordError: code: 'DUPLICATE_RECORD', status: 409, the driver's own error whole on cause, object, developerMessage, and field only when uniqueViolationColumn determinably named the conflicting column. Both driver exits of the door are covered — the by-id driver.update call and the predicate (multi: true) driver.updateMany call — so the scoped-repository facade (ctx.api.object(name).update(...) / .updateById(...)) inherits it. Everything that is not a recognised unique violation passes through untouched, on identity. The Update operation failed log line keeps the driver's own diagnosis through the envelope's cause.

Triage's asymmetry sentence, which the changeset carries: the same user action now answers 409 DUPLICATE_RECORD on create and 500 INTERNAL_ERROR on edit.

Measured — before and after, real engine on real drivers

Rig: a real ObjectQL, engine.registerDriver(driver, true) + init(), an object with indexes: [{ fields: ['email'], unique: true }], driven through engine.update. Temporary vitest rig in packages/runtime (has both drivers as deps), run against the built @objectstack/objectql dist before and after the change; the rig was not committed. Raw JSON in the report comment's tests field.

driverdoorBEFORE (origin/main 9c7d9d4)AFTER
driver-sqlite-wasmby-idError, code/status/cause all absent, keys=[], message = the compiled UPDATE with bound values (update \duly_note` set `id` = '…', `email` = 'a@b.example', … where `id` = '…' - UNIQUE constraint failed: duly_note.email`)DuplicateRecordErrorDUPLICATE_RECORD / 409, field: 'email', object: 'duly_note', cause = that same raw error
driver-sqlite-wasmpredicate (where: {title}, multi: true, 2 matched rows onto one value)same raw shape, message = update … set \email` = 'z@z.example' … where `title` = 'grp' - UNIQUE constraint failed: duly_note.email`envelope, field: 'email', cause = raw; the matched rows are untouched (both still hold their old emails)
driver-memoryby-idits own envelope UNIQUE_VIOLATION / 409, no cause, declared-index sentence (… over (\email`) …`)DUPLICATE_RECORD / 409, cause = the UNIQUE_VIOLATION error (cause.code === 'UNIQUE_VIOLATION'), no fielduniqueViolationColumn answers undefined for that sentence, measured, so none is invented
driver-memorypredicatesame UNIQUE_VIOLATION / 409same envelope, no field
bothcontrol (update onto a free value)succeedssucceeds (non-vacuity)

REST boundary status resolution (resolveThrownHttpError(e, 500).status from @objectstack/types, the passthrough the REST layer uses): raw driver error → 500; DuplicateRecordError409. The wire code is not decided here (#14389's lane, untouched; packages/rest not edited).

Placement — Zone 2 assumption 1: measured, and the outer catch is NOT equivalent

update() has one try { (engine.ts, before the branch on isByIdWrite) whose catch (e) logs Update operation failed and rethrows. Measured contents of that try: the value-shape/validation passes, assertReferencesResolve, both driver exits, and the after phase — triggerHooks('afterUpdate', …) (per-row and record forms), recomputeSummaries (which re-enters this.update(desc.parentObject, …) on the master object), hydrateWriteFormulas, stripSearchCompanion. The before-phase hooks (beforeUpdate, per-row before hooks, unscoped-multi dispatch) run BEFORE that try. So an envelope at the outer catch would also run on errors thrown by afterUpdate hook code: a nested ctx.api write already envelopes on its own door with its own object name (idempotent at a second seam), but a raw driver error from a hook that reaches a store directly would be re-attributed to THIS object. The narrowest placement that wraps exactly the two driver exits was chosen (two four-line try { … } catch (driverError) { throw envelopeUniqueViolation(driverError, object); } blocks); the outer catch was changed only in what it logs. Pinned: engine-update-duplicate-record.test.ts › "a raw unique violation thrown INSIDE an afterUpdate hook is not attributed to this object" (the caller receives the hook's raw SQLITE_CONSTRAINT_UNIQUE error on identity, not a doc envelope).

Operator log — Zone 2 assumption 2: measured before and after

Before (real sqlite-wasm): Update operation failed carried err.message = "UNIQUE constraint failed: duly_note.email [statement and bound values redacted]", meta { object: 'duly_note' }. After: byte-identical on both doors — the catch logs e instanceof DuplicateRecordError ? e.cause : e through the same redactBoundStatement, exactly as the insert door does. The envelope's own message (Duplicate record refused on 'duly_note': a unique constraint on 'email' already holds this value. No record was written.) opens with no SQL verb and carries no statement or bound value; pinned (the raw message DOES open with update, which sanitizeRowError's backstop would discard).

Recognisability across drivers — Zone 2 assumption 3

Measured live on the two in-process stores above (sqlite-wasm: by-id and predicate; driver-memory: by-id and predicate). driver-sqlite-wasm overrides neither update nor updateMany (SqlDriver's builder.update(...) propagates the knex error raw, no wrapping — read at sql-driver.tsupdate/updateMany), so the SQL family's shapes are the dialect fixtures sql-driver-unique-violation-predicate.test.ts records: Postgres (23505, column in detail) and MySQL (ER_DUP_ENTRY/1062, index name only) are pinned through fixtures in the new pin file. NOT MEASURED on a live Postgres, MySQL, MongoDB or Turso store — no such store is reachable from this container.

Multi-row — Zone 2 assumption 4

Driven for real (two matched rows onto one value through updateMany, table above) and pinned: the predicate envelope carries exactly the own-keys of the by-id envelope (Object.keys set-equal), no rows/count/index property, no "one of N" wording, the same message; field is present only when the dialect named a column (sqlite fixture → email; mysql fixture → absent; driver-memory live → absent). No row attribution is invented, per triage.

Facades — Zone 2 assumption 5

ObjectRepository.update(data) and updateById(id, data) (what a hook reaches as ctx.api.object(name)) both delegate to engine.update — read at engine.ts class ObjectRepository; there is no second update door. Both are pinned reaching the envelope.

Changeset level — Zone 2 assumption 6

@objectstack/objectql: minor, the level #14095 shipped for the same envelope on insert (.changeset/insert-unique-violation-duplicate-record-envelope.md): callers observe a different error object on a public data-API door. node scripts/check-changeset-no-major.mjs✓ This diff introduces no major bump. (exit 0; the pnpm check:changeset-no-major spelling does not exist as a script — exit 254 "Command not found", i.e. that spelling is NOT MEASURED; the derived node scripts/… spelling is the one run). check:empty-changeset exit 0. The changeset carries triage's asymmetry sentence.

Clause-② self-reading

Yes, read from this diff. The accept/reject set of engine.update is unchanged: every input that was accepted is still accepted, every driver refusal is still a refusal, and the negative controls (NOT NULL, deadlock, missing table, unreachable store) are pinned on identity on both exits. What changes is the SHAPE of one existing refusal on a published verb: a recognised unique violation gains code (DUPLICATE_RECORD) and status (409) where it had neither, with the driver error moved one step down onto cause, so the REST answer for that refusal moves 500 → 409 (measured through resolveThrownHttpError). No new public export (DuplicateRecordError / DUPLICATE_RECORD_CODE were exported by #14095); no packages/spec change; no signature change on DuplicateRecordError. needs:contract-review is hung on this PR for the in-seat contract review; the PR stays draft.

The negative contract, verbatim, one pin per class per exit

engine-update-duplicate-record.test.ts (29 cases): NOT NULL, missing table, deadlock, unreachable store — each asserted toBe(raw) (identity), not a DuplicateRecordError, status undefined, on the by-id door AND on the predicate door (8 pins), plus the predicate-level control that SQLite's NOT NULL sentence is not a unique violation. Refusal pins assert code AND status, never a bare toThrow().

Ablation

Reverted ONLY the two envelope throws (throw envelopeUniqueViolation(driverError, object);throw driverError;, 2 occurrences) from the committed state, with a trap … EXIT INT TERM restore to HEAD. Predicted before the run: the update pin file goes red on every positive-contract case — 16 of 29 (the idempotency pin, the 9 negative controls, the placement pin, the two raw-reader pins and the non-enveloped log pin stay green) — and the insert pin file stays green (its door is untouched). Observed: 16 failed / 13 passed in engine-update-duplicate-record.test.ts, engine-insert-duplicate-record.test.ts 24/24 passed. Mutation proven on disk by grep counts (envelope throws 2 → 0, ABLATION-14390 marker 0 → 2) and blob hash (36bec3e6…85c62f46…, ≠ HEAD). Restore proven: git hash-object = HEAD:packages/objectql/src/engine.ts (36bec3e6…), git diff HEAD --stat empty, marker count 0, throws 2. No dist/ leg applies: both pin files import ./engine relatively from src, so no exports resolution to dist/ is on the path.

Validation

git rev-parse --short HEAD = b488ecc44 (the last commit; the union below was derived and run at this head — node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands with no path list, 63 commands).

Gate union at b488ecc44: 63 derived commands run, 58 exit 0, 5 NOT MEASURED in their own words (exit codes captured after a redirect, never through a pipe):

  • node scripts/check-test-completeness.mjs → exit 3: "Nothing was measured … pass a saved turbo run test log — or, running the family locally, record this gate as NOT MEASURED."
  • pnpm check:dual-build-cjs-loads → exit 3: "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/ … ⛔ This is NOT a pass: nothing was measured." (53 unbuilt packages; a whole-repo pnpm build is CI's run.)
  • node scripts/pm/check-half-states.mjs → exit 3: needs GitHub read access this container does not have ("Treat this exit as an unread instrument").
  • pnpm --filter @objectstack/spec run check:skill-examples → exit 1, prerequisite text: "Build first, then re-run: pnpm --filter @objectstack/client-react build" — the client-react closure build never acquired the shared verification lock (see below), so this is NOT MEASURED, not a red.
  • pnpm check:type-check-debt → exit 3: "Build the closure first … ⛔ This is NOT a pass and NOT a finding: nothing was measured."

Every other derived gate exit 0 at b488ecc44, including the families the seed named: check:engine-double-contract (the pin file's driver double is a DRIVER shape — primary key second — and sits out of scan scope; no engine-double-contract.pinned.json rows were asked for), check:objectql-double-limit ("305 double(s) graded, 106 apply the caller's bound or refuse it loudly"), check:system-context-census ("109 elevation read sites … all anchored; 145 anchors resolve"), check:changeset-no-major ("✓ This diff introduces no major bump."), check-empty-changeset, check:test-source-alias, check:cross-package-test-inputs, check:swallow-census-controls, check:durability-log-level, check:where-matcher, check:type-check-coverage, spec check:docs / check:liveness / check:empty-state.

Always-runs: pnpm lint (whole repo, eslint . --no-inline-config) exit 0 at b488ecc44; pnpm check:nul-bytes exit 0 ("scanned 7923 text file(s) … no raw ASCII control bytes"); pnpm check:error-status-conformance exit 0 ("51 code(s) reconciled … 140 producer site(s) derived").

Declared narrowing on the post-merge head. After the second git merge origin/main (28 commits, moving packages/spec, packages/types, packages/metadata, packages/metadata-protocol), the dependency-closure rebuild could not be completed on this shared box: two attempts acquired scripts/pm/os-verify-lock.sh and were killed by the container's 10-minute foreground cap during packages/spec's DTS leg (exit 143), and five attempts (spec build ×3, spec DTS-only leg, pin suites) ended queue-timeout (exit 99) · never acquired behind other seats' whole-repo turbo run build/typecheck and closure-build holds (holders named in the report's tests). The six adjacent suites WERE measured at b488ecc44 on a later acquisition: Test Files 6 passed (6) · Tests 222 passed (222) (held the lock 18s · waited 160s), against the merged tree's JS dist — spec's ESM/CJS legs had completed before the cap kill; only its .d.ts leg is missing, which vitest does not read. So at b488ecc44 the objectql typecheck and the full objectql vitest run are NOT MEASUREDpackages/spec/dist/*.d.ts is absent in this worktree. The last measured readings are at eb144eaf6 (one merge earlier, closure freshly built from that tree): pnpm --filter @objectstack/objectql typecheck exit 0 (check:test-typecheck: OK — … 44 file(s) / 242 error(s) / 69 pinned signature(s), i.e. the debt ledger unchanged and the new pin file compiling with zero errors), pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2Test Files 260 passed (260) · Tests 4513 passed (4513) (held the lock 200s), and the six adjacent suites (engine-update-duplicate-record, engine-insert-duplicate-record, engine-autonumber-resync, driver-fault-redaction, engine-update-dispatch, hook-input-shape-contract) → 222/222. Between eb144eaf6 and b488ecc44 the only commits are the merge and the census regeneration; the four hand-edited files are byte-identical at both heads (git diff eb144eaf6 b488ecc44 -- … empty). CI's TypeScript Type Check and Test Core jobs are the authority on the merged head.

Deviations and notes

  • packages/objectql/src/duplicate-record-error.ts: prose-only, no signature change — the module header and envelopeUniqueViolation's doc now name both doors, and two words in string literals: the object param doc ("refused insert" → "refused write") and developerMessage ("refused this insert" → "refused this write"), because the envelope is now raised by update too and a developerMessage calling an update an insert is a wrong attribution handed to the application author. No pin asserted the old word (git grep "refused this insert" → only the source line); the insert pin file's developerMessage assertions (DUPLICATE_RECORD, cause) are unchanged and green.
  • The message sentence (… No record was written.) is unchanged: it is pinned by the insert file and is true of a refused update as well (nothing was persisted); making it verb-aware would be a constructor signature change, which is out.
  • upsert: there is no engine-level upsert doorObjectQL has no upsert method (update.options.upsert is a retired-key tombstone, [finding] options.upsert is accepted by engine.update()'s option surface and never read — a declared-but-unenforced key (ADR-0049) #8057), update() never reaches a driver upsert, and the driver-level upsert (driver-sql, driver-memory, driver-mongodb) shares no code path with either update exit. No stop condition was met and nothing was widened.
  • content/docs/permissions/system-context.mdx re-anchored twice with check-system-context-census.mjs --fix (never hand-edited): once for the engine.ts line shifts, once per merge of origin/main (the os-regen merge driver deferred it; the discharge commit follows each merge).
  • Measurements were taken on the tree at 12a0c1dc8 (before merging origin/main); engine.ts and duplicate-record-error.ts are byte-identical in the final head to that commit for the changed hunks (the merges brought no change to either file).
  • Two git merge origin/main (28 + 6 commits); never rebased or force-pushed.

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68


Generated by Claude Code

…DUPLICATE_RECORD (#14390)
Both driver exits of engine.update — the by-id driver.update call and the
predicate driver.updateMany call — now answer a recognised unique violation
with the ADR-0112 DuplicateRecordError envelope the insert door has carried
since #14095. Everything that is not a unique violation passes through
untouched; the Update operation failed log line keeps the driver's own
diagnosis through the envelope's cause.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…shifts (#14390)
Regenerated with node scripts/check-system-context-census.mjs --fix; never hand-edited.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Discharges the os-regen deferral the merge of origin/main recorded for
content/docs/permissions/system-context.mdx; regenerated with
node scripts/check-system-context-census.mjs --fix, never hand-edited.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
)
Discharges the os-regen deferral recorded by the second merge of origin/main
for content/docs/permissions/system-context.mdx; regenerated with
node scripts/check-system-context-census.mjs --fix, never hand-edited.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

2 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 ObjectQL))
  • content/docs/protocol/kernel/error-handling.mdx(via DUPLICATE_RECORD (literal, a string literal in ObjectQL))
What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 65 pages)
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 16 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 2a265361968a435a9303ad08aacb16f497118556packageMentionDocs.

Which tree this was computed on

This run read content/docs from 86458eddd7e2ca21870730662a91548b608f7d32 — the merge of head b488ecc44b0ddb03896a0bcc66f1ef365ade81e2 into base 2a265361968a435a9303ad08aacb16f497118556, 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 86458eddd7e2ca21870730662a91548b608f7d32 && git checkout 86458eddd7e2ca21870730662a91548b608f7d32
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2a265361968a435a9303ad08aacb16f497118556 b488ecc44b0ddb03896a0bcc66f1ef365ade81e2 && git checkout -B drift-repro 2a265361968a435a9303ad08aacb16f497118556 && git merge --no-ff b488ecc44b0ddb03896a0bcc66f1ef365ade81e2
node scripts/docs-audit/affected-docs.mjs --json 2a265361968a435a9303ad08aacb16f497118556

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@os-muskos-musk added needs:contract-review and removed documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026 — with Claude
@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Validation addendum at b488ecc44 (supersedes one sentence of the body's "Declared narrowing" paragraph). After the body was written, packages/spec's DTS leg finally acquired the shared verification lock (ACQUIRED after 15s, held the lock 261s): DTS ⚡️ Build success in 224025ms, check-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present, and then, chained in the same hold, pnpm --filter @objectstack/objectql typecheck → exit 0, check:test-typecheck: OK — @objectstack/objectql's test layer compiles under packages/objectql/tsconfig.test.json; 44 file(s) / 242 error(s) / 69 pinned signature(s) held in test-typecheck-debt.json. So the objectql typecheck IS measured at b488ecc44 (green, debt ledger unchanged). What remains NOT MEASURED at this head is the full objectql vitest run — the rest of the closure (types/core/metadata*/formula, all moved by the merge) could not be rebuilt (queue-timeout (exit 99) · never acquired · never reached the head of the queue, holder pnpm check:type-check-debt of another seat, held 231s); the six adjacent suites at this head are 222/222 as the body says, and the last full run is 260 files / 4513 tests green at eb144eaf6. CI's Test Core job is the authority on the merged head.

Generated by Claude Code


Generated by Claude Code

@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Landing provenance (engine execution seat, session session_0112hMx9hjJ9BgB28X97DS68): flipped ready and armed auto-merge (squash) at 14:57Z on head b488ecc44.


Generated by Claude Code

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

Labels

Projects

None yet

2 participants

@os-musk@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(objectql): update answers a driver unique violation with the DUPLICATE_RECORD envelope, on every driver (#14390) - #14603

Merged
os-musk merged 6 commits into
mainfrom
claude/issue-14390-update-unique-violation-envelope
Sep 2, 2026
Merged

fix(objectql): update answers a driver unique violation with the DUPLICATE_RECORD envelope, on every driver (#14390)#14603
os-musk merged 6 commits into
mainfrom
claude/issue-14390-update-unique-violation-envelope

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

Fixes#14390

engine.update now answers a driver's unique-constraint refusal with the ADR-0112 envelope the insert door has carried since #14095DuplicateRecordError: code: 'DUPLICATE_RECORD', status: 409, the driver's own error whole on cause, object, developerMessage, and field only when uniqueViolationColumn determinably named the conflicting column. Both driver exits of the door are covered — the by-id driver.update call and the predicate (multi: true) driver.updateMany call — so the scoped-repository facade (ctx.api.object(name).update(...) / .updateById(...)) inherits it. Everything that is not a recognised unique violation passes through untouched, on identity. The Update operation failed log line keeps the driver's own diagnosis through the envelope's cause.

Triage's asymmetry sentence, which the changeset carries: the same user action now answers 409 DUPLICATE_RECORD on create and 500 INTERNAL_ERROR on edit.

Measured — before and after, real engine on real drivers

Rig: a real ObjectQL, engine.registerDriver(driver, true) + init(), an object with indexes: [{ fields: ['email'], unique: true }], driven through engine.update. Temporary vitest rig in packages/runtime (has both drivers as deps), run against the built @objectstack/objectql dist before and after the change; the rig was not committed. Raw JSON in the report comment's tests field.

driverdoorBEFORE (origin/main 9c7d9d4)AFTER
driver-sqlite-wasmby-idError, code/status/cause all absent, keys=[], message = the compiled UPDATE with bound values (update \duly_note` set `id` = '…', `email` = 'a@b.example', … where `id` = '…' - UNIQUE constraint failed: duly_note.email`)DuplicateRecordErrorDUPLICATE_RECORD / 409, field: 'email', object: 'duly_note', cause = that same raw error
driver-sqlite-wasmpredicate (where: {title}, multi: true, 2 matched rows onto one value)same raw shape, message = update … set \email` = 'z@z.example' … where `title` = 'grp' - UNIQUE constraint failed: duly_note.email`envelope, field: 'email', cause = raw; the matched rows are untouched (both still hold their old emails)
driver-memoryby-idits own envelope UNIQUE_VIOLATION / 409, no cause, declared-index sentence (… over (\email`) …`)DUPLICATE_RECORD / 409, cause = the UNIQUE_VIOLATION error (cause.code === 'UNIQUE_VIOLATION'), no fielduniqueViolationColumn answers undefined for that sentence, measured, so none is invented
driver-memorypredicatesame UNIQUE_VIOLATION / 409same envelope, no field
bothcontrol (update onto a free value)succeedssucceeds (non-vacuity)

REST boundary status resolution (resolveThrownHttpError(e, 500).status from @objectstack/types, the passthrough the REST layer uses): raw driver error → 500; DuplicateRecordError409. The wire code is not decided here (#14389's lane, untouched; packages/rest not edited).

Placement — Zone 2 assumption 1: measured, and the outer catch is NOT equivalent

update() has one try { (engine.ts, before the branch on isByIdWrite) whose catch (e) logs Update operation failed and rethrows. Measured contents of that try: the value-shape/validation passes, assertReferencesResolve, both driver exits, and the after phase — triggerHooks('afterUpdate', …) (per-row and record forms), recomputeSummaries (which re-enters this.update(desc.parentObject, …) on the master object), hydrateWriteFormulas, stripSearchCompanion. The before-phase hooks (beforeUpdate, per-row before hooks, unscoped-multi dispatch) run BEFORE that try. So an envelope at the outer catch would also run on errors thrown by afterUpdate hook code: a nested ctx.api write already envelopes on its own door with its own object name (idempotent at a second seam), but a raw driver error from a hook that reaches a store directly would be re-attributed to THIS object. The narrowest placement that wraps exactly the two driver exits was chosen (two four-line try { … } catch (driverError) { throw envelopeUniqueViolation(driverError, object); } blocks); the outer catch was changed only in what it logs. Pinned: engine-update-duplicate-record.test.ts › "a raw unique violation thrown INSIDE an afterUpdate hook is not attributed to this object" (the caller receives the hook's raw SQLITE_CONSTRAINT_UNIQUE error on identity, not a doc envelope).

Operator log — Zone 2 assumption 2: measured before and after

Before (real sqlite-wasm): Update operation failed carried err.message = "UNIQUE constraint failed: duly_note.email [statement and bound values redacted]", meta { object: 'duly_note' }. After: byte-identical on both doors — the catch logs e instanceof DuplicateRecordError ? e.cause : e through the same redactBoundStatement, exactly as the insert door does. The envelope's own message (Duplicate record refused on 'duly_note': a unique constraint on 'email' already holds this value. No record was written.) opens with no SQL verb and carries no statement or bound value; pinned (the raw message DOES open with update, which sanitizeRowError's backstop would discard).

Recognisability across drivers — Zone 2 assumption 3

Measured live on the two in-process stores above (sqlite-wasm: by-id and predicate; driver-memory: by-id and predicate). driver-sqlite-wasm overrides neither update nor updateMany (SqlDriver's builder.update(...) propagates the knex error raw, no wrapping — read at sql-driver.tsupdate/updateMany), so the SQL family's shapes are the dialect fixtures sql-driver-unique-violation-predicate.test.ts records: Postgres (23505, column in detail) and MySQL (ER_DUP_ENTRY/1062, index name only) are pinned through fixtures in the new pin file. NOT MEASURED on a live Postgres, MySQL, MongoDB or Turso store — no such store is reachable from this container.

Multi-row — Zone 2 assumption 4

Driven for real (two matched rows onto one value through updateMany, table above) and pinned: the predicate envelope carries exactly the own-keys of the by-id envelope (Object.keys set-equal), no rows/count/index property, no "one of N" wording, the same message; field is present only when the dialect named a column (sqlite fixture → email; mysql fixture → absent; driver-memory live → absent). No row attribution is invented, per triage.

Facades — Zone 2 assumption 5

ObjectRepository.update(data) and updateById(id, data) (what a hook reaches as ctx.api.object(name)) both delegate to engine.update — read at engine.ts class ObjectRepository; there is no second update door. Both are pinned reaching the envelope.

Changeset level — Zone 2 assumption 6

@objectstack/objectql: minor, the level #14095 shipped for the same envelope on insert (.changeset/insert-unique-violation-duplicate-record-envelope.md): callers observe a different error object on a public data-API door. node scripts/check-changeset-no-major.mjs✓ This diff introduces no major bump. (exit 0; the pnpm check:changeset-no-major spelling does not exist as a script — exit 254 "Command not found", i.e. that spelling is NOT MEASURED; the derived node scripts/… spelling is the one run). check:empty-changeset exit 0. The changeset carries triage's asymmetry sentence.

Clause-② self-reading

Yes, read from this diff. The accept/reject set of engine.update is unchanged: every input that was accepted is still accepted, every driver refusal is still a refusal, and the negative controls (NOT NULL, deadlock, missing table, unreachable store) are pinned on identity on both exits. What changes is the SHAPE of one existing refusal on a published verb: a recognised unique violation gains code (DUPLICATE_RECORD) and status (409) where it had neither, with the driver error moved one step down onto cause, so the REST answer for that refusal moves 500 → 409 (measured through resolveThrownHttpError). No new public export (DuplicateRecordError / DUPLICATE_RECORD_CODE were exported by #14095); no packages/spec change; no signature change on DuplicateRecordError. needs:contract-review is hung on this PR for the in-seat contract review; the PR stays draft.

The negative contract, verbatim, one pin per class per exit

engine-update-duplicate-record.test.ts (29 cases): NOT NULL, missing table, deadlock, unreachable store — each asserted toBe(raw) (identity), not a DuplicateRecordError, status undefined, on the by-id door AND on the predicate door (8 pins), plus the predicate-level control that SQLite's NOT NULL sentence is not a unique violation. Refusal pins assert code AND status, never a bare toThrow().

Ablation

Reverted ONLY the two envelope throws (throw envelopeUniqueViolation(driverError, object);throw driverError;, 2 occurrences) from the committed state, with a trap … EXIT INT TERM restore to HEAD. Predicted before the run: the update pin file goes red on every positive-contract case — 16 of 29 (the idempotency pin, the 9 negative controls, the placement pin, the two raw-reader pins and the non-enveloped log pin stay green) — and the insert pin file stays green (its door is untouched). Observed: 16 failed / 13 passed in engine-update-duplicate-record.test.ts, engine-insert-duplicate-record.test.ts 24/24 passed. Mutation proven on disk by grep counts (envelope throws 2 → 0, ABLATION-14390 marker 0 → 2) and blob hash (36bec3e6…85c62f46…, ≠ HEAD). Restore proven: git hash-object = HEAD:packages/objectql/src/engine.ts (36bec3e6…), git diff HEAD --stat empty, marker count 0, throws 2. No dist/ leg applies: both pin files import ./engine relatively from src, so no exports resolution to dist/ is on the path.

Validation

git rev-parse --short HEAD = b488ecc44 (the last commit; the union below was derived and run at this head — node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands with no path list, 63 commands).

Gate union at b488ecc44: 63 derived commands run, 58 exit 0, 5 NOT MEASURED in their own words (exit codes captured after a redirect, never through a pipe):

  • node scripts/check-test-completeness.mjs → exit 3: "Nothing was measured … pass a saved turbo run test log — or, running the family locally, record this gate as NOT MEASURED."
  • pnpm check:dual-build-cjs-loads → exit 3: "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/ … ⛔ This is NOT a pass: nothing was measured." (53 unbuilt packages; a whole-repo pnpm build is CI's run.)
  • node scripts/pm/check-half-states.mjs → exit 3: needs GitHub read access this container does not have ("Treat this exit as an unread instrument").
  • pnpm --filter @objectstack/spec run check:skill-examples → exit 1, prerequisite text: "Build first, then re-run: pnpm --filter @objectstack/client-react build" — the client-react closure build never acquired the shared verification lock (see below), so this is NOT MEASURED, not a red.
  • pnpm check:type-check-debt → exit 3: "Build the closure first … ⛔ This is NOT a pass and NOT a finding: nothing was measured."

Every other derived gate exit 0 at b488ecc44, including the families the seed named: check:engine-double-contract (the pin file's driver double is a DRIVER shape — primary key second — and sits out of scan scope; no engine-double-contract.pinned.json rows were asked for), check:objectql-double-limit ("305 double(s) graded, 106 apply the caller's bound or refuse it loudly"), check:system-context-census ("109 elevation read sites … all anchored; 145 anchors resolve"), check:changeset-no-major ("✓ This diff introduces no major bump."), check-empty-changeset, check:test-source-alias, check:cross-package-test-inputs, check:swallow-census-controls, check:durability-log-level, check:where-matcher, check:type-check-coverage, spec check:docs / check:liveness / check:empty-state.

Always-runs: pnpm lint (whole repo, eslint . --no-inline-config) exit 0 at b488ecc44; pnpm check:nul-bytes exit 0 ("scanned 7923 text file(s) … no raw ASCII control bytes"); pnpm check:error-status-conformance exit 0 ("51 code(s) reconciled … 140 producer site(s) derived").

Declared narrowing on the post-merge head. After the second git merge origin/main (28 commits, moving packages/spec, packages/types, packages/metadata, packages/metadata-protocol), the dependency-closure rebuild could not be completed on this shared box: two attempts acquired scripts/pm/os-verify-lock.sh and were killed by the container's 10-minute foreground cap during packages/spec's DTS leg (exit 143), and five attempts (spec build ×3, spec DTS-only leg, pin suites) ended queue-timeout (exit 99) · never acquired behind other seats' whole-repo turbo run build/typecheck and closure-build holds (holders named in the report's tests). The six adjacent suites WERE measured at b488ecc44 on a later acquisition: Test Files 6 passed (6) · Tests 222 passed (222) (held the lock 18s · waited 160s), against the merged tree's JS dist — spec's ESM/CJS legs had completed before the cap kill; only its .d.ts leg is missing, which vitest does not read. So at b488ecc44 the objectql typecheck and the full objectql vitest run are NOT MEASUREDpackages/spec/dist/*.d.ts is absent in this worktree. The last measured readings are at eb144eaf6 (one merge earlier, closure freshly built from that tree): pnpm --filter @objectstack/objectql typecheck exit 0 (check:test-typecheck: OK — … 44 file(s) / 242 error(s) / 69 pinned signature(s), i.e. the debt ledger unchanged and the new pin file compiling with zero errors), pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2Test Files 260 passed (260) · Tests 4513 passed (4513) (held the lock 200s), and the six adjacent suites (engine-update-duplicate-record, engine-insert-duplicate-record, engine-autonumber-resync, driver-fault-redaction, engine-update-dispatch, hook-input-shape-contract) → 222/222. Between eb144eaf6 and b488ecc44 the only commits are the merge and the census regeneration; the four hand-edited files are byte-identical at both heads (git diff eb144eaf6 b488ecc44 -- … empty). CI's TypeScript Type Check and Test Core jobs are the authority on the merged head.

Deviations and notes

  • packages/objectql/src/duplicate-record-error.ts: prose-only, no signature change — the module header and envelopeUniqueViolation's doc now name both doors, and two words in string literals: the object param doc ("refused insert" → "refused write") and developerMessage ("refused this insert" → "refused this write"), because the envelope is now raised by update too and a developerMessage calling an update an insert is a wrong attribution handed to the application author. No pin asserted the old word (git grep "refused this insert" → only the source line); the insert pin file's developerMessage assertions (DUPLICATE_RECORD, cause) are unchanged and green.
  • The message sentence (… No record was written.) is unchanged: it is pinned by the insert file and is true of a refused update as well (nothing was persisted); making it verb-aware would be a constructor signature change, which is out.
  • upsert: there is no engine-level upsert doorObjectQL has no upsert method (update.options.upsert is a retired-key tombstone, [finding] options.upsert is accepted by engine.update()'s option surface and never read — a declared-but-unenforced key (ADR-0049) #8057), update() never reaches a driver upsert, and the driver-level upsert (driver-sql, driver-memory, driver-mongodb) shares no code path with either update exit. No stop condition was met and nothing was widened.
  • content/docs/permissions/system-context.mdx re-anchored twice with check-system-context-census.mjs --fix (never hand-edited): once for the engine.ts line shifts, once per merge of origin/main (the os-regen merge driver deferred it; the discharge commit follows each merge).
  • Measurements were taken on the tree at 12a0c1dc8 (before merging origin/main); engine.ts and duplicate-record-error.ts are byte-identical in the final head to that commit for the changed hunks (the merges brought no change to either file).
  • Two git merge origin/main (28 + 6 commits); never rebased or force-pushed.

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68


Generated by Claude Code

…DUPLICATE_RECORD (#14390)
Both driver exits of engine.update — the by-id driver.update call and the
predicate driver.updateMany call — now answer a recognised unique violation
with the ADR-0112 DuplicateRecordError envelope the insert door has carried
since #14095. Everything that is not a unique violation passes through
untouched; the Update operation failed log line keeps the driver's own
diagnosis through the envelope's cause.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…shifts (#14390)
Regenerated with node scripts/check-system-context-census.mjs --fix; never hand-edited.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Discharges the os-regen deferral the merge of origin/main recorded for
content/docs/permissions/system-context.mdx; regenerated with
node scripts/check-system-context-census.mjs --fix, never hand-edited.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
)
Discharges the os-regen deferral recorded by the second merge of origin/main
for content/docs/permissions/system-context.mdx; regenerated with
node scripts/check-system-context-census.mjs --fix, never hand-edited.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

2 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 ObjectQL))
  • content/docs/protocol/kernel/error-handling.mdx(via DUPLICATE_RECORD (literal, a string literal in ObjectQL))
What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 65 pages)
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 16 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 2a265361968a435a9303ad08aacb16f497118556packageMentionDocs.

Which tree this was computed on

This run read content/docs from 86458eddd7e2ca21870730662a91548b608f7d32 — the merge of head b488ecc44b0ddb03896a0bcc66f1ef365ade81e2 into base 2a265361968a435a9303ad08aacb16f497118556, 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 86458eddd7e2ca21870730662a91548b608f7d32 && git checkout 86458eddd7e2ca21870730662a91548b608f7d32
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2a265361968a435a9303ad08aacb16f497118556 b488ecc44b0ddb03896a0bcc66f1ef365ade81e2 && git checkout -B drift-repro 2a265361968a435a9303ad08aacb16f497118556 && git merge --no-ff b488ecc44b0ddb03896a0bcc66f1ef365ade81e2
node scripts/docs-audit/affected-docs.mjs --json 2a265361968a435a9303ad08aacb16f497118556

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@os-muskos-musk added needs:contract-review and removed documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026 — with Claude
@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Validation addendum at b488ecc44 (supersedes one sentence of the body's "Declared narrowing" paragraph). After the body was written, packages/spec's DTS leg finally acquired the shared verification lock (ACQUIRED after 15s, held the lock 261s): DTS ⚡️ Build success in 224025ms, check-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present, and then, chained in the same hold, pnpm --filter @objectstack/objectql typecheck → exit 0, check:test-typecheck: OK — @objectstack/objectql's test layer compiles under packages/objectql/tsconfig.test.json; 44 file(s) / 242 error(s) / 69 pinned signature(s) held in test-typecheck-debt.json. So the objectql typecheck IS measured at b488ecc44 (green, debt ledger unchanged). What remains NOT MEASURED at this head is the full objectql vitest run — the rest of the closure (types/core/metadata*/formula, all moved by the merge) could not be rebuilt (queue-timeout (exit 99) · never acquired · never reached the head of the queue, holder pnpm check:type-check-debt of another seat, held 231s); the six adjacent suites at this head are 222/222 as the body says, and the last full run is 260 files / 4513 tests green at eb144eaf6. CI's Test Core job is the authority on the merged head.

Generated by Claude Code


Generated by Claude Code

@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Landing provenance (engine execution seat, session session_0112hMx9hjJ9BgB28X97DS68): flipped ready and armed auto-merge (squash) at 14:57Z on head b488ecc44.


Generated by Claude Code

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

Labels

Projects

None yet

2 participants

@os-musk@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(objectql): update answers a driver unique violation with the DUPLICATE_RECORD envelope, on every driver (#14390) - #14603

Merged
os-musk merged 6 commits into
mainfrom
claude/issue-14390-update-unique-violation-envelope
Sep 2, 2026
Merged

fix(objectql): update answers a driver unique violation with the DUPLICATE_RECORD envelope, on every driver (#14390)#14603
os-musk merged 6 commits into
mainfrom
claude/issue-14390-update-unique-violation-envelope

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

Fixes#14390

engine.update now answers a driver's unique-constraint refusal with the ADR-0112 envelope the insert door has carried since #14095DuplicateRecordError: code: 'DUPLICATE_RECORD', status: 409, the driver's own error whole on cause, object, developerMessage, and field only when uniqueViolationColumn determinably named the conflicting column. Both driver exits of the door are covered — the by-id driver.update call and the predicate (multi: true) driver.updateMany call — so the scoped-repository facade (ctx.api.object(name).update(...) / .updateById(...)) inherits it. Everything that is not a recognised unique violation passes through untouched, on identity. The Update operation failed log line keeps the driver's own diagnosis through the envelope's cause.

Triage's asymmetry sentence, which the changeset carries: the same user action now answers 409 DUPLICATE_RECORD on create and 500 INTERNAL_ERROR on edit.

Measured — before and after, real engine on real drivers

Rig: a real ObjectQL, engine.registerDriver(driver, true) + init(), an object with indexes: [{ fields: ['email'], unique: true }], driven through engine.update. Temporary vitest rig in packages/runtime (has both drivers as deps), run against the built @objectstack/objectql dist before and after the change; the rig was not committed. Raw JSON in the report comment's tests field.

driverdoorBEFORE (origin/main 9c7d9d4)AFTER
driver-sqlite-wasmby-idError, code/status/cause all absent, keys=[], message = the compiled UPDATE with bound values (update \duly_note` set `id` = '…', `email` = 'a@b.example', … where `id` = '…' - UNIQUE constraint failed: duly_note.email`)DuplicateRecordErrorDUPLICATE_RECORD / 409, field: 'email', object: 'duly_note', cause = that same raw error
driver-sqlite-wasmpredicate (where: {title}, multi: true, 2 matched rows onto one value)same raw shape, message = update … set \email` = 'z@z.example' … where `title` = 'grp' - UNIQUE constraint failed: duly_note.email`envelope, field: 'email', cause = raw; the matched rows are untouched (both still hold their old emails)
driver-memoryby-idits own envelope UNIQUE_VIOLATION / 409, no cause, declared-index sentence (… over (\email`) …`)DUPLICATE_RECORD / 409, cause = the UNIQUE_VIOLATION error (cause.code === 'UNIQUE_VIOLATION'), no fielduniqueViolationColumn answers undefined for that sentence, measured, so none is invented
driver-memorypredicatesame UNIQUE_VIOLATION / 409same envelope, no field
bothcontrol (update onto a free value)succeedssucceeds (non-vacuity)

REST boundary status resolution (resolveThrownHttpError(e, 500).status from @objectstack/types, the passthrough the REST layer uses): raw driver error → 500; DuplicateRecordError409. The wire code is not decided here (#14389's lane, untouched; packages/rest not edited).

Placement — Zone 2 assumption 1: measured, and the outer catch is NOT equivalent

update() has one try { (engine.ts, before the branch on isByIdWrite) whose catch (e) logs Update operation failed and rethrows. Measured contents of that try: the value-shape/validation passes, assertReferencesResolve, both driver exits, and the after phase — triggerHooks('afterUpdate', …) (per-row and record forms), recomputeSummaries (which re-enters this.update(desc.parentObject, …) on the master object), hydrateWriteFormulas, stripSearchCompanion. The before-phase hooks (beforeUpdate, per-row before hooks, unscoped-multi dispatch) run BEFORE that try. So an envelope at the outer catch would also run on errors thrown by afterUpdate hook code: a nested ctx.api write already envelopes on its own door with its own object name (idempotent at a second seam), but a raw driver error from a hook that reaches a store directly would be re-attributed to THIS object. The narrowest placement that wraps exactly the two driver exits was chosen (two four-line try { … } catch (driverError) { throw envelopeUniqueViolation(driverError, object); } blocks); the outer catch was changed only in what it logs. Pinned: engine-update-duplicate-record.test.ts › "a raw unique violation thrown INSIDE an afterUpdate hook is not attributed to this object" (the caller receives the hook's raw SQLITE_CONSTRAINT_UNIQUE error on identity, not a doc envelope).

Operator log — Zone 2 assumption 2: measured before and after

Before (real sqlite-wasm): Update operation failed carried err.message = "UNIQUE constraint failed: duly_note.email [statement and bound values redacted]", meta { object: 'duly_note' }. After: byte-identical on both doors — the catch logs e instanceof DuplicateRecordError ? e.cause : e through the same redactBoundStatement, exactly as the insert door does. The envelope's own message (Duplicate record refused on 'duly_note': a unique constraint on 'email' already holds this value. No record was written.) opens with no SQL verb and carries no statement or bound value; pinned (the raw message DOES open with update, which sanitizeRowError's backstop would discard).

Recognisability across drivers — Zone 2 assumption 3

Measured live on the two in-process stores above (sqlite-wasm: by-id and predicate; driver-memory: by-id and predicate). driver-sqlite-wasm overrides neither update nor updateMany (SqlDriver's builder.update(...) propagates the knex error raw, no wrapping — read at sql-driver.tsupdate/updateMany), so the SQL family's shapes are the dialect fixtures sql-driver-unique-violation-predicate.test.ts records: Postgres (23505, column in detail) and MySQL (ER_DUP_ENTRY/1062, index name only) are pinned through fixtures in the new pin file. NOT MEASURED on a live Postgres, MySQL, MongoDB or Turso store — no such store is reachable from this container.

Multi-row — Zone 2 assumption 4

Driven for real (two matched rows onto one value through updateMany, table above) and pinned: the predicate envelope carries exactly the own-keys of the by-id envelope (Object.keys set-equal), no rows/count/index property, no "one of N" wording, the same message; field is present only when the dialect named a column (sqlite fixture → email; mysql fixture → absent; driver-memory live → absent). No row attribution is invented, per triage.

Facades — Zone 2 assumption 5

ObjectRepository.update(data) and updateById(id, data) (what a hook reaches as ctx.api.object(name)) both delegate to engine.update — read at engine.ts class ObjectRepository; there is no second update door. Both are pinned reaching the envelope.

Changeset level — Zone 2 assumption 6

@objectstack/objectql: minor, the level #14095 shipped for the same envelope on insert (.changeset/insert-unique-violation-duplicate-record-envelope.md): callers observe a different error object on a public data-API door. node scripts/check-changeset-no-major.mjs✓ This diff introduces no major bump. (exit 0; the pnpm check:changeset-no-major spelling does not exist as a script — exit 254 "Command not found", i.e. that spelling is NOT MEASURED; the derived node scripts/… spelling is the one run). check:empty-changeset exit 0. The changeset carries triage's asymmetry sentence.

Clause-② self-reading

Yes, read from this diff. The accept/reject set of engine.update is unchanged: every input that was accepted is still accepted, every driver refusal is still a refusal, and the negative controls (NOT NULL, deadlock, missing table, unreachable store) are pinned on identity on both exits. What changes is the SHAPE of one existing refusal on a published verb: a recognised unique violation gains code (DUPLICATE_RECORD) and status (409) where it had neither, with the driver error moved one step down onto cause, so the REST answer for that refusal moves 500 → 409 (measured through resolveThrownHttpError). No new public export (DuplicateRecordError / DUPLICATE_RECORD_CODE were exported by #14095); no packages/spec change; no signature change on DuplicateRecordError. needs:contract-review is hung on this PR for the in-seat contract review; the PR stays draft.

The negative contract, verbatim, one pin per class per exit

engine-update-duplicate-record.test.ts (29 cases): NOT NULL, missing table, deadlock, unreachable store — each asserted toBe(raw) (identity), not a DuplicateRecordError, status undefined, on the by-id door AND on the predicate door (8 pins), plus the predicate-level control that SQLite's NOT NULL sentence is not a unique violation. Refusal pins assert code AND status, never a bare toThrow().

Ablation

Reverted ONLY the two envelope throws (throw envelopeUniqueViolation(driverError, object);throw driverError;, 2 occurrences) from the committed state, with a trap … EXIT INT TERM restore to HEAD. Predicted before the run: the update pin file goes red on every positive-contract case — 16 of 29 (the idempotency pin, the 9 negative controls, the placement pin, the two raw-reader pins and the non-enveloped log pin stay green) — and the insert pin file stays green (its door is untouched). Observed: 16 failed / 13 passed in engine-update-duplicate-record.test.ts, engine-insert-duplicate-record.test.ts 24/24 passed. Mutation proven on disk by grep counts (envelope throws 2 → 0, ABLATION-14390 marker 0 → 2) and blob hash (36bec3e6…85c62f46…, ≠ HEAD). Restore proven: git hash-object = HEAD:packages/objectql/src/engine.ts (36bec3e6…), git diff HEAD --stat empty, marker count 0, throws 2. No dist/ leg applies: both pin files import ./engine relatively from src, so no exports resolution to dist/ is on the path.

Validation

git rev-parse --short HEAD = b488ecc44 (the last commit; the union below was derived and run at this head — node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands with no path list, 63 commands).

Gate union at b488ecc44: 63 derived commands run, 58 exit 0, 5 NOT MEASURED in their own words (exit codes captured after a redirect, never through a pipe):

  • node scripts/check-test-completeness.mjs → exit 3: "Nothing was measured … pass a saved turbo run test log — or, running the family locally, record this gate as NOT MEASURED."
  • pnpm check:dual-build-cjs-loads → exit 3: "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/ … ⛔ This is NOT a pass: nothing was measured." (53 unbuilt packages; a whole-repo pnpm build is CI's run.)
  • node scripts/pm/check-half-states.mjs → exit 3: needs GitHub read access this container does not have ("Treat this exit as an unread instrument").
  • pnpm --filter @objectstack/spec run check:skill-examples → exit 1, prerequisite text: "Build first, then re-run: pnpm --filter @objectstack/client-react build" — the client-react closure build never acquired the shared verification lock (see below), so this is NOT MEASURED, not a red.
  • pnpm check:type-check-debt → exit 3: "Build the closure first … ⛔ This is NOT a pass and NOT a finding: nothing was measured."

Every other derived gate exit 0 at b488ecc44, including the families the seed named: check:engine-double-contract (the pin file's driver double is a DRIVER shape — primary key second — and sits out of scan scope; no engine-double-contract.pinned.json rows were asked for), check:objectql-double-limit ("305 double(s) graded, 106 apply the caller's bound or refuse it loudly"), check:system-context-census ("109 elevation read sites … all anchored; 145 anchors resolve"), check:changeset-no-major ("✓ This diff introduces no major bump."), check-empty-changeset, check:test-source-alias, check:cross-package-test-inputs, check:swallow-census-controls, check:durability-log-level, check:where-matcher, check:type-check-coverage, spec check:docs / check:liveness / check:empty-state.

Always-runs: pnpm lint (whole repo, eslint . --no-inline-config) exit 0 at b488ecc44; pnpm check:nul-bytes exit 0 ("scanned 7923 text file(s) … no raw ASCII control bytes"); pnpm check:error-status-conformance exit 0 ("51 code(s) reconciled … 140 producer site(s) derived").

Declared narrowing on the post-merge head. After the second git merge origin/main (28 commits, moving packages/spec, packages/types, packages/metadata, packages/metadata-protocol), the dependency-closure rebuild could not be completed on this shared box: two attempts acquired scripts/pm/os-verify-lock.sh and were killed by the container's 10-minute foreground cap during packages/spec's DTS leg (exit 143), and five attempts (spec build ×3, spec DTS-only leg, pin suites) ended queue-timeout (exit 99) · never acquired behind other seats' whole-repo turbo run build/typecheck and closure-build holds (holders named in the report's tests). The six adjacent suites WERE measured at b488ecc44 on a later acquisition: Test Files 6 passed (6) · Tests 222 passed (222) (held the lock 18s · waited 160s), against the merged tree's JS dist — spec's ESM/CJS legs had completed before the cap kill; only its .d.ts leg is missing, which vitest does not read. So at b488ecc44 the objectql typecheck and the full objectql vitest run are NOT MEASUREDpackages/spec/dist/*.d.ts is absent in this worktree. The last measured readings are at eb144eaf6 (one merge earlier, closure freshly built from that tree): pnpm --filter @objectstack/objectql typecheck exit 0 (check:test-typecheck: OK — … 44 file(s) / 242 error(s) / 69 pinned signature(s), i.e. the debt ledger unchanged and the new pin file compiling with zero errors), pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2Test Files 260 passed (260) · Tests 4513 passed (4513) (held the lock 200s), and the six adjacent suites (engine-update-duplicate-record, engine-insert-duplicate-record, engine-autonumber-resync, driver-fault-redaction, engine-update-dispatch, hook-input-shape-contract) → 222/222. Between eb144eaf6 and b488ecc44 the only commits are the merge and the census regeneration; the four hand-edited files are byte-identical at both heads (git diff eb144eaf6 b488ecc44 -- … empty). CI's TypeScript Type Check and Test Core jobs are the authority on the merged head.

Deviations and notes

  • packages/objectql/src/duplicate-record-error.ts: prose-only, no signature change — the module header and envelopeUniqueViolation's doc now name both doors, and two words in string literals: the object param doc ("refused insert" → "refused write") and developerMessage ("refused this insert" → "refused this write"), because the envelope is now raised by update too and a developerMessage calling an update an insert is a wrong attribution handed to the application author. No pin asserted the old word (git grep "refused this insert" → only the source line); the insert pin file's developerMessage assertions (DUPLICATE_RECORD, cause) are unchanged and green.
  • The message sentence (… No record was written.) is unchanged: it is pinned by the insert file and is true of a refused update as well (nothing was persisted); making it verb-aware would be a constructor signature change, which is out.
  • upsert: there is no engine-level upsert doorObjectQL has no upsert method (update.options.upsert is a retired-key tombstone, [finding] options.upsert is accepted by engine.update()'s option surface and never read — a declared-but-unenforced key (ADR-0049) #8057), update() never reaches a driver upsert, and the driver-level upsert (driver-sql, driver-memory, driver-mongodb) shares no code path with either update exit. No stop condition was met and nothing was widened.
  • content/docs/permissions/system-context.mdx re-anchored twice with check-system-context-census.mjs --fix (never hand-edited): once for the engine.ts line shifts, once per merge of origin/main (the os-regen merge driver deferred it; the discharge commit follows each merge).
  • Measurements were taken on the tree at 12a0c1dc8 (before merging origin/main); engine.ts and duplicate-record-error.ts are byte-identical in the final head to that commit for the changed hunks (the merges brought no change to either file).
  • Two git merge origin/main (28 + 6 commits); never rebased or force-pushed.

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68


Generated by Claude Code

…DUPLICATE_RECORD (#14390)
Both driver exits of engine.update — the by-id driver.update call and the
predicate driver.updateMany call — now answer a recognised unique violation
with the ADR-0112 DuplicateRecordError envelope the insert door has carried
since #14095. Everything that is not a unique violation passes through
untouched; the Update operation failed log line keeps the driver's own
diagnosis through the envelope's cause.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…shifts (#14390)
Regenerated with node scripts/check-system-context-census.mjs --fix; never hand-edited.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Discharges the os-regen deferral the merge of origin/main recorded for
content/docs/permissions/system-context.mdx; regenerated with
node scripts/check-system-context-census.mjs --fix, never hand-edited.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
)
Discharges the os-regen deferral recorded by the second merge of origin/main
for content/docs/permissions/system-context.mdx; regenerated with
node scripts/check-system-context-census.mjs --fix, never hand-edited.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

2 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 ObjectQL))
  • content/docs/protocol/kernel/error-handling.mdx(via DUPLICATE_RECORD (literal, a string literal in ObjectQL))
What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 65 pages)
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 16 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 2a265361968a435a9303ad08aacb16f497118556packageMentionDocs.

Which tree this was computed on

This run read content/docs from 86458eddd7e2ca21870730662a91548b608f7d32 — the merge of head b488ecc44b0ddb03896a0bcc66f1ef365ade81e2 into base 2a265361968a435a9303ad08aacb16f497118556, 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 86458eddd7e2ca21870730662a91548b608f7d32 && git checkout 86458eddd7e2ca21870730662a91548b608f7d32
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2a265361968a435a9303ad08aacb16f497118556 b488ecc44b0ddb03896a0bcc66f1ef365ade81e2 && git checkout -B drift-repro 2a265361968a435a9303ad08aacb16f497118556 && git merge --no-ff b488ecc44b0ddb03896a0bcc66f1ef365ade81e2
node scripts/docs-audit/affected-docs.mjs --json 2a265361968a435a9303ad08aacb16f497118556

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@os-muskos-musk added needs:contract-review and removed documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026 — with Claude
@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Validation addendum at b488ecc44 (supersedes one sentence of the body's "Declared narrowing" paragraph). After the body was written, packages/spec's DTS leg finally acquired the shared verification lock (ACQUIRED after 15s, held the lock 261s): DTS ⚡️ Build success in 224025ms, check-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present, and then, chained in the same hold, pnpm --filter @objectstack/objectql typecheck → exit 0, check:test-typecheck: OK — @objectstack/objectql's test layer compiles under packages/objectql/tsconfig.test.json; 44 file(s) / 242 error(s) / 69 pinned signature(s) held in test-typecheck-debt.json. So the objectql typecheck IS measured at b488ecc44 (green, debt ledger unchanged). What remains NOT MEASURED at this head is the full objectql vitest run — the rest of the closure (types/core/metadata*/formula, all moved by the merge) could not be rebuilt (queue-timeout (exit 99) · never acquired · never reached the head of the queue, holder pnpm check:type-check-debt of another seat, held 231s); the six adjacent suites at this head are 222/222 as the body says, and the last full run is 260 files / 4513 tests green at eb144eaf6. CI's Test Core job is the authority on the merged head.

Generated by Claude Code


Generated by Claude Code

@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Landing provenance (engine execution seat, session session_0112hMx9hjJ9BgB28X97DS68): flipped ready and armed auto-merge (squash) at 14:57Z on head b488ecc44.


Generated by Claude Code

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

Labels

Projects

None yet

2 participants

@os-musk@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(objectql): update answers a driver unique violation with the DUPLICATE_RECORD envelope, on every driver (#14390) - #14603

Merged
os-musk merged 6 commits into
mainfrom
claude/issue-14390-update-unique-violation-envelope
Sep 2, 2026
Merged

fix(objectql): update answers a driver unique violation with the DUPLICATE_RECORD envelope, on every driver (#14390)#14603
os-musk merged 6 commits into
mainfrom
claude/issue-14390-update-unique-violation-envelope

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

Fixes#14390

engine.update now answers a driver's unique-constraint refusal with the ADR-0112 envelope the insert door has carried since #14095DuplicateRecordError: code: 'DUPLICATE_RECORD', status: 409, the driver's own error whole on cause, object, developerMessage, and field only when uniqueViolationColumn determinably named the conflicting column. Both driver exits of the door are covered — the by-id driver.update call and the predicate (multi: true) driver.updateMany call — so the scoped-repository facade (ctx.api.object(name).update(...) / .updateById(...)) inherits it. Everything that is not a recognised unique violation passes through untouched, on identity. The Update operation failed log line keeps the driver's own diagnosis through the envelope's cause.

Triage's asymmetry sentence, which the changeset carries: the same user action now answers 409 DUPLICATE_RECORD on create and 500 INTERNAL_ERROR on edit.

Measured — before and after, real engine on real drivers

Rig: a real ObjectQL, engine.registerDriver(driver, true) + init(), an object with indexes: [{ fields: ['email'], unique: true }], driven through engine.update. Temporary vitest rig in packages/runtime (has both drivers as deps), run against the built @objectstack/objectql dist before and after the change; the rig was not committed. Raw JSON in the report comment's tests field.

driverdoorBEFORE (origin/main 9c7d9d4)AFTER
driver-sqlite-wasmby-idError, code/status/cause all absent, keys=[], message = the compiled UPDATE with bound values (update \duly_note` set `id` = '…', `email` = 'a@b.example', … where `id` = '…' - UNIQUE constraint failed: duly_note.email`)DuplicateRecordErrorDUPLICATE_RECORD / 409, field: 'email', object: 'duly_note', cause = that same raw error
driver-sqlite-wasmpredicate (where: {title}, multi: true, 2 matched rows onto one value)same raw shape, message = update … set \email` = 'z@z.example' … where `title` = 'grp' - UNIQUE constraint failed: duly_note.email`envelope, field: 'email', cause = raw; the matched rows are untouched (both still hold their old emails)
driver-memoryby-idits own envelope UNIQUE_VIOLATION / 409, no cause, declared-index sentence (… over (\email`) …`)DUPLICATE_RECORD / 409, cause = the UNIQUE_VIOLATION error (cause.code === 'UNIQUE_VIOLATION'), no fielduniqueViolationColumn answers undefined for that sentence, measured, so none is invented
driver-memorypredicatesame UNIQUE_VIOLATION / 409same envelope, no field
bothcontrol (update onto a free value)succeedssucceeds (non-vacuity)

REST boundary status resolution (resolveThrownHttpError(e, 500).status from @objectstack/types, the passthrough the REST layer uses): raw driver error → 500; DuplicateRecordError409. The wire code is not decided here (#14389's lane, untouched; packages/rest not edited).

Placement — Zone 2 assumption 1: measured, and the outer catch is NOT equivalent

update() has one try { (engine.ts, before the branch on isByIdWrite) whose catch (e) logs Update operation failed and rethrows. Measured contents of that try: the value-shape/validation passes, assertReferencesResolve, both driver exits, and the after phase — triggerHooks('afterUpdate', …) (per-row and record forms), recomputeSummaries (which re-enters this.update(desc.parentObject, …) on the master object), hydrateWriteFormulas, stripSearchCompanion. The before-phase hooks (beforeUpdate, per-row before hooks, unscoped-multi dispatch) run BEFORE that try. So an envelope at the outer catch would also run on errors thrown by afterUpdate hook code: a nested ctx.api write already envelopes on its own door with its own object name (idempotent at a second seam), but a raw driver error from a hook that reaches a store directly would be re-attributed to THIS object. The narrowest placement that wraps exactly the two driver exits was chosen (two four-line try { … } catch (driverError) { throw envelopeUniqueViolation(driverError, object); } blocks); the outer catch was changed only in what it logs. Pinned: engine-update-duplicate-record.test.ts › "a raw unique violation thrown INSIDE an afterUpdate hook is not attributed to this object" (the caller receives the hook's raw SQLITE_CONSTRAINT_UNIQUE error on identity, not a doc envelope).

Operator log — Zone 2 assumption 2: measured before and after

Before (real sqlite-wasm): Update operation failed carried err.message = "UNIQUE constraint failed: duly_note.email [statement and bound values redacted]", meta { object: 'duly_note' }. After: byte-identical on both doors — the catch logs e instanceof DuplicateRecordError ? e.cause : e through the same redactBoundStatement, exactly as the insert door does. The envelope's own message (Duplicate record refused on 'duly_note': a unique constraint on 'email' already holds this value. No record was written.) opens with no SQL verb and carries no statement or bound value; pinned (the raw message DOES open with update, which sanitizeRowError's backstop would discard).

Recognisability across drivers — Zone 2 assumption 3

Measured live on the two in-process stores above (sqlite-wasm: by-id and predicate; driver-memory: by-id and predicate). driver-sqlite-wasm overrides neither update nor updateMany (SqlDriver's builder.update(...) propagates the knex error raw, no wrapping — read at sql-driver.tsupdate/updateMany), so the SQL family's shapes are the dialect fixtures sql-driver-unique-violation-predicate.test.ts records: Postgres (23505, column in detail) and MySQL (ER_DUP_ENTRY/1062, index name only) are pinned through fixtures in the new pin file. NOT MEASURED on a live Postgres, MySQL, MongoDB or Turso store — no such store is reachable from this container.

Multi-row — Zone 2 assumption 4

Driven for real (two matched rows onto one value through updateMany, table above) and pinned: the predicate envelope carries exactly the own-keys of the by-id envelope (Object.keys set-equal), no rows/count/index property, no "one of N" wording, the same message; field is present only when the dialect named a column (sqlite fixture → email; mysql fixture → absent; driver-memory live → absent). No row attribution is invented, per triage.

Facades — Zone 2 assumption 5

ObjectRepository.update(data) and updateById(id, data) (what a hook reaches as ctx.api.object(name)) both delegate to engine.update — read at engine.ts class ObjectRepository; there is no second update door. Both are pinned reaching the envelope.

Changeset level — Zone 2 assumption 6

@objectstack/objectql: minor, the level #14095 shipped for the same envelope on insert (.changeset/insert-unique-violation-duplicate-record-envelope.md): callers observe a different error object on a public data-API door. node scripts/check-changeset-no-major.mjs✓ This diff introduces no major bump. (exit 0; the pnpm check:changeset-no-major spelling does not exist as a script — exit 254 "Command not found", i.e. that spelling is NOT MEASURED; the derived node scripts/… spelling is the one run). check:empty-changeset exit 0. The changeset carries triage's asymmetry sentence.

Clause-② self-reading

Yes, read from this diff. The accept/reject set of engine.update is unchanged: every input that was accepted is still accepted, every driver refusal is still a refusal, and the negative controls (NOT NULL, deadlock, missing table, unreachable store) are pinned on identity on both exits. What changes is the SHAPE of one existing refusal on a published verb: a recognised unique violation gains code (DUPLICATE_RECORD) and status (409) where it had neither, with the driver error moved one step down onto cause, so the REST answer for that refusal moves 500 → 409 (measured through resolveThrownHttpError). No new public export (DuplicateRecordError / DUPLICATE_RECORD_CODE were exported by #14095); no packages/spec change; no signature change on DuplicateRecordError. needs:contract-review is hung on this PR for the in-seat contract review; the PR stays draft.

The negative contract, verbatim, one pin per class per exit

engine-update-duplicate-record.test.ts (29 cases): NOT NULL, missing table, deadlock, unreachable store — each asserted toBe(raw) (identity), not a DuplicateRecordError, status undefined, on the by-id door AND on the predicate door (8 pins), plus the predicate-level control that SQLite's NOT NULL sentence is not a unique violation. Refusal pins assert code AND status, never a bare toThrow().

Ablation

Reverted ONLY the two envelope throws (throw envelopeUniqueViolation(driverError, object);throw driverError;, 2 occurrences) from the committed state, with a trap … EXIT INT TERM restore to HEAD. Predicted before the run: the update pin file goes red on every positive-contract case — 16 of 29 (the idempotency pin, the 9 negative controls, the placement pin, the two raw-reader pins and the non-enveloped log pin stay green) — and the insert pin file stays green (its door is untouched). Observed: 16 failed / 13 passed in engine-update-duplicate-record.test.ts, engine-insert-duplicate-record.test.ts 24/24 passed. Mutation proven on disk by grep counts (envelope throws 2 → 0, ABLATION-14390 marker 0 → 2) and blob hash (36bec3e6…85c62f46…, ≠ HEAD). Restore proven: git hash-object = HEAD:packages/objectql/src/engine.ts (36bec3e6…), git diff HEAD --stat empty, marker count 0, throws 2. No dist/ leg applies: both pin files import ./engine relatively from src, so no exports resolution to dist/ is on the path.

Validation

git rev-parse --short HEAD = b488ecc44 (the last commit; the union below was derived and run at this head — node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands with no path list, 63 commands).

Gate union at b488ecc44: 63 derived commands run, 58 exit 0, 5 NOT MEASURED in their own words (exit codes captured after a redirect, never through a pipe):

  • node scripts/check-test-completeness.mjs → exit 3: "Nothing was measured … pass a saved turbo run test log — or, running the family locally, record this gate as NOT MEASURED."
  • pnpm check:dual-build-cjs-loads → exit 3: "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/ … ⛔ This is NOT a pass: nothing was measured." (53 unbuilt packages; a whole-repo pnpm build is CI's run.)
  • node scripts/pm/check-half-states.mjs → exit 3: needs GitHub read access this container does not have ("Treat this exit as an unread instrument").
  • pnpm --filter @objectstack/spec run check:skill-examples → exit 1, prerequisite text: "Build first, then re-run: pnpm --filter @objectstack/client-react build" — the client-react closure build never acquired the shared verification lock (see below), so this is NOT MEASURED, not a red.
  • pnpm check:type-check-debt → exit 3: "Build the closure first … ⛔ This is NOT a pass and NOT a finding: nothing was measured."

Every other derived gate exit 0 at b488ecc44, including the families the seed named: check:engine-double-contract (the pin file's driver double is a DRIVER shape — primary key second — and sits out of scan scope; no engine-double-contract.pinned.json rows were asked for), check:objectql-double-limit ("305 double(s) graded, 106 apply the caller's bound or refuse it loudly"), check:system-context-census ("109 elevation read sites … all anchored; 145 anchors resolve"), check:changeset-no-major ("✓ This diff introduces no major bump."), check-empty-changeset, check:test-source-alias, check:cross-package-test-inputs, check:swallow-census-controls, check:durability-log-level, check:where-matcher, check:type-check-coverage, spec check:docs / check:liveness / check:empty-state.

Always-runs: pnpm lint (whole repo, eslint . --no-inline-config) exit 0 at b488ecc44; pnpm check:nul-bytes exit 0 ("scanned 7923 text file(s) … no raw ASCII control bytes"); pnpm check:error-status-conformance exit 0 ("51 code(s) reconciled … 140 producer site(s) derived").

Declared narrowing on the post-merge head. After the second git merge origin/main (28 commits, moving packages/spec, packages/types, packages/metadata, packages/metadata-protocol), the dependency-closure rebuild could not be completed on this shared box: two attempts acquired scripts/pm/os-verify-lock.sh and were killed by the container's 10-minute foreground cap during packages/spec's DTS leg (exit 143), and five attempts (spec build ×3, spec DTS-only leg, pin suites) ended queue-timeout (exit 99) · never acquired behind other seats' whole-repo turbo run build/typecheck and closure-build holds (holders named in the report's tests). The six adjacent suites WERE measured at b488ecc44 on a later acquisition: Test Files 6 passed (6) · Tests 222 passed (222) (held the lock 18s · waited 160s), against the merged tree's JS dist — spec's ESM/CJS legs had completed before the cap kill; only its .d.ts leg is missing, which vitest does not read. So at b488ecc44 the objectql typecheck and the full objectql vitest run are NOT MEASUREDpackages/spec/dist/*.d.ts is absent in this worktree. The last measured readings are at eb144eaf6 (one merge earlier, closure freshly built from that tree): pnpm --filter @objectstack/objectql typecheck exit 0 (check:test-typecheck: OK — … 44 file(s) / 242 error(s) / 69 pinned signature(s), i.e. the debt ledger unchanged and the new pin file compiling with zero errors), pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2Test Files 260 passed (260) · Tests 4513 passed (4513) (held the lock 200s), and the six adjacent suites (engine-update-duplicate-record, engine-insert-duplicate-record, engine-autonumber-resync, driver-fault-redaction, engine-update-dispatch, hook-input-shape-contract) → 222/222. Between eb144eaf6 and b488ecc44 the only commits are the merge and the census regeneration; the four hand-edited files are byte-identical at both heads (git diff eb144eaf6 b488ecc44 -- … empty). CI's TypeScript Type Check and Test Core jobs are the authority on the merged head.

Deviations and notes

  • packages/objectql/src/duplicate-record-error.ts: prose-only, no signature change — the module header and envelopeUniqueViolation's doc now name both doors, and two words in string literals: the object param doc ("refused insert" → "refused write") and developerMessage ("refused this insert" → "refused this write"), because the envelope is now raised by update too and a developerMessage calling an update an insert is a wrong attribution handed to the application author. No pin asserted the old word (git grep "refused this insert" → only the source line); the insert pin file's developerMessage assertions (DUPLICATE_RECORD, cause) are unchanged and green.
  • The message sentence (… No record was written.) is unchanged: it is pinned by the insert file and is true of a refused update as well (nothing was persisted); making it verb-aware would be a constructor signature change, which is out.
  • upsert: there is no engine-level upsert doorObjectQL has no upsert method (update.options.upsert is a retired-key tombstone, [finding] options.upsert is accepted by engine.update()'s option surface and never read — a declared-but-unenforced key (ADR-0049) #8057), update() never reaches a driver upsert, and the driver-level upsert (driver-sql, driver-memory, driver-mongodb) shares no code path with either update exit. No stop condition was met and nothing was widened.
  • content/docs/permissions/system-context.mdx re-anchored twice with check-system-context-census.mjs --fix (never hand-edited): once for the engine.ts line shifts, once per merge of origin/main (the os-regen merge driver deferred it; the discharge commit follows each merge).
  • Measurements were taken on the tree at 12a0c1dc8 (before merging origin/main); engine.ts and duplicate-record-error.ts are byte-identical in the final head to that commit for the changed hunks (the merges brought no change to either file).
  • Two git merge origin/main (28 + 6 commits); never rebased or force-pushed.

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68


Generated by Claude Code

…DUPLICATE_RECORD (#14390)
Both driver exits of engine.update — the by-id driver.update call and the
predicate driver.updateMany call — now answer a recognised unique violation
with the ADR-0112 DuplicateRecordError envelope the insert door has carried
since #14095. Everything that is not a unique violation passes through
untouched; the Update operation failed log line keeps the driver's own
diagnosis through the envelope's cause.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…shifts (#14390)
Regenerated with node scripts/check-system-context-census.mjs --fix; never hand-edited.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Discharges the os-regen deferral the merge of origin/main recorded for
content/docs/permissions/system-context.mdx; regenerated with
node scripts/check-system-context-census.mjs --fix, never hand-edited.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
)
Discharges the os-regen deferral recorded by the second merge of origin/main
for content/docs/permissions/system-context.mdx; regenerated with
node scripts/check-system-context-census.mjs --fix, never hand-edited.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

2 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 ObjectQL))
  • content/docs/protocol/kernel/error-handling.mdx(via DUPLICATE_RECORD (literal, a string literal in ObjectQL))
What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 65 pages)
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 16 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 2a265361968a435a9303ad08aacb16f497118556packageMentionDocs.

Which tree this was computed on

This run read content/docs from 86458eddd7e2ca21870730662a91548b608f7d32 — the merge of head b488ecc44b0ddb03896a0bcc66f1ef365ade81e2 into base 2a265361968a435a9303ad08aacb16f497118556, 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 86458eddd7e2ca21870730662a91548b608f7d32 && git checkout 86458eddd7e2ca21870730662a91548b608f7d32
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2a265361968a435a9303ad08aacb16f497118556 b488ecc44b0ddb03896a0bcc66f1ef365ade81e2 && git checkout -B drift-repro 2a265361968a435a9303ad08aacb16f497118556 && git merge --no-ff b488ecc44b0ddb03896a0bcc66f1ef365ade81e2
node scripts/docs-audit/affected-docs.mjs --json 2a265361968a435a9303ad08aacb16f497118556

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@os-muskos-musk added needs:contract-review and removed documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026 — with Claude
@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Validation addendum at b488ecc44 (supersedes one sentence of the body's "Declared narrowing" paragraph). After the body was written, packages/spec's DTS leg finally acquired the shared verification lock (ACQUIRED after 15s, held the lock 261s): DTS ⚡️ Build success in 224025ms, check-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present, and then, chained in the same hold, pnpm --filter @objectstack/objectql typecheck → exit 0, check:test-typecheck: OK — @objectstack/objectql's test layer compiles under packages/objectql/tsconfig.test.json; 44 file(s) / 242 error(s) / 69 pinned signature(s) held in test-typecheck-debt.json. So the objectql typecheck IS measured at b488ecc44 (green, debt ledger unchanged). What remains NOT MEASURED at this head is the full objectql vitest run — the rest of the closure (types/core/metadata*/formula, all moved by the merge) could not be rebuilt (queue-timeout (exit 99) · never acquired · never reached the head of the queue, holder pnpm check:type-check-debt of another seat, held 231s); the six adjacent suites at this head are 222/222 as the body says, and the last full run is 260 files / 4513 tests green at eb144eaf6. CI's Test Core job is the authority on the merged head.

Generated by Claude Code


Generated by Claude Code

@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Landing provenance (engine execution seat, session session_0112hMx9hjJ9BgB28X97DS68): flipped ready and armed auto-merge (squash) at 14:57Z on head b488ecc44.


Generated by Claude Code

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

Labels

Projects

None yet

2 participants

@os-musk@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(objectql): update answers a driver unique violation with the DUPLICATE_RECORD envelope, on every driver (#14390) - #14603

Merged
os-musk merged 6 commits into
mainfrom
claude/issue-14390-update-unique-violation-envelope
Sep 2, 2026
Merged

fix(objectql): update answers a driver unique violation with the DUPLICATE_RECORD envelope, on every driver (#14390)#14603
os-musk merged 6 commits into
mainfrom
claude/issue-14390-update-unique-violation-envelope

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

Fixes#14390

engine.update now answers a driver's unique-constraint refusal with the ADR-0112 envelope the insert door has carried since #14095DuplicateRecordError: code: 'DUPLICATE_RECORD', status: 409, the driver's own error whole on cause, object, developerMessage, and field only when uniqueViolationColumn determinably named the conflicting column. Both driver exits of the door are covered — the by-id driver.update call and the predicate (multi: true) driver.updateMany call — so the scoped-repository facade (ctx.api.object(name).update(...) / .updateById(...)) inherits it. Everything that is not a recognised unique violation passes through untouched, on identity. The Update operation failed log line keeps the driver's own diagnosis through the envelope's cause.

Triage's asymmetry sentence, which the changeset carries: the same user action now answers 409 DUPLICATE_RECORD on create and 500 INTERNAL_ERROR on edit.

Measured — before and after, real engine on real drivers

Rig: a real ObjectQL, engine.registerDriver(driver, true) + init(), an object with indexes: [{ fields: ['email'], unique: true }], driven through engine.update. Temporary vitest rig in packages/runtime (has both drivers as deps), run against the built @objectstack/objectql dist before and after the change; the rig was not committed. Raw JSON in the report comment's tests field.

driverdoorBEFORE (origin/main 9c7d9d4)AFTER
driver-sqlite-wasmby-idError, code/status/cause all absent, keys=[], message = the compiled UPDATE with bound values (update \duly_note` set `id` = '…', `email` = 'a@b.example', … where `id` = '…' - UNIQUE constraint failed: duly_note.email`)DuplicateRecordErrorDUPLICATE_RECORD / 409, field: 'email', object: 'duly_note', cause = that same raw error
driver-sqlite-wasmpredicate (where: {title}, multi: true, 2 matched rows onto one value)same raw shape, message = update … set \email` = 'z@z.example' … where `title` = 'grp' - UNIQUE constraint failed: duly_note.email`envelope, field: 'email', cause = raw; the matched rows are untouched (both still hold their old emails)
driver-memoryby-idits own envelope UNIQUE_VIOLATION / 409, no cause, declared-index sentence (… over (\email`) …`)DUPLICATE_RECORD / 409, cause = the UNIQUE_VIOLATION error (cause.code === 'UNIQUE_VIOLATION'), no fielduniqueViolationColumn answers undefined for that sentence, measured, so none is invented
driver-memorypredicatesame UNIQUE_VIOLATION / 409same envelope, no field
bothcontrol (update onto a free value)succeedssucceeds (non-vacuity)

REST boundary status resolution (resolveThrownHttpError(e, 500).status from @objectstack/types, the passthrough the REST layer uses): raw driver error → 500; DuplicateRecordError409. The wire code is not decided here (#14389's lane, untouched; packages/rest not edited).

Placement — Zone 2 assumption 1: measured, and the outer catch is NOT equivalent

update() has one try { (engine.ts, before the branch on isByIdWrite) whose catch (e) logs Update operation failed and rethrows. Measured contents of that try: the value-shape/validation passes, assertReferencesResolve, both driver exits, and the after phase — triggerHooks('afterUpdate', …) (per-row and record forms), recomputeSummaries (which re-enters this.update(desc.parentObject, …) on the master object), hydrateWriteFormulas, stripSearchCompanion. The before-phase hooks (beforeUpdate, per-row before hooks, unscoped-multi dispatch) run BEFORE that try. So an envelope at the outer catch would also run on errors thrown by afterUpdate hook code: a nested ctx.api write already envelopes on its own door with its own object name (idempotent at a second seam), but a raw driver error from a hook that reaches a store directly would be re-attributed to THIS object. The narrowest placement that wraps exactly the two driver exits was chosen (two four-line try { … } catch (driverError) { throw envelopeUniqueViolation(driverError, object); } blocks); the outer catch was changed only in what it logs. Pinned: engine-update-duplicate-record.test.ts › "a raw unique violation thrown INSIDE an afterUpdate hook is not attributed to this object" (the caller receives the hook's raw SQLITE_CONSTRAINT_UNIQUE error on identity, not a doc envelope).

Operator log — Zone 2 assumption 2: measured before and after

Before (real sqlite-wasm): Update operation failed carried err.message = "UNIQUE constraint failed: duly_note.email [statement and bound values redacted]", meta { object: 'duly_note' }. After: byte-identical on both doors — the catch logs e instanceof DuplicateRecordError ? e.cause : e through the same redactBoundStatement, exactly as the insert door does. The envelope's own message (Duplicate record refused on 'duly_note': a unique constraint on 'email' already holds this value. No record was written.) opens with no SQL verb and carries no statement or bound value; pinned (the raw message DOES open with update, which sanitizeRowError's backstop would discard).

Recognisability across drivers — Zone 2 assumption 3

Measured live on the two in-process stores above (sqlite-wasm: by-id and predicate; driver-memory: by-id and predicate). driver-sqlite-wasm overrides neither update nor updateMany (SqlDriver's builder.update(...) propagates the knex error raw, no wrapping — read at sql-driver.tsupdate/updateMany), so the SQL family's shapes are the dialect fixtures sql-driver-unique-violation-predicate.test.ts records: Postgres (23505, column in detail) and MySQL (ER_DUP_ENTRY/1062, index name only) are pinned through fixtures in the new pin file. NOT MEASURED on a live Postgres, MySQL, MongoDB or Turso store — no such store is reachable from this container.

Multi-row — Zone 2 assumption 4

Driven for real (two matched rows onto one value through updateMany, table above) and pinned: the predicate envelope carries exactly the own-keys of the by-id envelope (Object.keys set-equal), no rows/count/index property, no "one of N" wording, the same message; field is present only when the dialect named a column (sqlite fixture → email; mysql fixture → absent; driver-memory live → absent). No row attribution is invented, per triage.

Facades — Zone 2 assumption 5

ObjectRepository.update(data) and updateById(id, data) (what a hook reaches as ctx.api.object(name)) both delegate to engine.update — read at engine.ts class ObjectRepository; there is no second update door. Both are pinned reaching the envelope.

Changeset level — Zone 2 assumption 6

@objectstack/objectql: minor, the level #14095 shipped for the same envelope on insert (.changeset/insert-unique-violation-duplicate-record-envelope.md): callers observe a different error object on a public data-API door. node scripts/check-changeset-no-major.mjs✓ This diff introduces no major bump. (exit 0; the pnpm check:changeset-no-major spelling does not exist as a script — exit 254 "Command not found", i.e. that spelling is NOT MEASURED; the derived node scripts/… spelling is the one run). check:empty-changeset exit 0. The changeset carries triage's asymmetry sentence.

Clause-② self-reading

Yes, read from this diff. The accept/reject set of engine.update is unchanged: every input that was accepted is still accepted, every driver refusal is still a refusal, and the negative controls (NOT NULL, deadlock, missing table, unreachable store) are pinned on identity on both exits. What changes is the SHAPE of one existing refusal on a published verb: a recognised unique violation gains code (DUPLICATE_RECORD) and status (409) where it had neither, with the driver error moved one step down onto cause, so the REST answer for that refusal moves 500 → 409 (measured through resolveThrownHttpError). No new public export (DuplicateRecordError / DUPLICATE_RECORD_CODE were exported by #14095); no packages/spec change; no signature change on DuplicateRecordError. needs:contract-review is hung on this PR for the in-seat contract review; the PR stays draft.

The negative contract, verbatim, one pin per class per exit

engine-update-duplicate-record.test.ts (29 cases): NOT NULL, missing table, deadlock, unreachable store — each asserted toBe(raw) (identity), not a DuplicateRecordError, status undefined, on the by-id door AND on the predicate door (8 pins), plus the predicate-level control that SQLite's NOT NULL sentence is not a unique violation. Refusal pins assert code AND status, never a bare toThrow().

Ablation

Reverted ONLY the two envelope throws (throw envelopeUniqueViolation(driverError, object);throw driverError;, 2 occurrences) from the committed state, with a trap … EXIT INT TERM restore to HEAD. Predicted before the run: the update pin file goes red on every positive-contract case — 16 of 29 (the idempotency pin, the 9 negative controls, the placement pin, the two raw-reader pins and the non-enveloped log pin stay green) — and the insert pin file stays green (its door is untouched). Observed: 16 failed / 13 passed in engine-update-duplicate-record.test.ts, engine-insert-duplicate-record.test.ts 24/24 passed. Mutation proven on disk by grep counts (envelope throws 2 → 0, ABLATION-14390 marker 0 → 2) and blob hash (36bec3e6…85c62f46…, ≠ HEAD). Restore proven: git hash-object = HEAD:packages/objectql/src/engine.ts (36bec3e6…), git diff HEAD --stat empty, marker count 0, throws 2. No dist/ leg applies: both pin files import ./engine relatively from src, so no exports resolution to dist/ is on the path.

Validation

git rev-parse --short HEAD = b488ecc44 (the last commit; the union below was derived and run at this head — node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands with no path list, 63 commands).

Gate union at b488ecc44: 63 derived commands run, 58 exit 0, 5 NOT MEASURED in their own words (exit codes captured after a redirect, never through a pipe):

  • node scripts/check-test-completeness.mjs → exit 3: "Nothing was measured … pass a saved turbo run test log — or, running the family locally, record this gate as NOT MEASURED."
  • pnpm check:dual-build-cjs-loads → exit 3: "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/ … ⛔ This is NOT a pass: nothing was measured." (53 unbuilt packages; a whole-repo pnpm build is CI's run.)
  • node scripts/pm/check-half-states.mjs → exit 3: needs GitHub read access this container does not have ("Treat this exit as an unread instrument").
  • pnpm --filter @objectstack/spec run check:skill-examples → exit 1, prerequisite text: "Build first, then re-run: pnpm --filter @objectstack/client-react build" — the client-react closure build never acquired the shared verification lock (see below), so this is NOT MEASURED, not a red.
  • pnpm check:type-check-debt → exit 3: "Build the closure first … ⛔ This is NOT a pass and NOT a finding: nothing was measured."

Every other derived gate exit 0 at b488ecc44, including the families the seed named: check:engine-double-contract (the pin file's driver double is a DRIVER shape — primary key second — and sits out of scan scope; no engine-double-contract.pinned.json rows were asked for), check:objectql-double-limit ("305 double(s) graded, 106 apply the caller's bound or refuse it loudly"), check:system-context-census ("109 elevation read sites … all anchored; 145 anchors resolve"), check:changeset-no-major ("✓ This diff introduces no major bump."), check-empty-changeset, check:test-source-alias, check:cross-package-test-inputs, check:swallow-census-controls, check:durability-log-level, check:where-matcher, check:type-check-coverage, spec check:docs / check:liveness / check:empty-state.

Always-runs: pnpm lint (whole repo, eslint . --no-inline-config) exit 0 at b488ecc44; pnpm check:nul-bytes exit 0 ("scanned 7923 text file(s) … no raw ASCII control bytes"); pnpm check:error-status-conformance exit 0 ("51 code(s) reconciled … 140 producer site(s) derived").

Declared narrowing on the post-merge head. After the second git merge origin/main (28 commits, moving packages/spec, packages/types, packages/metadata, packages/metadata-protocol), the dependency-closure rebuild could not be completed on this shared box: two attempts acquired scripts/pm/os-verify-lock.sh and were killed by the container's 10-minute foreground cap during packages/spec's DTS leg (exit 143), and five attempts (spec build ×3, spec DTS-only leg, pin suites) ended queue-timeout (exit 99) · never acquired behind other seats' whole-repo turbo run build/typecheck and closure-build holds (holders named in the report's tests). The six adjacent suites WERE measured at b488ecc44 on a later acquisition: Test Files 6 passed (6) · Tests 222 passed (222) (held the lock 18s · waited 160s), against the merged tree's JS dist — spec's ESM/CJS legs had completed before the cap kill; only its .d.ts leg is missing, which vitest does not read. So at b488ecc44 the objectql typecheck and the full objectql vitest run are NOT MEASUREDpackages/spec/dist/*.d.ts is absent in this worktree. The last measured readings are at eb144eaf6 (one merge earlier, closure freshly built from that tree): pnpm --filter @objectstack/objectql typecheck exit 0 (check:test-typecheck: OK — … 44 file(s) / 242 error(s) / 69 pinned signature(s), i.e. the debt ledger unchanged and the new pin file compiling with zero errors), pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2Test Files 260 passed (260) · Tests 4513 passed (4513) (held the lock 200s), and the six adjacent suites (engine-update-duplicate-record, engine-insert-duplicate-record, engine-autonumber-resync, driver-fault-redaction, engine-update-dispatch, hook-input-shape-contract) → 222/222. Between eb144eaf6 and b488ecc44 the only commits are the merge and the census regeneration; the four hand-edited files are byte-identical at both heads (git diff eb144eaf6 b488ecc44 -- … empty). CI's TypeScript Type Check and Test Core jobs are the authority on the merged head.

Deviations and notes

  • packages/objectql/src/duplicate-record-error.ts: prose-only, no signature change — the module header and envelopeUniqueViolation's doc now name both doors, and two words in string literals: the object param doc ("refused insert" → "refused write") and developerMessage ("refused this insert" → "refused this write"), because the envelope is now raised by update too and a developerMessage calling an update an insert is a wrong attribution handed to the application author. No pin asserted the old word (git grep "refused this insert" → only the source line); the insert pin file's developerMessage assertions (DUPLICATE_RECORD, cause) are unchanged and green.
  • The message sentence (… No record was written.) is unchanged: it is pinned by the insert file and is true of a refused update as well (nothing was persisted); making it verb-aware would be a constructor signature change, which is out.
  • upsert: there is no engine-level upsert doorObjectQL has no upsert method (update.options.upsert is a retired-key tombstone, [finding] options.upsert is accepted by engine.update()'s option surface and never read — a declared-but-unenforced key (ADR-0049) #8057), update() never reaches a driver upsert, and the driver-level upsert (driver-sql, driver-memory, driver-mongodb) shares no code path with either update exit. No stop condition was met and nothing was widened.
  • content/docs/permissions/system-context.mdx re-anchored twice with check-system-context-census.mjs --fix (never hand-edited): once for the engine.ts line shifts, once per merge of origin/main (the os-regen merge driver deferred it; the discharge commit follows each merge).
  • Measurements were taken on the tree at 12a0c1dc8 (before merging origin/main); engine.ts and duplicate-record-error.ts are byte-identical in the final head to that commit for the changed hunks (the merges brought no change to either file).
  • Two git merge origin/main (28 + 6 commits); never rebased or force-pushed.

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68


Generated by Claude Code

…DUPLICATE_RECORD (#14390)
Both driver exits of engine.update — the by-id driver.update call and the
predicate driver.updateMany call — now answer a recognised unique violation
with the ADR-0112 DuplicateRecordError envelope the insert door has carried
since #14095. Everything that is not a unique violation passes through
untouched; the Update operation failed log line keeps the driver's own
diagnosis through the envelope's cause.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…shifts (#14390)
Regenerated with node scripts/check-system-context-census.mjs --fix; never hand-edited.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Discharges the os-regen deferral the merge of origin/main recorded for
content/docs/permissions/system-context.mdx; regenerated with
node scripts/check-system-context-census.mjs --fix, never hand-edited.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
)
Discharges the os-regen deferral recorded by the second merge of origin/main
for content/docs/permissions/system-context.mdx; regenerated with
node scripts/check-system-context-census.mjs --fix, never hand-edited.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

2 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 ObjectQL))
  • content/docs/protocol/kernel/error-handling.mdx(via DUPLICATE_RECORD (literal, a string literal in ObjectQL))
What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 65 pages)
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 16 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 2a265361968a435a9303ad08aacb16f497118556packageMentionDocs.

Which tree this was computed on

This run read content/docs from 86458eddd7e2ca21870730662a91548b608f7d32 — the merge of head b488ecc44b0ddb03896a0bcc66f1ef365ade81e2 into base 2a265361968a435a9303ad08aacb16f497118556, 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 86458eddd7e2ca21870730662a91548b608f7d32 && git checkout 86458eddd7e2ca21870730662a91548b608f7d32
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2a265361968a435a9303ad08aacb16f497118556 b488ecc44b0ddb03896a0bcc66f1ef365ade81e2 && git checkout -B drift-repro 2a265361968a435a9303ad08aacb16f497118556 && git merge --no-ff b488ecc44b0ddb03896a0bcc66f1ef365ade81e2
node scripts/docs-audit/affected-docs.mjs --json 2a265361968a435a9303ad08aacb16f497118556

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@os-muskos-musk added needs:contract-review and removed documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026 — with Claude
@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Validation addendum at b488ecc44 (supersedes one sentence of the body's "Declared narrowing" paragraph). After the body was written, packages/spec's DTS leg finally acquired the shared verification lock (ACQUIRED after 15s, held the lock 261s): DTS ⚡️ Build success in 224025ms, check-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present, and then, chained in the same hold, pnpm --filter @objectstack/objectql typecheck → exit 0, check:test-typecheck: OK — @objectstack/objectql's test layer compiles under packages/objectql/tsconfig.test.json; 44 file(s) / 242 error(s) / 69 pinned signature(s) held in test-typecheck-debt.json. So the objectql typecheck IS measured at b488ecc44 (green, debt ledger unchanged). What remains NOT MEASURED at this head is the full objectql vitest run — the rest of the closure (types/core/metadata*/formula, all moved by the merge) could not be rebuilt (queue-timeout (exit 99) · never acquired · never reached the head of the queue, holder pnpm check:type-check-debt of another seat, held 231s); the six adjacent suites at this head are 222/222 as the body says, and the last full run is 260 files / 4513 tests green at eb144eaf6. CI's Test Core job is the authority on the merged head.

Generated by Claude Code


Generated by Claude Code

@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Landing provenance (engine execution seat, session session_0112hMx9hjJ9BgB28X97DS68): flipped ready and armed auto-merge (squash) at 14:57Z on head b488ecc44.


Generated by Claude Code

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

Labels

Projects

None yet

2 participants

@os-musk@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(objectql): update answers a driver unique violation with the DUPLICATE_RECORD envelope, on every driver (#14390) - #14603

Merged
os-musk merged 6 commits into
mainfrom
claude/issue-14390-update-unique-violation-envelope
Sep 2, 2026
Merged

fix(objectql): update answers a driver unique violation with the DUPLICATE_RECORD envelope, on every driver (#14390)#14603
os-musk merged 6 commits into
mainfrom
claude/issue-14390-update-unique-violation-envelope

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

Fixes#14390

engine.update now answers a driver's unique-constraint refusal with the ADR-0112 envelope the insert door has carried since #14095DuplicateRecordError: code: 'DUPLICATE_RECORD', status: 409, the driver's own error whole on cause, object, developerMessage, and field only when uniqueViolationColumn determinably named the conflicting column. Both driver exits of the door are covered — the by-id driver.update call and the predicate (multi: true) driver.updateMany call — so the scoped-repository facade (ctx.api.object(name).update(...) / .updateById(...)) inherits it. Everything that is not a recognised unique violation passes through untouched, on identity. The Update operation failed log line keeps the driver's own diagnosis through the envelope's cause.

Triage's asymmetry sentence, which the changeset carries: the same user action now answers 409 DUPLICATE_RECORD on create and 500 INTERNAL_ERROR on edit.

Measured — before and after, real engine on real drivers

Rig: a real ObjectQL, engine.registerDriver(driver, true) + init(), an object with indexes: [{ fields: ['email'], unique: true }], driven through engine.update. Temporary vitest rig in packages/runtime (has both drivers as deps), run against the built @objectstack/objectql dist before and after the change; the rig was not committed. Raw JSON in the report comment's tests field.

driverdoorBEFORE (origin/main 9c7d9d4)AFTER
driver-sqlite-wasmby-idError, code/status/cause all absent, keys=[], message = the compiled UPDATE with bound values (update \duly_note` set `id` = '…', `email` = 'a@b.example', … where `id` = '…' - UNIQUE constraint failed: duly_note.email`)DuplicateRecordErrorDUPLICATE_RECORD / 409, field: 'email', object: 'duly_note', cause = that same raw error
driver-sqlite-wasmpredicate (where: {title}, multi: true, 2 matched rows onto one value)same raw shape, message = update … set \email` = 'z@z.example' … where `title` = 'grp' - UNIQUE constraint failed: duly_note.email`envelope, field: 'email', cause = raw; the matched rows are untouched (both still hold their old emails)
driver-memoryby-idits own envelope UNIQUE_VIOLATION / 409, no cause, declared-index sentence (… over (\email`) …`)DUPLICATE_RECORD / 409, cause = the UNIQUE_VIOLATION error (cause.code === 'UNIQUE_VIOLATION'), no fielduniqueViolationColumn answers undefined for that sentence, measured, so none is invented
driver-memorypredicatesame UNIQUE_VIOLATION / 409same envelope, no field
bothcontrol (update onto a free value)succeedssucceeds (non-vacuity)

REST boundary status resolution (resolveThrownHttpError(e, 500).status from @objectstack/types, the passthrough the REST layer uses): raw driver error → 500; DuplicateRecordError409. The wire code is not decided here (#14389's lane, untouched; packages/rest not edited).

Placement — Zone 2 assumption 1: measured, and the outer catch is NOT equivalent

update() has one try { (engine.ts, before the branch on isByIdWrite) whose catch (e) logs Update operation failed and rethrows. Measured contents of that try: the value-shape/validation passes, assertReferencesResolve, both driver exits, and the after phase — triggerHooks('afterUpdate', …) (per-row and record forms), recomputeSummaries (which re-enters this.update(desc.parentObject, …) on the master object), hydrateWriteFormulas, stripSearchCompanion. The before-phase hooks (beforeUpdate, per-row before hooks, unscoped-multi dispatch) run BEFORE that try. So an envelope at the outer catch would also run on errors thrown by afterUpdate hook code: a nested ctx.api write already envelopes on its own door with its own object name (idempotent at a second seam), but a raw driver error from a hook that reaches a store directly would be re-attributed to THIS object. The narrowest placement that wraps exactly the two driver exits was chosen (two four-line try { … } catch (driverError) { throw envelopeUniqueViolation(driverError, object); } blocks); the outer catch was changed only in what it logs. Pinned: engine-update-duplicate-record.test.ts › "a raw unique violation thrown INSIDE an afterUpdate hook is not attributed to this object" (the caller receives the hook's raw SQLITE_CONSTRAINT_UNIQUE error on identity, not a doc envelope).

Operator log — Zone 2 assumption 2: measured before and after

Before (real sqlite-wasm): Update operation failed carried err.message = "UNIQUE constraint failed: duly_note.email [statement and bound values redacted]", meta { object: 'duly_note' }. After: byte-identical on both doors — the catch logs e instanceof DuplicateRecordError ? e.cause : e through the same redactBoundStatement, exactly as the insert door does. The envelope's own message (Duplicate record refused on 'duly_note': a unique constraint on 'email' already holds this value. No record was written.) opens with no SQL verb and carries no statement or bound value; pinned (the raw message DOES open with update, which sanitizeRowError's backstop would discard).

Recognisability across drivers — Zone 2 assumption 3

Measured live on the two in-process stores above (sqlite-wasm: by-id and predicate; driver-memory: by-id and predicate). driver-sqlite-wasm overrides neither update nor updateMany (SqlDriver's builder.update(...) propagates the knex error raw, no wrapping — read at sql-driver.tsupdate/updateMany), so the SQL family's shapes are the dialect fixtures sql-driver-unique-violation-predicate.test.ts records: Postgres (23505, column in detail) and MySQL (ER_DUP_ENTRY/1062, index name only) are pinned through fixtures in the new pin file. NOT MEASURED on a live Postgres, MySQL, MongoDB or Turso store — no such store is reachable from this container.

Multi-row — Zone 2 assumption 4

Driven for real (two matched rows onto one value through updateMany, table above) and pinned: the predicate envelope carries exactly the own-keys of the by-id envelope (Object.keys set-equal), no rows/count/index property, no "one of N" wording, the same message; field is present only when the dialect named a column (sqlite fixture → email; mysql fixture → absent; driver-memory live → absent). No row attribution is invented, per triage.

Facades — Zone 2 assumption 5

ObjectRepository.update(data) and updateById(id, data) (what a hook reaches as ctx.api.object(name)) both delegate to engine.update — read at engine.ts class ObjectRepository; there is no second update door. Both are pinned reaching the envelope.

Changeset level — Zone 2 assumption 6

@objectstack/objectql: minor, the level #14095 shipped for the same envelope on insert (.changeset/insert-unique-violation-duplicate-record-envelope.md): callers observe a different error object on a public data-API door. node scripts/check-changeset-no-major.mjs✓ This diff introduces no major bump. (exit 0; the pnpm check:changeset-no-major spelling does not exist as a script — exit 254 "Command not found", i.e. that spelling is NOT MEASURED; the derived node scripts/… spelling is the one run). check:empty-changeset exit 0. The changeset carries triage's asymmetry sentence.

Clause-② self-reading

Yes, read from this diff. The accept/reject set of engine.update is unchanged: every input that was accepted is still accepted, every driver refusal is still a refusal, and the negative controls (NOT NULL, deadlock, missing table, unreachable store) are pinned on identity on both exits. What changes is the SHAPE of one existing refusal on a published verb: a recognised unique violation gains code (DUPLICATE_RECORD) and status (409) where it had neither, with the driver error moved one step down onto cause, so the REST answer for that refusal moves 500 → 409 (measured through resolveThrownHttpError). No new public export (DuplicateRecordError / DUPLICATE_RECORD_CODE were exported by #14095); no packages/spec change; no signature change on DuplicateRecordError. needs:contract-review is hung on this PR for the in-seat contract review; the PR stays draft.

The negative contract, verbatim, one pin per class per exit

engine-update-duplicate-record.test.ts (29 cases): NOT NULL, missing table, deadlock, unreachable store — each asserted toBe(raw) (identity), not a DuplicateRecordError, status undefined, on the by-id door AND on the predicate door (8 pins), plus the predicate-level control that SQLite's NOT NULL sentence is not a unique violation. Refusal pins assert code AND status, never a bare toThrow().

Ablation

Reverted ONLY the two envelope throws (throw envelopeUniqueViolation(driverError, object);throw driverError;, 2 occurrences) from the committed state, with a trap … EXIT INT TERM restore to HEAD. Predicted before the run: the update pin file goes red on every positive-contract case — 16 of 29 (the idempotency pin, the 9 negative controls, the placement pin, the two raw-reader pins and the non-enveloped log pin stay green) — and the insert pin file stays green (its door is untouched). Observed: 16 failed / 13 passed in engine-update-duplicate-record.test.ts, engine-insert-duplicate-record.test.ts 24/24 passed. Mutation proven on disk by grep counts (envelope throws 2 → 0, ABLATION-14390 marker 0 → 2) and blob hash (36bec3e6…85c62f46…, ≠ HEAD). Restore proven: git hash-object = HEAD:packages/objectql/src/engine.ts (36bec3e6…), git diff HEAD --stat empty, marker count 0, throws 2. No dist/ leg applies: both pin files import ./engine relatively from src, so no exports resolution to dist/ is on the path.

Validation

git rev-parse --short HEAD = b488ecc44 (the last commit; the union below was derived and run at this head — node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands with no path list, 63 commands).

Gate union at b488ecc44: 63 derived commands run, 58 exit 0, 5 NOT MEASURED in their own words (exit codes captured after a redirect, never through a pipe):

  • node scripts/check-test-completeness.mjs → exit 3: "Nothing was measured … pass a saved turbo run test log — or, running the family locally, record this gate as NOT MEASURED."
  • pnpm check:dual-build-cjs-loads → exit 3: "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/ … ⛔ This is NOT a pass: nothing was measured." (53 unbuilt packages; a whole-repo pnpm build is CI's run.)
  • node scripts/pm/check-half-states.mjs → exit 3: needs GitHub read access this container does not have ("Treat this exit as an unread instrument").
  • pnpm --filter @objectstack/spec run check:skill-examples → exit 1, prerequisite text: "Build first, then re-run: pnpm --filter @objectstack/client-react build" — the client-react closure build never acquired the shared verification lock (see below), so this is NOT MEASURED, not a red.
  • pnpm check:type-check-debt → exit 3: "Build the closure first … ⛔ This is NOT a pass and NOT a finding: nothing was measured."

Every other derived gate exit 0 at b488ecc44, including the families the seed named: check:engine-double-contract (the pin file's driver double is a DRIVER shape — primary key second — and sits out of scan scope; no engine-double-contract.pinned.json rows were asked for), check:objectql-double-limit ("305 double(s) graded, 106 apply the caller's bound or refuse it loudly"), check:system-context-census ("109 elevation read sites … all anchored; 145 anchors resolve"), check:changeset-no-major ("✓ This diff introduces no major bump."), check-empty-changeset, check:test-source-alias, check:cross-package-test-inputs, check:swallow-census-controls, check:durability-log-level, check:where-matcher, check:type-check-coverage, spec check:docs / check:liveness / check:empty-state.

Always-runs: pnpm lint (whole repo, eslint . --no-inline-config) exit 0 at b488ecc44; pnpm check:nul-bytes exit 0 ("scanned 7923 text file(s) … no raw ASCII control bytes"); pnpm check:error-status-conformance exit 0 ("51 code(s) reconciled … 140 producer site(s) derived").

Declared narrowing on the post-merge head. After the second git merge origin/main (28 commits, moving packages/spec, packages/types, packages/metadata, packages/metadata-protocol), the dependency-closure rebuild could not be completed on this shared box: two attempts acquired scripts/pm/os-verify-lock.sh and were killed by the container's 10-minute foreground cap during packages/spec's DTS leg (exit 143), and five attempts (spec build ×3, spec DTS-only leg, pin suites) ended queue-timeout (exit 99) · never acquired behind other seats' whole-repo turbo run build/typecheck and closure-build holds (holders named in the report's tests). The six adjacent suites WERE measured at b488ecc44 on a later acquisition: Test Files 6 passed (6) · Tests 222 passed (222) (held the lock 18s · waited 160s), against the merged tree's JS dist — spec's ESM/CJS legs had completed before the cap kill; only its .d.ts leg is missing, which vitest does not read. So at b488ecc44 the objectql typecheck and the full objectql vitest run are NOT MEASUREDpackages/spec/dist/*.d.ts is absent in this worktree. The last measured readings are at eb144eaf6 (one merge earlier, closure freshly built from that tree): pnpm --filter @objectstack/objectql typecheck exit 0 (check:test-typecheck: OK — … 44 file(s) / 242 error(s) / 69 pinned signature(s), i.e. the debt ledger unchanged and the new pin file compiling with zero errors), pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2Test Files 260 passed (260) · Tests 4513 passed (4513) (held the lock 200s), and the six adjacent suites (engine-update-duplicate-record, engine-insert-duplicate-record, engine-autonumber-resync, driver-fault-redaction, engine-update-dispatch, hook-input-shape-contract) → 222/222. Between eb144eaf6 and b488ecc44 the only commits are the merge and the census regeneration; the four hand-edited files are byte-identical at both heads (git diff eb144eaf6 b488ecc44 -- … empty). CI's TypeScript Type Check and Test Core jobs are the authority on the merged head.

Deviations and notes

  • packages/objectql/src/duplicate-record-error.ts: prose-only, no signature change — the module header and envelopeUniqueViolation's doc now name both doors, and two words in string literals: the object param doc ("refused insert" → "refused write") and developerMessage ("refused this insert" → "refused this write"), because the envelope is now raised by update too and a developerMessage calling an update an insert is a wrong attribution handed to the application author. No pin asserted the old word (git grep "refused this insert" → only the source line); the insert pin file's developerMessage assertions (DUPLICATE_RECORD, cause) are unchanged and green.
  • The message sentence (… No record was written.) is unchanged: it is pinned by the insert file and is true of a refused update as well (nothing was persisted); making it verb-aware would be a constructor signature change, which is out.
  • upsert: there is no engine-level upsert doorObjectQL has no upsert method (update.options.upsert is a retired-key tombstone, [finding] options.upsert is accepted by engine.update()'s option surface and never read — a declared-but-unenforced key (ADR-0049) #8057), update() never reaches a driver upsert, and the driver-level upsert (driver-sql, driver-memory, driver-mongodb) shares no code path with either update exit. No stop condition was met and nothing was widened.
  • content/docs/permissions/system-context.mdx re-anchored twice with check-system-context-census.mjs --fix (never hand-edited): once for the engine.ts line shifts, once per merge of origin/main (the os-regen merge driver deferred it; the discharge commit follows each merge).
  • Measurements were taken on the tree at 12a0c1dc8 (before merging origin/main); engine.ts and duplicate-record-error.ts are byte-identical in the final head to that commit for the changed hunks (the merges brought no change to either file).
  • Two git merge origin/main (28 + 6 commits); never rebased or force-pushed.

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68


Generated by Claude Code

…DUPLICATE_RECORD (#14390)
Both driver exits of engine.update — the by-id driver.update call and the
predicate driver.updateMany call — now answer a recognised unique violation
with the ADR-0112 DuplicateRecordError envelope the insert door has carried
since #14095. Everything that is not a unique violation passes through
untouched; the Update operation failed log line keeps the driver's own
diagnosis through the envelope's cause.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…shifts (#14390)
Regenerated with node scripts/check-system-context-census.mjs --fix; never hand-edited.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Discharges the os-regen deferral the merge of origin/main recorded for
content/docs/permissions/system-context.mdx; regenerated with
node scripts/check-system-context-census.mjs --fix, never hand-edited.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
)
Discharges the os-regen deferral recorded by the second merge of origin/main
for content/docs/permissions/system-context.mdx; regenerated with
node scripts/check-system-context-census.mjs --fix, never hand-edited.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

2 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 ObjectQL))
  • content/docs/protocol/kernel/error-handling.mdx(via DUPLICATE_RECORD (literal, a string literal in ObjectQL))
What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 65 pages)
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 16 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 2a265361968a435a9303ad08aacb16f497118556packageMentionDocs.

Which tree this was computed on

This run read content/docs from 86458eddd7e2ca21870730662a91548b608f7d32 — the merge of head b488ecc44b0ddb03896a0bcc66f1ef365ade81e2 into base 2a265361968a435a9303ad08aacb16f497118556, 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 86458eddd7e2ca21870730662a91548b608f7d32 && git checkout 86458eddd7e2ca21870730662a91548b608f7d32
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2a265361968a435a9303ad08aacb16f497118556 b488ecc44b0ddb03896a0bcc66f1ef365ade81e2 && git checkout -B drift-repro 2a265361968a435a9303ad08aacb16f497118556 && git merge --no-ff b488ecc44b0ddb03896a0bcc66f1ef365ade81e2
node scripts/docs-audit/affected-docs.mjs --json 2a265361968a435a9303ad08aacb16f497118556

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@os-muskos-musk added needs:contract-review and removed documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026 — with Claude
@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Validation addendum at b488ecc44 (supersedes one sentence of the body's "Declared narrowing" paragraph). After the body was written, packages/spec's DTS leg finally acquired the shared verification lock (ACQUIRED after 15s, held the lock 261s): DTS ⚡️ Build success in 224025ms, check-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present, and then, chained in the same hold, pnpm --filter @objectstack/objectql typecheck → exit 0, check:test-typecheck: OK — @objectstack/objectql's test layer compiles under packages/objectql/tsconfig.test.json; 44 file(s) / 242 error(s) / 69 pinned signature(s) held in test-typecheck-debt.json. So the objectql typecheck IS measured at b488ecc44 (green, debt ledger unchanged). What remains NOT MEASURED at this head is the full objectql vitest run — the rest of the closure (types/core/metadata*/formula, all moved by the merge) could not be rebuilt (queue-timeout (exit 99) · never acquired · never reached the head of the queue, holder pnpm check:type-check-debt of another seat, held 231s); the six adjacent suites at this head are 222/222 as the body says, and the last full run is 260 files / 4513 tests green at eb144eaf6. CI's Test Core job is the authority on the merged head.

Generated by Claude Code


Generated by Claude Code

@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Landing provenance (engine execution seat, session session_0112hMx9hjJ9BgB28X97DS68): flipped ready and armed auto-merge (squash) at 14:57Z on head b488ecc44.


Generated by Claude Code

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

Labels

Projects

None yet

2 participants

@os-musk@claude