Skip to content

fix(driver-sql,driver-turso)!: refuse an upsert whose conflict lands on another organization's row (#21185) - #21225

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-21185-upsert-cross-org-refusal
Oct 1, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-21185-upsert-cross-org-refusal

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #21185

Clause-②: no (narrowing)

Executes ruling record 5934879010 on #21185 (letter A, refinements 1/2/3): a tenant-scoped upsert whose conflict lands on a row of another organization is refused with UNIQUE_VIOLATION and writes nothing, and the tenant column is insert-only (insertOnlyUpsertColumns), so an upsert never changes a row's organization. update() stays the deliberate path. Classes and positions only; the reproduction stays with the seats.

The two packages/spec edits (patch round 1, on the seat's answer A)

The ruled answer needed two packages/spec edits. The dispatch held them for the seat's word, and the seat answered A on #21185 (claim amendment 5937974892), under the spec lane's standing pre-approval. Neither edit moves the ruled Clause-②: no (narrowing): no accept set widens. The precedent is the driver-sql-calendar-day-methods-removed changeset, which carries registered plus Clause-②: no (narrowing) with @objectstack/spec at patch.

  1. Error-code provenance (82f8120c). driver-sql now stamps the registered UNIQUE_VIOLATION (UPSERT_UNIQUE_VIOLATION_CODE in sql-driver.ts), so one row, 'UNIQUE_VIOLATION', is added under the @objectstack/driver-sql owner key in packages/spec/src/api/error-code-ledger.zod.ts. It is provenance only: the ErrorCode union is byte-unchanged, and api-surface records the ledger by name only. driver-turso throws the same constructor through SqlDriver, so it needs no row.
  2. ADR-0087 disposition (82f8120c, then 5682e8fe). The ruled FROM → TO is a migration prescription, so the disposition is registered driver-upsert-cross-organization-conflict-refused. The semantic entry is packages/spec/src/migrations/entries/semantic/18.driver-upsert-cross-organization-conflict-refused.ts. Its prefix is the protocol-18 step that registry.ts records as accumulating and uncut, as for the precedent 18.driver-sql-calendar-day-methods-removed.ts. src/migrations/registry.ts was regenerated with gen:migration-registry, the one artifact check:generated --fix proved stale; spec-changes.json and the upgrade guide do not move until the protocol major reaches 18. The changeset front matter carries '@objectstack/spec': patch.

What changed, per face

IDataDriver.upsert's docblock in packages/spec/src/contracts/data-driver.ts is one line and states no merge semantics, so Clause-② stays no (narrowing).

Tests

The pins and the round-0 results below ran on e746da26 (after merging main, which brought in #21178's remote-transport change). Patch round 1's head, 35d01c9d, merges main again: #21163 (c6b68891, the scanMaxNumericTail region of sql-driver.ts, a clean merge) and #21067 (682873d9). On 35d01c9d the driver suites were re-run (SQLite cell: driver-sql 211 files, 3545 passed; driver-turso 86 files, 2313 passed; driver-sqlite-wasm 675 passed), and so were the live PostgreSQL 16 and MariaDB 10.11 cells (92 of 92). Pins:

  • packages/drivers/driver-sql/src/sql-driver-upsert-cross-organization-refusal.test.ts. One dialect-cell matrix: SQLite, PostgreSQL, MySQL.
    • A cross-organization conflict is refused on the default id target, on an explicit ['id'] target and on a unique: 'global' column, with code and status asserted. The other organization's row reads back raw and byte-identical, and no row is added.
    • The refusal also holds inside a caller's transaction that commits afterwards.
    • The refusal names no organization, and it is recognised by the shared predicate.
    • A row with no organization is refused too.
    • Controls: a same-organization merge on both targets; an insert lands under the caller's organization; an explicitly named organization on an insert is not refused.
    • Half 2: no tenant context keeps the row's organization on both targets, and update() still moves it.
  • packages/drivers/driver-turso/src/turso-local-remote-upsert-cross-organization-parity.test.ts. The same pins on both TursoDriver faces, plus a parity pin that holds the two faces' answers equal.

Local results:

MySQL 8.0 itself is NOT MEASURED locally (no server in the container). CI's Temporal Conformance (live PG + MySQL) job runs this package's whole suite with OS_EXPECT_LIVE_DIALECT_MATRIX=1.

Reverse verification. The fix was committed first. Then main's sql-driver.ts was restored under a trap and the new file was run on all three cells: 25 red and 14 green. The red ones are every refusal pin and half 2 on each cell. The fresh-id business-key pin is red on MariaDB only, because SQLite and PostgreSQL already answer it with their own unique violation. The file was then restored, with byte identity proved by blob hash and an empty git diff HEAD. The turso file ran red on main (10 of 15), including the remote insert leg writing no organization.

Gates. On 35d01c9d, dispatch-gates --commands derives 93 families (the spec paths added 30), and --ran reconciles 93 derived, 93 run, 0 NOT MEASURED, all exit 0. Among them: check-adr-0087-registration --base origin/main, check:error-code-provenance (336 stamp sites: 317 listed, 19 waived), check:generated (15 artifacts up to date), check:migration-registry, check:spec-changes, check:upgrade-guide, check:api-surface and check:error-code-casing. check:driver-conformance reads the same before and after (50 covered, 0 DEBT, 0 exempt), and check:tenant-chokepoint passes. ESLint, narrowed to the 8 changed .ts files, reports 0 errors and 0 warnings (--format json). The config enables no type-aware linting (eslint.config.mjs says so), so this diff cannot move the verdict on an untouched file.

Acceptance notes

  • The predicate is the written organization, as ruled. A tenant-scoped call that names another organization in its payload uses injectTenantOnInsert's documented admin authority, so it still merges into that organization's row. update() under the caller's scope would not reach that row. Under the group posture the fence is the written organization alone: an upsert onto a row of another member organization is refused, where update()'s union scope reaches it.
  • Rows with no organization are fenced off too. The written value is never NULL, so the predicate treats a stored NULL as distinct.
  • No in-repo producer reaches a fenced cross-organization conflict. The lifecycle archiver's cold.upsert(object, row, ['id']) carries no tenant context, so it gets half 2 only. The sandbox body runner's ql.upsert falls back to insert, and the knowledge adapters' upsert is a different interface.
  • driver-sqlite-wasm inherits the fence. Its knex dialect extends the SQLite3 compiler and its suite is green, but it has no tenant-scoped upsert pin of its own.
  • SqlDriver.create on the MySQL family answers the insert id, not the record: filed as driver-sql on MySQL: create() answers the insert id (0) instead of the inserted record, so sign-up answers 400 FAILED_TO_CREATE_USER, the dev admin seed fails and no user can sign in #21227. Measured by this card's round-0 dev and then at the public doors: builder.insert(...).returning('*') returns [insertId] on MySQL, so create() returns 0 for a string-id table. Sign-up answers 400 FAILED_TO_CREATE_USER and no user can sign in. bulkCreate has the same pattern. This PR's pins read the stored row back rather than create's return value, and upsert's own read-back is unaffected, so it does not bear on this change.

Generated by Claude Code

claude added 6 commits October 1, 2026 16:56
…sal (red on main)

Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp
Co-authored-by: Claude <noreply@anthropic.com>
…utside the written organization

An upsert resolves its conflict against the whole table, so a tenant-scoped
call could merge into, and re-parent, a row of another organization. The
merge leg is now fenced to rows of the organization the row is written
under (in-statement predicate on SQLite/PostgreSQL/libsql; a
transaction-bound check on MySQL), a conflict elsewhere is refused with
UNIQUE_VIOLATION/409 and writes nothing, and the tenant column joins
insertOnlyUpsertColumns so no upsert re-parents a row.

Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp
Co-authored-by: Claude <noreply@anthropic.com>
… context; pin the caller-transaction refusal

Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp
Co-authored-by: Claude <noreply@anthropic.com>
…sert refusal (BREAKING, minor)

Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Oct 1, 2026
@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/driver-sql, @objectstack/driver-turso, @objectstack/spec, touching 15 documentable anchor(s).

15 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/api/error-catalog.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object), UNIQUE_VIOLATION (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UPSERT_UNIQUE_VIOLATION_CODE))
  • content/docs/api/error-handling-server.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/data-modeling/drivers.mdx (via SqlDriver (symbol, a top-level class), UNIQUE_VIOLATION (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UPSERT_UNIQUE_VIOLATION_CODE))
  • content/docs/data-modeling/index.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/data-modeling/queries.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/kernel/contracts/data-engine.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/permissions/tenant-audit-census.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/error-handling.mdx (via UNIQUE_VIOLATION (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UPSERT_UNIQUE_VIOLATION_CODE))
  • content/docs/protocol/kernel/http-protocol.mdx (via UNIQUE_VIOLATION (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UPSERT_UNIQUE_VIOLATION_CODE))
  • content/docs/protocol/kernel/index.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/lifecycle.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/objectql/query-syntax.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/objectql/types.mdx (via SqlDriver (symbol, a top-level class))

