Skip to content

fix(driver-sql): refuse and roll back a MySQL upsert that merges onto a row the caller never identified (#8807) - #8940

Merged
hotlong merged 8 commits into
mainfrom
claude/issue-8807-upsert-unnamed-unique-key-merge
Aug 16, 2026
Merged

fix(driver-sql): refuse and roll back a MySQL upsert that merges onto a row the caller never identified (#8807)#8940
hotlong merged 8 commits into
mainfrom
claude/issue-8807-upsert-unnamed-unique-key-merge

Conversation

@hotlong

@hotlonghotlong commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Fixes#8807

ON DUPLICATE KEY UPDATE carries no conflict target, so on MySQL a merge lands on whichever UNIQUE key the row collides with first. #8621 closed the half where nothing backed a caller-named target; #8755 closed the half where a rival key could absorb a caller-named one. This closes the residue those two left by construction: the conflictKeys-less call and the ['id'] call, which compile byte-identically and which no pre-flight can judge, because neither names anything.

Built on#8806's pre-flight, not beside it — the same physicalKeyIndexes read, the same cache, the same judge(), now returning a third verdict instead of a second guard.

Premise re-verified on live MySQL 8.0.46 (both shapes)

Measured through the same knex + mysql2 path upsert takes, against origin/main @ 716ac9bf8, email and tax_id both unique: true:

seed upsert({email:'d@b.com', tax_id:'T-9', title:'first'}) -- no conflictKeys
-> inserted, id=iVvD35rMk4BIayYc
B upsert({email:'e@b.com', tax_id:'T-9', title:'second'}) -- no conflictKeys
-> RESOLVED. ONE row, and it is the SEEDED one: its email rewritten
d@b.com -> e@b.com. The id B was handed back (F-Fp1OGCQB-l5XRu)
exists in no row at all.

The ['id'] spelling reproduced identically. The legitimate same-id merge was unaffected.

Zone 2 — the falsifier the ruling adopted

measure how many archiver-touched objects declare a non-primary unique field.

Measured: 0 of 2. The objects declaring lifecycle.archive in this repo are exactly sys_audit_log and sys_metadata_audit; neither declares a unique field or a unique index. The set is empty, not large, so the STOP condition does not fire — and the archiver needed no call-site change, for a stronger reason than the count (below).

The enforcement chosen, and why

A post-hoc identity check, the ruling's "acceptable alternative", because measurement favoured it decisively over A-narrowed:

A-narrowed collapses into blanket A on this path. For a caller-named target, rivals are the keys other than the named one — a narrow set. Here the target is always the primary key, so every non-primary UNIQUE key is a rival: "narrowed to tables carrying a rival key" and "every table with a business unique key" are the same set. The narrowing that made a pre-flight refusal proportionate for #8755 does not exist here, so A-narrowed would have refused a legitimate upsert(object, row) on all 43 first-party objects declaring a unique field, plus every customer object with a natural business key — the blanket ban the ruling excluded by name.

The post-hoc check is exact, not heuristic.id is insert-only on the merge path (#8622), so a row merged on the primary key always still carries the supplied id, and a row merged on any other key never does. Row-absent is therefore a biconditional for "landed on a row the caller never identified" — which is why it has zero false refusals, the property A-narrowed could not have.

The biconditional needs one fact beyond #8622, and it was measured rather than assumed: when the incoming row collides on the primary key and a rival key at once, a pre-existing row carrying our id would mask a merge that went elsewhere. MySQL updates only the first matched index. Measured on live MySQL 8.0.46 — rows R1(id=R1, tax_id=T-1) and R2(id=R2, tax_id=T-2), inserting (id=R1, tax_id=T-2):

ROW_COUNT() = 2 (an update)
R1 -> title='MERGED' <- the PRIMARY KEY row won
R2 -> untouched

The primary key is matched first, so the masking case does not arise.

It runs inside a transaction with the statement: "must never modify" is not satisfied by noticing afterwards, so the check's failure is what rolls the wrong write back.

The identity read is tenant-scoped — to the tenant the row was WRITTEN under

The check issues a read, so it routes through applyTenantScope on a bound builder like every other read door (check:tenant-chokepoint asserts exactly that; the builder is bound rather than used inline). The substantive question is which tenant to scope to, and the two readings differ on a real case:

  • Ordinary tenanted call — nothing supplied a tenant, so injectTenantOnInsert stamped the caller's org on the row. Scoping to the written tenant is scoping to the caller's org: identical to the sibling doors.
  • Admin writing to a specific tenant via raw row data — a documented authority (injectTenantOnInsert: "explicit values are never overwritten"). The row lands under the tenant the caller named, so scoping to the caller's active org would miss a row that really was written and refuse a correct write. Scoping to the written tenant does not.
  • No tenancy field, or no tenant contextapplyTenantScope returns the builder untouched when tenantId is empty, by its own contract. That is what keeps the archiver working: it calls cold.upsert(object, row, ['id']) with no options, so the read is unscoped there, exactly as before.

The verdict is tenant-independent regardless — id is the PRIMARY KEY, so at most one row can carry it. tenantIds (the ADR-0105 group-union posture) is dropped deliberately: that posture widens a read to a membership set, and this is an identity probe for one row.

Pinned in all three states, and the third pin discriminates: under the naive "caller's active org" reading it goes red (ablation C below).

Selectivity — what does not pay

The archiver, first-party

cold.upsert(object, row, ['id']) is unchanged and correct by construction: it copies rows that already carry their own id, so the merge lands on the supplied identity and the check passes — including idempotent re-copies. The one case it refuses is a cold row colliding on a business key while carrying a different id, which is archival about to overwrite an unrelated archived record; there the upsert throws, bulkDelete never runs, and hot rows survive for the next sweep (the Archiver's own safety rule). The measurement and this reasoning are recorded at the call site.

Verification — at final head bf7e01142

Both branches pinned per dialect, as the ruling requires:

refused shapestill-merging shape
live MySQL 8.0.465 pins: envelope, rollback, ['id'] spelling, message/cause, tenanted5 pins: PK merge on a two-key table, no-rival table, archiver shape, tenanted merge, admin cross-org write
SQLite / PostgreSQL1 pin: the seeded row is never modified1 pin: supplied-identity merge still merges
  • sql-driver-upsert-conflict-target-dialects.test.ts38 passed, 2 skipped against live MySQL 8.0.46 (OS_TEST_MYSQL_URL), re-run at the final head.
  • sql-driver-tenant-scope-read-doors.test.ts21 passed. The chokepoint gate's own docblock names this file as the other half of its contract ("the gate proves the call is there; only the fixture proves it works"), so it is run explicitly.
  • Consumer sweep, downstream direction (--filter '...@objectstack/driver-sql' — 48 packages): driver-sql1724 passed, driver-turso1002, driver-sqlite-wasm394, spec10719, objectql3721, metadata-protocol1512 — all green, zero failures.
  • Typecheck green on driver-sql, objectql, spec.
  • TEST_DEBT measured on a built workspace: --re-measure OK, 33 entries, 1926 raw errors, none above its recorded number. No ceiling raised. check:query-options-erasure green. Both ratchets were re-measured after the merge rather than assumed to carry over, since the merge imported feat(objectql,metadata-protocol): refuse a dotted filter key whose head is a relation, a formula, or a plain scalar — at both doors (#8371) #8936's test surface.

Ablations — direction predicted before running, all three matched

A. Identity check disabled — predicted the refusal pins red and all controls green. Observed (4 failed / 31 passed):

AssertionError: the seeded row survived with the OVERWRITTEN email — the refusal
was reported but not rolled back, so the corruption this card exists to stop
still happened: expected 'e@b.com' to be 'd@b.com'

B. Transaction wrapper removed, check kept — predicted the envelope pins stay green and only the stored-row pins go red, isolating the rollback. Observed exactly that (2 failed / 33 passed).

C. Scoping switched to the caller's active org — predicted exactly one red, the admin cross-org pin, with the other two tenanted pins green. Observed (1 failed / 37 passed):

AssertionError: the identity read is scoped to the CALLER's active org rather than
the tenant the row was written under, so a deliberate cross-org admin write reads
as a cross-row merge

All restored to byte-identity (git status clean, 0 ablation markers).

Gates

Re-derived from actual changed paths via scripts/pm/dispatch-gates.mjs, and — since that script derives from paths and cannot name a content-keyed gate — the entire 48-gate ESLint job was run explicitly, all green, check:tenant-chokepoint included (20 bindings across 3 files; every read builder scoped, every unscoped one an insert). Also green: type-check-debt --re-measure, query-options-erasure, adr-0087-registration, changeset-no-major, empty-changeset, dev-prereqs, engine-split-ratio, doc-formula-expressions, migration-registry.

The 48-gate union ran at bf46f6379 (the first merge commit). The second merge that produced bf7e01142 brought in 10 further main commits with zero file overlap with this diff (computed, not assumed), after which tenant-chokepoint, nul-bytes, migration-registry and the live-MySQL suite were re-confirmed at bf7e01142, plus the two gates that newly landed on main in that window — check-examples-live-imports.mjs and check-release-page-status.mjs — both green on this branch.

check:tenant-chokepoint is the gate that caught the first push: it is keyed on builder shape, not path, so the derived list was correct and still incomplete — the standing "lower bound" rule.

ADR-0087: implicated and added — entries/semantic/18.driver-sql-upsert-cross-row-identity-merge-refused.ts, matching the precedent #8790 set for a driver-sql accept-set change.

Merge-conflict resolution (the MERGE_CONFLICT dequeue)

packages/spec/src/migrations/registry.ts is generated, and #8936 regenerated it for its own entry. Resolved by regeneration in the mandated order — merge, commit the merge, thengen:migration-registry — never regenerating while in MERGE state. Both intents verified by exact name in the entry file and the registry body:

identry fileregistry body
driver-sql-upsert-cross-row-identity-merge-refused (this PR)presentpresent
engine-dotted-filter-refused (#8936)presentpresent

97 semantic entries, check:migration-registry green. #8936's implementation body was checked too, not just its index entry: this branch's net diff against main across objectql and metadata-protocol is exactly this PR's 26-line lifecycle comment and nothing else. No pin was weakened, deleted or adjusted.

Out of scope, as ruled

The #6943 autonumber re-seed reachability thread is untouched and unmeasured here.


Generated by Claude Code

@vercel

vercelBot commented Aug 16, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 16, 2026 3:29am

Request Review

@github-actions

github-actionsBot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/driver-sql, @objectstack/objectql, @objectstack/spec.

109 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx(via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx(via @objectstack/spec)
  • content/docs/ai/skills.mdx(via @objectstack/spec)
  • content/docs/api/client-sdk.mdx(via @objectstack/spec)
  • content/docs/api/environment-routing.mdx(via @objectstack/spec)
  • content/docs/api/error-catalog.mdx(via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx(via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx(via @objectstack/spec)
  • content/docs/api/index.mdx(via @objectstack/spec)
  • content/docs/automation/approvals.mdx(via @objectstack/spec)
  • content/docs/automation/connectors.mdx(via @objectstack/spec)
  • content/docs/automation/flows.mdx(via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx(via packages/spec)
  • content/docs/automation/hooks.mdx(via @objectstack/spec)
  • content/docs/automation/index.mdx(via @objectstack/spec)
  • content/docs/automation/webhooks.mdx(via @objectstack/spec)
  • content/docs/automation/workflows.mdx(via @objectstack/spec)
  • content/docs/concepts/architecture.mdx(via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx(via packages/spec)
  • content/docs/concepts/index.mdx(via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx(via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx(via @objectstack/objectql, packages/spec)
  • content/docs/concepts/north-star.mdx(via @objectstack/spec)
  • content/docs/data-modeling/analytics.mdx(via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx(via @objectstack/driver-sql, @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx(via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx(via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx(via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx(via packages/objectql, @objectstack/spec)
  • content/docs/data-modeling/index.mdx(via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx(via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx(via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx(via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx(via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx(via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx(via @objectstack/spec)
  • content/docs/deployment/cli.mdx(via @objectstack/spec)
  • content/docs/deployment/tenancy-modes.mdx(via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx(via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx(via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx(via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx(via @objectstack/spec)
  • content/docs/getting-started/examples.mdx(via @objectstack/spec)
  • content/docs/getting-started/glossary.mdx(via @objectstack/driver-sql)
  • content/docs/getting-started/quick-reference.mdx(via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx(via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx(via @objectstack/spec)
  • content/docs/kernel/cluster.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx(via @objectstack/objectql, @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx(via @objectstack/spec)
  • content/docs/kernel/index.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/data-service.mdx(via @objectstack/spec)
  • content/docs/kernel/runtime-services/email-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/examples.mdx(via packages/objectql, @objectstack/spec)
  • content/docs/kernel/runtime-services/index.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx(via @objectstack/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx(via @objectstack/spec)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/driver-sql, @objectstack/objectql, @objectstack/spec)
  • content/docs/kernel/services.mdx(via @objectstack/objectql, @objectstack/spec)
  • content/docs/permissions/authentication.mdx(via @objectstack/objectql)
  • content/docs/permissions/authorization.mdx(via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx(via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx(via @objectstack/spec)
  • content/docs/permissions/positions.mdx(via @objectstack/spec)
  • content/docs/permissions/rls.mdx(via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx(via @objectstack/spec)
  • content/docs/permissions/system-context.mdx(via packages/objectql, packages/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx(via @objectstack/spec)
  • content/docs/plugins/anatomy.mdx(via @objectstack/driver-sql)
  • content/docs/plugins/development.mdx(via @objectstack/spec)
  • content/docs/plugins/index.mdx(via @objectstack/objectql, @objectstack/spec)
  • content/docs/plugins/packages.mdx(via @objectstack/driver-sql, @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx(via @objectstack/spec)
  • content/docs/protocol/diagram.mdx(via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/http-protocol.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx(via @objectstack/driver-sql, @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx(via @objectstack/driver-sql, @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx(via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx(via @objectstack/driver-sql, packages/objectql, @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx(via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx(via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx(via @objectstack/spec)
  • content/docs/ui/actions.mdx(via @objectstack/spec)
  • content/docs/ui/apps.mdx(via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx(via @objectstack/spec)
  • content/docs/ui/dashboards.mdx(via @objectstack/spec)
  • content/docs/ui/field-grouping-and-order.mdx(via @objectstack/spec)
  • content/docs/ui/forms.mdx(via @objectstack/spec)
  • content/docs/ui/index.mdx(via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx(via @objectstack/spec)
  • content/docs/ui/setup-app.mdx(via @objectstack/spec)
  • content/docs/ui/translations.mdx(via @objectstack/spec)
  • content/docs/ui/views.mdx(via @objectstack/spec)

7 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/driver-sql, @objectstack/objectql, @objectstack/spec)
  • content/docs/releases/index.mdx(via @objectstack/spec)
  • content/docs/releases/v12.mdx(via @objectstack/spec)
  • content/docs/releases/v13.mdx(via @objectstack/spec)
  • content/docs/releases/v16.mdx(via @objectstack/spec)
  • content/docs/releases/v17.mdx(via @objectstack/spec)
  • content/docs/releases/v9.mdx(via @objectstack/spec)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 16, 2026
@hotlong
hotlong marked this pull request as ready for review August 16, 2026 01:59
@hotlong
hotlong added this pull request to the merge queueAug 16, 2026
@github-merge-queue
github-merge-queueBot removed this pull request from the merge queue due to a conflict with the base branch Aug 16, 2026
…merge (#8807)
Both entries stack: #8936's engine-dotted-filter-refused and this card's
driver-sql-upsert-cross-row-identity-merge-refused. 97 semantic entries.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8
@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

Handover note — an obligation I took on that must not live only in a session.domain:drivers seat, PM session session_01XeQRiAa7vYRVX5Fog7Zby8, written at shift end.

State

This PR was dequeued with MERGE_CONFLICT at 02:05Z — ⛔ not a CI failure. Its 29 checks were all green at bd0f45cd7 (28 success, 1 skipped), including the two that had been red earlier: ESLint (which hosts check:tenant-chokepoint) and Test Core (2/3).

main moved. Measured cause, so nobody re-derives it:

a8189aef4 feat(objectql,metadata-protocol): refuse a dotted filter key … at both doors (#8371) (#8936)
+ packages/spec/src/migrations/entries/semantic/18.engine-dotted-filter-refused.ts

#8936 added its own ADR-0087 semantic entry and regenerated packages/spec/src/migrations/registry.ts. This PR added 18.driver-sql-upsert-cross-row-identity-merge-refused.ts and regenerated the same file. The generated registry is the conflict.

The obligation

I told the implementing dev, verbatim: "Push when green. I will re-queue; you do not need to." Recording it here because a commitment held only in a session is a half-state.

When the dev pushes the resolved merge and CI is green, this PR needs auto-merge re-armed. It is already draft: false, so arming is a single action — ⛔ no ready-conversion needed, ⛔ no rebase, ⛔ no new review.

A self check-in is armed at 02:51Z carrying this criterion. If that fires and nothing has been pushed, it re-arms silently rather than acting.

⚠️ The trap the resolution must avoid

registry.ts is generated. The mandated order (scripts/pm/os-regen-merge.sh) is: merge → commit the merge → then regenerate.

⛔ Regenerating while still in MERGE state silently rolls the artifacts back to the fork point. It does not error. It looks like it worked. One of the two entries would be lost, and the loss would be invisible in this PR's own diff.

The resolution is only provable if both ids are confirmed present afterwards, in the entry file and the registry body:

A sibling PR hit this exact trap tonight (#8927) and resolved it correctly; the discipline is inherited from there, not invented here.

Also re-run after the merge, not assumed to carry over

A merge can import another PR's test surface: check:type-check-debt --re-measure on a built workspace, check:query-options-erasure, and the whole ESLint job's gate set — #8936 touched objectql and metadata-protocol, and this PR's identity-read binding is exactly what check:tenant-chokepoint watches.


Generated by Claude Code

@hotlong
hotlong added this pull request to the merge queueAug 16, 2026
Merged via the queue into main with commit d00d2f6Aug 16, 2026
30 checks passed
@hotlong
hotlong deleted the claude/issue-8807-upsert-unnamed-unique-key-merge branch August 16, 2026 04:09
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

drivers(sql): on MySQL an upsert with no conflictKeys — or one naming the primary key — still merges on a unique key the caller never named

2 participants

@hotlong@claude