⛔ 5 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object), SqlDriver (symbol, a top-level class))
  • content/docs/releases/v17/17-1.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/releases/v17/17-3.mdx (via UNIQUE_VIOLATION (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UPSERT_UNIQUE_VIOLATION_CODE))
  • content/docs/releases/v17/17-4.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object), UNIQUE_VIOLATION (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UPSERT_UNIQUE_VIOLATION_CODE))
  • content/docs/releases/v17/17-5.mdx (via SqlDriver (symbol, a top-level class), UNIQUE_VIOLATION (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UPSERT_UNIQUE_VIOLATION_CODE))

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

What this run could not see
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 140 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 7c5a311a5829ae3b550a3eeb9bb984f06be8b865 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 76d50acf09f9fac6f4b96d547c2d7617d3fd08f0 — the merge of head 35d01c9d7fedae909f93a171ac2c319ed17000a6 into base 7c5a311a5829ae3b550a3eeb9bb984f06be8b865, 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 76d50acf09f9fac6f4b96d547c2d7617d3fd08f0 && git checkout 76d50acf09f9fac6f4b96d547c2d7617d3fd08f0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7c5a311a5829ae3b550a3eeb9bb984f06be8b865 35d01c9d7fedae909f93a171ac2c319ed17000a6 && git checkout -B drift-repro 7c5a311a5829ae3b550a3eeb9bb984f06be8b865 && git merge --no-ff 35d01c9d7fedae909f93a171ac2c319ed17000a6

node scripts/docs-audit/affected-docs.mjs --json 7c5a311a5829ae3b550a3eeb9bb984f06be8b865

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

…IOLATION provenance for driver-sql and its ADR-0087 semantic entry

Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 35d01c9d7fedae909f93a171ac2c319ed17000a6
Local-runs: none

Inputs: card #21185 (body; comments 5933970973, 5934416748, 5934820328, the ruling 5934879010, the claim 5935961155, the round-0 os-dev-report 5937894588, the claim amendment 5937974892, the round-1 os-dev-report 5938821270); PR #21225 (body as edited by the seat, 9-file list, the net diff against main at the merge base c6b68891, which is also the PR's base sha, so the net diff is the branch's own change); the check-runs on this head; files read at the head and at origin/main with git show / git grep after a fetch. Nothing built, run, re-run or ablated. The head is a merge of origin/main c6b68891 into the branch; judged on the net diff. No path in the file list is a governed surface (register read: docs/adr/, .claude/, skills/, AGENTS.md, CLAUDE.md, docs/NORTH-STAR.md); this record is the at-tier review the spec lane's pre-approval of the cross-lane error-code-ledger.zod.ts append conditions on (5937974892), not a Tier S release. Head repo equals base repo; 1,285 changed lines.

① Derived judgments

Every accept-set or public-surface change the diff implies, each named right or wrong.

  1. SqlDriver.upsert narrows (local faces of driver-sql, inherited by driver-sqlite-wasm and TursoDriver's local face): a tenant-scoped call (non-empty options.tenantId on an object with a tenant column) whose conflict lands on a row whose stored tenant column is not the written one, another organization's or NULL, is refused with code: 'UNIQUE_VIOLATION', status: 409, and nothing is written. Right: the ruling's letter A. A narrowing; no input is newly accepted.
  2. Half 2, the tenant column joins insertOnlyUpsertColumns. At main the set is created_at, id and the autonumber columns; the head adds resolveTenantField(object) through remoteColumn, the same resolution the autonumber columns use. One list, read by both faces (TursoDriver's remote branch spreads the same method). Right, and the ruled no-op reading holds: with the fence, stored and written values are equal on every merge a tenant-scoped call is allowed, so the exclusion only acts on the call with no tenant context.
  3. Refinement 1, the predicate is the landed row's organization for ANY conflict target. upsertTenantGuard is computed from the row after injectTenantOnInsert and never reads mergeKeys; the in-statement predicate compares the stored tenant column with excluded whatever the target; MySQL's read keys on the conflict-key values the statement sent, or id when one is empty. Pinned on the default target, an explicit ['id'] and a unique: 'global' column, on all three dialect cells and on both Turso faces. Right.
  4. Refinement 2, UNIQUE_VIOLATION exactly as create(), no new code, no organization named. One constructor (refuseUpsertConflictOutsideWrittenOrganization) builds the registered code with status 409 and the engine's duplicate-record sentence; it carries no cause, no organization, no tenant column name, no value of the landed row; both faces throw it (upsertConflictRefusal is the protected door the remote face uses). isUniqueViolationError recognises it because UNIQUE_VIOLATION is in that predicate's codes set (read in packages/types/src/unique-violation.ts). Pinned: message, own properties and four levels of cause swept for both organization ids and the other row's title, on every cell and face. One nuance judged right rather than wrong: the SQL drivers' own create() throws the raw dialect error at the driver boundary and the engine wraps it to the same 409 UNIQUE_VIOLATION; the shared predicate recognises both, so "the same answer" holds at the envelope consumers branch on, and the fresh-id pin lets SQLite and PostgreSQL answer with the server's own violation (what create() answers there) while asserting the driver's pair on MySQL only. The constant stamp is why the provenance row (item 12) is owed.
  5. Refinement 3, the mechanism per face.
    • SQLite and PostgreSQL: merging.where(knex.raw(...)) puts table.column IS excluded.column (SQLite) / IS NOT DISTINCT FROM (PostgreSQL) inside ON CONFLICT … DO UPDATE; NULL-safe, and the written value is never NULL on a fenced call, so a stored NULL is distinct and a platform row is fenced off. The read-back that already followed the statement now runs under the written organization exactly: applyTenantScope with tenantIds dropped, plus equality on the tenant column. The equality is necessary, not decorative: applyTenantScope compiles field = ? OR field IS NULL, so without it the ruled premise "the read-back finds no row" is false for a NULL row and the call would answer success for a write that did not happen. A miss throws the refusal. No added transaction and no added round trip. Right.
    • Remote face (libSQL): RemoteTransport.upsert takes a fence (column and written value, told which and not why, as with insertOnlyColumns), appends WHERE table."col" IS excluded."col" to a DO UPDATE and leaves DO NOTHING alone, adds AND "col" = ? to the read-back, and answers null on a fenced miss, which TursoDriver turns into the one refusal. The read-back decides rather than rowsAffected, and the reason is correct: DO NOTHING affects zero rows on a same-organization conflict too, so a rowsAffected verdict would refuse the control (pinned on both faces: "nothing left to merge is not refused"). writeRemoteRowWithAutoNumbers returns the null straight through, so no re-seed retry fires on a refusal. The ruling offered both options and asked the dev to state which; the PR body states it. Right.
    • MySQL: ON DUPLICATE KEY UPDATE takes no WHERE (knex refuses merge().where() there), so 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 #8807's mechanism is reused: the statement and assertMergeLandedInWrittenOrganization run in one knex.transaction when the caller holds none, and in a nested transaction (a savepoint) inside a caller's transaction; the read is under the written organization exactly (chokepoint scope plus equality), keyed on the conflict-key values; a miss throws and the write rolls back. The check is owed whenever the call is fenced (checkAfterStatement = verifyIdentity || mysqlFence), not only on a rival UNIQUE key, as ruled. When 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 #8807's verdict is also owed, the found row subsumes it, and a miss is told apart by probing each probeable rival key's sent values under the written organization: a hit is 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 #8807's own refusal, anything else is UNIQUE_VIOLATION, a refusal and a rollback either way. Right.
    • The catch rethrows the fence's refusal before the unbacked-target recogniser and the autonumber re-seed (isUpsertFenceRefusal reads the driver's own code and status pair, which no dialect error carries). Right: it is not a server error, and a retry would be refused identically.
  6. The written-organization choice. The fence compares against the row's tenant value after injectTenantOnInsert, so a tenant-scoped payload that names another organization (that method's documented admin authority: explicit values are never overwritten) merges into that organization's row and inserts under it. Right: it is the ruling's own predicate (refinement 3 compares the stored row with the written row's value, and the MySQL read is "under the written tenant"), and the reading 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 #8807's identity probe already settled. Pinned on the insert arm (an explicitly named organization on an inserted row is not refused); the merge arm is recorded in the PR's Acceptance notes as ruled semantics for the maintainer, which is the right place.
  7. The stored-NULL row is refused and left untouched. Right by the ruling's NULL-safe predicate; pinned on all three dialect cells. On the remote face it holds by the same construction (IS plus the equality read-back) but is not pinned there; noted, not a defect.
  8. The group posture note. The fence is the written organization alone, with tenantIds dropped as in 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 #8807's one-row probe, so a tenant-scoped upsert onto a member organization's row is refused where update()'s union scope reaches it. Inside the ruling: the ruled predicate is the written row's organization, and ADR-0105 D5 keeps the insert target the active organization. Recorded for the maintainer in Acceptance notes.
  9. A caller transaction. SQLite and PostgreSQL write nothing, so there is nothing to undo; MySQL runs the statement and its read in a savepoint, so the refusal does not depend on what the caller does next. Pinned on every cell: refused inside a caller's transaction, and a commit after the refusal writes nothing while the caller's own later write lands. Right, and consistent with the ruling's "its failure rolls the write back"; 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 #8807's own hand-the-decision-over path is unchanged for the calls it still owns.
  10. Two behaviour changes beyond the refusal, each judged against the ruling and the Clause-②: no (narrowing) line:
    • (i) The remote face now stamps the caller's organization on an upsert's insert leg (injectTenantOnInsert on entry). Inside the ruling and inside the narrowing line. The ruled mechanism is defined on the organization the row is written under and presupposes the stamp ("a tenant-scoped call is stamped with its caller's organization on entry" is a premise of the ruling); measured on main (the p0 hit), the remote insert leg wrote a row with no organization, so the fence would have compared against nothing and refused the same-organization control. No input is newly accepted or refused on the insert leg; a tenant-scoped remote insert now lands where the local faces already land it. Declared in the changeset and the PR body, pinned on both faces. It is scoped to the upsert door; the remote create door still does not stamp, which is [security] driver-turso remote face: find / count / update / delete / create apply no tenant scope (only distinct() refuses), where the local face scopes every door by DriverOptions.tenantId #21226's.
    • (ii) assertMergeLandedOnSuppliedIdentity no longer scopes a no-tenant-context call to the payload's tenant (tenantContext now gates the written-tenant scope). Inside the ruling: a direct consequence of half 2, since the merged row keeps its organization and a read scoped to the payload's organization would miss a merge that landed on the supplied id (the dev measured that false refusal). The verdict is tenant-independent (id is the primary key), so the unscoped read loses nothing, and under a tenant context on a tenanted object the method is no longer reached at all (the fence's read replaces it). No accept set moves beyond half 2's.
  11. packages/spec, the error-code provenance row. One literal 'UNIQUE_VIOLATION' appended as the last entry of the @objectstack/driver-sql section (read at the head: it is the final literal before the @objectstack/driver-turso key); ErrorCode is derived from the de-duplicated set of every row, and the code is already registered by @objectstack/types, @objectstack/plugin-security and @objectstack/driver-memory, so the union is unchanged. Provenance only, per the file's own "listed once per emitting package" rule, which check:error-code-provenance enforces on the _CODE = '…' constdef stamp site; driver-turso stamps no literal (it throws through SqlDriver's constructor), so it owes no row. Right file, right section, no accept set moved.
  12. packages/spec, the semantic entry and its generated region. entries/semantic/18.driver-upsert-cross-organization-conflict-refused.ts is the right file and the right major step: registry.ts names step 18 "accumulating, uncut", the head carries 259 18.-prefixed semantic entries against 77 at 17., and the lane precedent 18.driver-sql-calendar-day-methods-removed.ts sits in the same step. The prose is accurate against the code: the surface names the three driver classes and both faces, the replacement states the 409 answer and the update door, the reason's mechanism sentences match the diff face by face, and nothing names an organization. The registry.ts hunk is the same literal inserted in id order inside step18's generated region; check:migration-registry (Lint & Repo Gates) and check:generated (TypeScript Type Check) are green on this head. spec-changes.json and the upgrade guide do not move for an uncut step; the precedent id has zero hits in either at the head. No Zod schema is touched. Right.
  13. The pins prove what they claim, with controls.
    • sql-driver-upsert-cross-organization-refusal.test.ts runs DIALECT_CELLS through declareDialectCell: SQLite always; PostgreSQL and MySQL from OS_TEST_POSTGRES_URL / OS_TEST_MYSQL_URL, an unprovisioned cell being a named skip and a named failure under OS_EXPECT_LIVE_DIALECT_MATRIX=1. The Temporal Conformance (live PG + MySQL) job sets all three and runs pnpm --filter @objectstack/driver-sql test, the whole suite, so all three cells ran on this head in CI and that job is green: MySQL 8.0 is measured there, which answers the dev's local MariaDB stand-in. "Nothing written" is a raw-table snapshot equality before and after, so on SQLite and PostgreSQL it proves the in-statement predicate (not only the read-back refusal) and on MySQL the rollback. Controls: same-organization merge on both targets with the other row untouched, an insert landing under the caller, an explicitly named organization on an insert not refused, update() still moving the row; half 2 on both targets. Reverse verification is recorded (25 red / 14 green with main's sql-driver.ts restored, blob identity proved, the MariaDB-only fresh-id red explained).
    • turso-local-remote-upsert-cross-organization-parity.test.ts drives one TursoDriver class on both faces; the remote face goes through the libSQL stub, which executes the transport's actual SQL on better-sqlite3 (prepare().run/all), so the IS excluded predicate and the fenced read-back are executed, not string-asserted. Refusal pins, controls and half 2 per face, plus a parity pin that holds the two faces' answers equal and then asserts the agreed answer is the refusal. Not pinned on the remote face: the stored-NULL row and the shared predicate; both are held by construction and by the local-face pins on the shared constructor. Noted.
  14. check:tenant-chokepoint sees the three new read builders (the MySQL landed and rival probes, the fenced read-back) route through applyTenantScope; the gate is green in Lint & Repo Gates.

② Semver level

  • .changeset/21185-upsert-cross-org-refusal.md: @objectstack/driver-sql: minor, @objectstack/driver-turso: minor, @objectstack/spec: patch. Matches what the diff publishes: the two drivers publish a narrowed upsert contract, BREAKING, as the ruling's execution parameters say; check-changeset-no-major forbids a major, so breaking is minor with !, which the title line carries (fix(driver-sql,driver-turso)!:). spec publishes a provenance row and a registry entry and no schema change, so patch, the fix(plugin-security,driver-sql,driver-turso): lower type-blind at the RLS seam without a guard, then delete the F1/F2 whole-day and NOT-rewrite copies (#5930 step 4, group 2) #20988 precedent (registered plus Clause-②: no (narrowing) plus spec patch).
  • The FROM → TO table is present with both ruled rows: a cross-organization conflict refused with UNIQUE_VIOLATION / 409 and nothing written; the merged row keeps its organization, update() to move it.
  • The ADR-0087 marker, in the HTML-comment form the gate reads, says adr-0087: registered driver-upsert-cross-organization-conflict-refused; the id resolves at the head and is new in this diff, which is the registered rule; the FROM → TO is a prescription, so not-required (no-migration-prescription) would have been refused and registered is the honest disposition. Check Changeset is green on this head (both runs).
  • Clause-②: no (narrowing) stands in the changeset and the PR body. Right: no packages/spec accept set moves (IDataDriver.upsert's docblock in contracts/data-driver.ts is one line and states no merge semantics, read at the head), and the drivers' accept set narrows.
  • @objectstack/driver-sqlite-wasm is not named: it inherits the door through driver-sql, versions with the fixed group regardless, and the precedent fix(plugin-security,driver-sql,driver-turso): lower type-blind at the RLS seam without a guard, then delete the F1/F2 whole-day and NOT-rewrite copies (#5930 step 4, group 2) #20988 named it neither; the semantic entry's surface names SqliteWasmDriver. Not a defect; noted.

③ Boundary flags

Round 0 deviations (5937894588):

Round 1 deviation (5938821270): the entry file is 18., not the dispatch's 17.. Right: the 17. was the dev's own round-0 guess echoed by the seat; registry.ts names step 18 as the accumulating uncut step, the precedent is 18., and the id is unchanged.

open_questions: round 0's single question (authorise the two packages/spec edits) was answered A by the seat in 5937974892 and executed in round 1 on the same branch and PR with no second claim (the newest Claim: names this branch; the branch-claim check is green). Round 1 lists none.

Out-of-scope findings:

Check-runs on 35d01c9d7fedae909f93a171ac2c319ed17000a6, read at 2026-10-01T19:34:44Z, newest run per name, all completed: the seven required contexts read success: Lint & Repo Gates, TypeScript Type Check, Test Core (and its six shards), Dogfood Regression Gate (and its three shards), Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. Advisory, success: Check Changeset (the first run, and the re-run the seat's body edit triggered), Check Documentation Links, Dogfood Verify CLI, Flag docs affected by code changes, Spec property liveness, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, The card this PR closes must claim this branch, the four Type Check · jobs, filter. skipped by design: Auto Label and Check PR Size (edited-trigger re-runs; the first runs were success), Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in). No required job is in progress and nothing reads failure.

Implemented-by: claude/issue-21185-upsert-cross-org-refusal
Reviewed-by: session_017xfMoEjKUuSh2xYB8sCozp

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 1, 2026 19:38
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 1, 2026 19:40
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 1, 2026
Merged via the queue into main with commit 95e24b0 Oct 1, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21185-upsert-cross-org-refusal branch October 1, 2026 20:05
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…objectstack-ai#21227) (objectstack-ai#21239)

Fixes objectstack-ai#21227

Clause-②: no

## What changes

`SqlDriver.create` and `SqlDriver.bulkCreate` ran
`builder.insert(...).returning('*')` and answered what the statement
answered. MySQL has no `RETURNING`: knex's MySQL compiler drops the
clause (it logs `.returning() is not supported by mysql`) and answers
`[insertId]`. That is ONE element whatever the row count, and `0` for
this driver's string primary key.

Both doors now answer the stored row on every dialect:

- **SQLite and PostgreSQL families**: unchanged. They answer from
`RETURNING` in one statement.
- **MySQL family, and any client the driver recognises as neither
family**: the INSERT is issued without `.returning()`, and the rows are
read back by the ids that were written. That is one `SELECT` per
`create`, and one per `bulkCreate` batch.

The switch is one protected capability getter, `insertReturnsStoredRows`
(`isSqlite || isPostgres`), next to `isMysql`. One private helper,
`readBackInsertedRows`, serves both doors. No caller was patched: the
auth adapter, the engine and the protocol are untouched.

### Measured before the fix (live MySQL 8.0.46, `origin/main`
`62b90d74`, driver called directly)

| call | answered | stored |
|:--|:--|:--|
| `create`, generated id | `0` | the row |
| `create`, supplied id | `0` | the row |
| `bulkCreate`, 3 rows | `[0]` (length 1) | all 3 rows |
| `bulkCreate`, 1 row | `[0]` | the row |

SQLite and live PostgreSQL 16.14 answered the full stored rows in the
same probe, including `done: false`, which only the column DEFAULT
supplies. That is the control.

### The doors, before and after (`pnpm dev:crm -- --fresh --database
mysql://...` on that MySQL 8.0.46)

| door | at `62b90d74` | with this change |
|:--|:--|:--|
| `POST /api/v1/auth/sign-up/email`, first user (`--no-seed-admin`) |
`400 FAILED_TO_CREATE_USER`; `sys_user` row stored, no `sys_account` row
| `200`; user, `credential` account and session stored |
| `POST /api/v1/auth/sign-in/email`, same credentials | `401
INVALID_EMAIL_OR_PASSWORD` | `200` |
| `--seed-admin` at boot | `dev admin seed skipped: Failed to create
user`; orphaned `sys_user` | seeded; admin sign-in `200` |
| `POST /api/v1/data/crm_account` as the seeded admin | not reachable
(no session could exist) | `201`, with the full record including the
stamped `organization_id` |
| boot: `curated capability ... has no platform row and could not be
seeded` warnings | 9 | 0 |

## Decisions the card left open

- **H3: read back only where `RETURNING` does not answer the stored
row.** Reading back on every dialect would add one round trip to every
`create` on SQLite and PostgreSQL, where `RETURNING` already answers the
stored row (measured above). It would also move the control cells onto
new code. Cost on MySQL: +1 `SELECT` per statement. Cost on SQLite and
PostgreSQL: 0. The pin file counts the statements on every cell. The
getter is a positive list on purpose. A client the driver does not
recognise (a Client constructor, `redshift`, `mariadb`) reads back,
which is correct on every dialect. `RETURNING` is the shortcut that only
a dialect known to answer the stored row gets. `driver-sqlite-wasm`
overrides `isSqlite` to `true`, so it keeps `RETURNING`.
- **H2: one helper for `create` and `bulkCreate` only. `update` and
`upsert` are byte-unchanged.** The four read-backs answer different
things:
- `update` reads by id under the caller's scope and answers `null` on a
miss, which its contract allows.
- `upsert` reads by the conflict-key values it matched on and falls back
to the payload.
  - `create` must answer a row, and is keyed on ids it wrote.

Sharing one helper would change one of those answers. It would also
touch the `upsert` region, which the card fences off.
- **H4: the read key is the written id, and that id always exists.**
`create` and `bulkCreate` give every row its id before the statement is
built: the caller's `id`, else `_id`, else a minted nanoid. The managed
`id` column is `varchar(255)` PRIMARY KEY with no AUTO_INCREMENT, so no
insert id is ever read. That is the only kind of key this path produces.
- The column goes through `remoteColumn`, so an external `columnMap`
that renames `id` is read by its physical column.
  - The table is the write target, a rotation shard included.
- The tenant scope goes through `applyTenantScope`, scoped to the
tenant(s) the rows were WRITTEN under (as
`assertMergeLandedOnSuppliedIdentity` scopes its probe). For a batch
that is the union through `tenantIds`. So an admin write that names
another tenant in the row data is answered rather than missed.
- The ids are this call's own and `id` is the PRIMARY KEY, so the read
cannot answer another organization's row.
  - The read uses the caller's transaction when there is one.
- **A written row that is gone before the read-back** (a concurrent
delete, or a trigger) is refused with `DATABASE_ERROR` / 500. It is not
answered with the payload, and the insert is not re-issued. The
read-back runs outside the insert's `try`, so a read fault can never
reach the autonumber collision retry.

## One conclusion per face of the invariant (`IDataDriver.create`
answers the inserted record)

1. **`driver-sql`**: changed for the MySQL family. SQLite and PostgreSQL
are already conformant and unchanged (evidence: the pin's control cells,
green before and after).
2. **`driver-sqlite-wasm` and LOCAL-mode `driver-turso`**: inherit
`SqlDriver.create` / `bulkCreate` and stay on `RETURNING`. Wasm
overrides `isSqlite` to `true`; Turso local uses `better-sqlite3`. Their
suites are green: wasm 36 files / 675 tests, turso 86 files / 2313
passed, 33 skipped.
3. **REMOTE-mode `driver-turso`**: already conformant.
`RemoteTransport.create` issues its INSERT and then `SELECT * ... WHERE
"id" = ?` and answers that row (`remote-transport.ts`). Its `bulkCreate`
loops the driver's own `create`.
4. **`driver-memory`**: already conformant. `create` pushes the built
record and answers a copy of it, and `bulkCreate` answers the pending
records it pushed.
5. **`driver-mongodb`**: already conformant. `create` answers the
document it inserted (minus `_id`), and `bulkCreate` answers the
inserted docs in order.

## Pins


`packages/drivers/driver-sql/src/sql-driver-21227-create-answers-stored-row.test.ts`,
through `declareDialectCell`: SQLite always, and live PostgreSQL and
MySQL where provisioned. The `Temporal Conformance (live PG + MySQL)`
job runs them on both. There is also one cell that always runs: SQLite
with the read-back path forced. It puts the read-back's ordering, tenant
scope, transaction and refusal into every CI run, not only the job with
a MySQL server. Each test pins one behaviour:

- `create` with a generated id and with a supplied id;
- `bulkCreate` of 3 rows, which answers 3 rows in written order from ONE
insert, plus ONE read on the read-back path;
- `bulkCreate` of 1 row;
- a tenanted create;
- an admin write naming another tenant (single and batch);
- create and bulkCreate inside a rolled-back caller transaction;
- the vanished-row refusal (forced cell only), asserting `code` and
`status` and that the insert is not re-issued;
- a per-cell check that the cell measures the path it claims to.

Every answer is compared with the driver's own `findOne` and must carry
the DEFAULT-only `done: false`.

Reverse verification, both legs run with live PostgreSQL 16.14 and MySQL
8.0.46:
- **Fix reverted** (`sql-driver.ts` at `62b90d74`, worktree only,
restore by trap with a hash check): `13 failed | 20 passed`.
- All 8 MySQL tests are red: `expected +0 to deeply equal {...}`, and
`expected [ +0 ] to have a length of 3 but got 1`.
  - 3 forced-cell path tests are red.
- The SQLite and PostgreSQL behaviour tests stay green (the control).
Their path checks are red only because the getter does not exist before
the fix.
- **Fix in place** (HEAD `f42d0354c3`): `33 passed`.

**Sign-up door pin: not added, declared.** No live-dialect harness runs
the real auth stack against a datasource URL. The plugin-auth
real-engine harness (`signup-existing-address-refusal.test.ts` and its
siblings) hard-codes better-sqlite3. A MySQL door pin there would need a
per-file database isolation helper in plugin-auth and a new CI step in
the live job. Without that step, the pin is a named skip that never runs
in CI. The door is measured above instead. The options are in the report
for the PM.

## Verification (HEAD `ee7c024b92`, after merging `origin/main` with
objectstack-ai#21225 in it)

- `pnpm --filter @objectstack/driver-sql exec vitest run --maxWorkers=2`
with `OS_TEST_POSTGRES_URL` and `OS_TEST_MYSQL_URL` (PG 16.14, MySQL
8.0.46), `OS_EXPECT_LIVE_DIALECT_MATRIX=1`, `TZ=America/New_York`: **223
files passed, 5369 passed, 1 skipped**. The skip is pre-existing, in
`schema-drift.base-type-mismatch.test.ts`.
- `pnpm --filter @objectstack/driver-sqlite-wasm test` (36 / 675 passed)
and `pnpm --filter @objectstack/driver-turso test` (86 files, 2313
passed, 33 skipped): exit 0.
- `typecheck` for driver-sql, driver-sqlite-wasm and driver-turso: exit
0. `tsc --listFiles` includes the new test file.
- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` derived 63 commands at `ee7c024b92`, and all 63 exited 0.
`--ran` reconciliation: `63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN`,
all with recorded exit codes. This includes `check:tenant-chokepoint`
("every read builder routes through applyTenantScope()").
- `pnpm check:driver-conformance`: before the first edit (`62b90d74`)
`50 covered cell(s), 0 in the DEBT ledger, 0 exempt`, dialect axis `8
conformance suite(s) ... 0 in the DIALECT ledger`; after the last commit
(`ee7c024b92`), identical.
- **Lint, a declared narrowing.** `eslint --no-inline-config --format
json` was run on the two touched TypeScript files.
- Population: eslint's own `--print-config` resolves rules for both (6
and 5 rules; neither file is ignored).
  - Count: 2 files, 0 errors, 0 warnings.
- Invariance: `eslint.config.mjs` enables no type-aware linting
(`parserOptions.project` and `projectService` are null for both files),
so this diff cannot move a verdict on an untouched file.
  - The repo-wide `pnpm lint` is left to CI.

## Acceptance notes

- `sql-driver-21163-autonumber-prefix-like-escape.test.ts`'s header says
its cases read the stored row "Not from `create`'s return value: on
MySQL that is not the row". After this change that sentence is stale. It
is a test comment, not published; it is left for whoever next edits that
file.
- In the same MySQL boot, service-package's raw `CREATE TABLE IF NOT
EXISTS sys_packages (... created_at TEXT DEFAULT CURRENT_TIMESTAMP ...)`
is refused with `ER_INVALID_DEFAULT` (`Invalid default value for
'created_at'`), and later `SELECT * FROM sys_packages` reads answer
`ER_NO_SUCH_TABLE`. No door was measured for it, so it is noted here and
not filed.
- The `sys_activity` boot failure is reported to the PM with a measured
door, for the seat to file. It is not touched here.

---

_Generated by [Claude
Code](https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[security] driver upsert: a tenant-scoped upsert keyed on a globally-unique business column can merge into, and re-parent, another tenant's row

2 participants