Repository navigation
[security] driver upsert: a tenant-scoped upsert keyed on a globally-unique business column can merge into, and re-parent, another tenant's row #21185
Description
Activity
objectstack-fleet commented
on Oct 1, 2026 ContributorAuthorMore actionsTriage: first grade —
bug·security·priority:p1·domain:engine·area:access·pm:blocked. Ruling: a conflict that resolves to another tenant's row is refused, and the tenant column is insert-onlyTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-01T14:51Z. ⛔ Not a claim, ⛔ not a dispatch. ⛔ Classes and positions only (detail withheld pending the maintainer).Blocked-by: #21166
Why p1. A tenant-isolation boundary does not hold on the published driver contract: a tenant-scoped upsert on a globally unique business key can merge into and re-parent another tenant's row. No in-repo producer reaches it today, by the seat's census, and the PostgreSQL and remote faces are not measured.
- Raise rule: if the claim finds an in-repo producer that reaches it, or measures it on the remote face that hosted tenants run, it raises the card to p0 and says so here.
The ruling (triage's, the safe side; overturnable by the maintainer, who is this card's first reader):
- Both halves, not either. Making the tenant column insert-only alone would still let the call overwrite another tenant's other columns, which is a cross-tenant write. So the conflict that resolves to a row of another tenant is refused with the platform's error shape, and nothing is written.
- The tenant column is also insert-only in the merge set, read from the one list (
insertOnlyUpsertColumns) on every face. ⛔ No second list.
Routing.
driver-sql, anddriver-tursothrough the same list, aredomain:engine.Why blocked. PR #21184 (#21166, in flight; read at this write) edits that same list. Fold: #21166's holder may fold this card into that PR, and says so on this card.
Pins: SQLite, PostgreSQL and the remote face each refuse the cross-tenant conflict with nothing written. A same-tenant upsert on the same key merges as before (the control).
Generated by Claude Code
- addedarea:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingSomething isn't workingpriority:p1High: required for production / M2High: required for production / M2and removed
on Oct 1, 2026 objectstack-fleet commented
on Oct 1, 2026 ContributorAuthorMore actionsUnlocked:
pm:blocked→pm:queue— #21166 closed as completeddomain:engine#2(seat post #20966) ·session_01Ujdtvqs7ree7WyQmEDwEnG· 2026-10-01T15:16Z. ⛔ Not a claim. ⛔ Classes and positions only.-
The condition: triage's transition 5933970973 blocked this card on [finding] driver-turso remote: upsert keyed on a business column replaces the existing row's primary key (the #8622 re-key) — RemoteTransport's merge set keeps
id, which the local face declares insert-only #21166, because PR fix(driver-turso,driver-sql): a business-key upsert keeps the stored id on the remote face, and both faces answer the stored row (#21166) #21184 edited the same insert-only list. [finding] driver-turso remote: upsert keyed on a business column replaces the existing row's primary key (the #8622 re-key) — RemoteTransport's merge set keepsid, which the local face declares insert-only #21166 is closed: PR fix(driver-turso,driver-sql): a business-key upsert keeps the stored id on the remote face, and both faces answer the stored row (#21166) #21184 merged asebdb6f2ac. Its remote face now readsSqlDriver.insertOnlyUpsertColumns, so the list this card extends is the one list on both faces. -
The double check: the newest transition is 5933970973, and no PR has merged on this card. The offered fold was not taken; PR fix(driver-turso,driver-sql): a business-key upsert keeps the stored id on the remote face, and both faces answer the stored row (#21166) #21184 landed on its reviewed scope.
-
Next claim: under the maintainer's current orders, release preparation with p0 and p1 only, and one subagent at a time once the in-flight work drains (5934179218, 5933047746), this card is first in this seat's line, ahead of [finding] driver-turso remote: RemoteTransport.buildWhereSQL has no JSON-column gate —
$containsmatches a substring instead of a member,$ninfails open, and the refused families compile over the serialized array (the orphaned #20987 remote item) #21178. That is security before non-security at equal priority. The claim executes triage's ruling, both halves:- the cross-tenant conflict is refused, with nothing written;
- the tenant column is insert-only, from the one list.
It also runs the p0 raise rule.
-
Public text on this card and its PR stays at classes and positions. The dispatch order requires it of the dev's report and the PR body.
Generated by Claude Code
-
objectstack-fleet commented
on Oct 1, 2026 ContributorAuthorMore actions决策卡:租户作用域 upsert 的冲突落到他租户的行时,平台怎么答
domain:engine#1(座位贴 #6367)·session_017xfMoEjKUuSh2xYB8sCozp· 2026-10-01T15:36Z。⛔ 不是认领。⛔ 只写类别与位置,复现细节不公开。出处: 维护者在本席会话里对「按分诊方向派发吗」的选择,原话:「先不派,进决策箱」。本卡因此由
pm:queue转needs-user-decision,退出活动队列。一句话问题
一个只属于甲组织的调用方,用一个全平台唯一的业务字段做 upsert 时,冲突可能落在乙组织的那一行上:那一行被合并改写、归属改成甲,没有任何报错。
Governing text
- ADR-0131 D8(已接受):「threads the same value to Layer 0 (
computeTenantLayer0Filter) and to every driver」——租户谓词应到达 driver 的每一扇门;upsert 的冲突合并路径是这句话没有兑现的一处。 - ADR-0074 Decision §2(已接受):
created_at是 insert-only,upsert 的冲突合并排除它——「哪些列 upsert 不许改」已有单一列表的先例(insertOnlyUpsertColumns)。 - AGENTS.md「Absence must be loud」:「Prefer failing to falling back」。
- 协议声明:不改
packages/spec;改的是已发布 driver 契约IDataDriver.upsert的行为(新增一种拒绝)⇒Clause-②: no (narrowing),driver-sql/driver-tursominor BREAKING。
前提(带 re-check 命令)
- P1 租户列不在 insert-only 列表里。
git show origin/main:packages/drivers/driver-sql/src/sql-driver.ts | grep -n -A6 "protected insertOnlyUpsertColumns":本席读数(origin/mainebdb6f2ac)集合只有created_at、id与 autonumber 列;同一命令命中函数名本身即阳性对照。 - P2 remote 面读同一列表。
git grep -n "insertOnlyUpsertColumns" origin/main -- packages/drivers/driver-turso/src/turso-driver.ts:本席读数命中(PR fix(driver-turso,driver-sql): a business-key upsert keeps the stored id on the remote face, and both faces answer the stored row (#21166) #21184 之后)。 - P3 仓内今天没有生产者用业务键做冲突键 —— 未验证,来源是 seat 2 在卡正文里的普查;PostgreSQL 与 remote 面也未实测。
选项 × 真实代价
选项 做什么 客户看得见的后果 A 两半都做(分诊方向) 冲突落到他组织的行 ⇒ 整笔拒绝、什么都不写(平台错误信封);同时租户列进 insert-only 单一列表 跨组织 upsert 得到一个响亮的拒绝;同组织 upsert 照旧合并 B 只做 insert-only 租户列不再被改写 行的归属保住了,但乙组织那一行的其它列仍被甲覆盖 —— 跨组织写仍在 C 冲突目标自动收窄到本组织 把租户列并进冲突键 全局唯一列上落成数据库唯一约束错误(不是平台信封),三种方言各答各的 D 不改代码,只写文档 文档要求冲突键含租户列 漏洞照旧可达,只是有了一句告示 业务含义直译
- A = 柜员发现这个账号属于别家客户,当场拒办,什么都不动。
- B = 不改户主,但把别家客户的余额改了。
- C = 把问题推给数据库,客户看到的是一条底层报错。
- D = 在柜台贴一张「请勿办理别家业务」的告示。
四轴(业务立场)
- 项目长远合理性: A 让「租户谓词到达每一扇门」(ADR-0131 D8)在 upsert 上也成立,并且沿用 ADR-0074 的单一 insert-only 列表,不增第二份清单;B/D 留下一扇不受谓词约束的门,C 把语义交给各方言的约束错误。
- 实际业务拉动: 今天仓内没有已知生产者走到这条路(P3,未验证);暴露面是已发布的 driver 契约,任何用业务键 upsert 的连接器、插件或宿主都会撞上。安全类不按拉动打折。
- 防 AI 犯错: 一个让 AI 写的连接器「用业务键 upsert」的写法,在 A 下得到响亮拒绝;在 B/D 下静默改写别家数据,出错时没人看到 —— 恰是最坏的那一面。
- 创业阶段不扩散: A 不新增能力、不新增键,只补一个拒绝和一行列表;C 引入一种新的冲突键语义,反而是扩散。
推荐
- 推荐 A。 终态句:两年后,平台的每个 driver 写门都受同一个租户谓词约束,upsert 与 insert/update 没有例外;多租户 SaaS 的通行做法(如 Salesforce 的外部 ID upsert 只在本组织数据内匹配)同样不存在跨组织合并。
- 只看①选 A;②③④ 是否翻转:否。
- 回退: 若某方言无法在一条语句里原子判定「冲突行属于他组织」,该方言在同一事务里先做一次不受租户谓词约束的按冲突键查询,命中他组织即拒绝;仍属 A,不是 B。
- 置信缺口: PostgreSQL 与 remote 面未实测;各方言判定是否无竞态未实测;拒绝用哪个已登记的错误码(
PERMISSION_DENIED一类,或冲突类)由 dev 在现有ERROR_CODE_LEDGER里取,新码即Clause-②: yes,需另报。
裁后执行
裁 A ⇒ 本席同笔转
pm:queue并认领派发一单(mode:subagent):SQLite、PostgreSQL、remote 三面各 pin「跨组织冲突被拒且零写入」,同组织同键合并作对照;租户列进insertOnlyUpsertColumns;公开文字(PR 正文、changeset、报告)只写类别与位置;分诊的 p0 提升规则照跑(找到仓内生产者或在 remote 面实测命中 ⇒ 升 p0 并在本卡说明)。os-decision-facets
- ① 项目长远合理性:A 缩小特例 —— upsert 不再是租户谓词之外的唯一一扇门,insert-only 仍是一份列表。
- ② 实际业务拉动:仓内今天零已知生产者(未验证),暴露在已发布 driver 契约上;安全边界不按拉动降级。
- ③ 防 AI 犯错:响亮拒绝优于静默改写他组织数据;B/D 的失败方向是静默泄露。
- ④ 创业阶段不扩散:A 零新键、零新能力;C 新增一种冲突键语义,是扩散。
Prior rulings read: upsert,tenant,cross-tenant,insert-only → 45 hits; ADR-0131 D8, ADR-0074 Decision §2; thread: none; repo: objectstack-ai/objectstack
推荐:A。只看①选 A;②③④ 是否翻转:否。
置信缺口:PostgreSQL 与 remote 面未实测;各方言判定的竞态未实测;拒绝的错误码未定(新码即Clause-②: yes)。
Generated by Claude Code
- ADR-0131 D8(已接受):「threads the same value to Layer 0 (
objectstack-fleet commented
on Oct 1, 2026 ContributorAuthorMore actionsRuling: batch #263 item 1 · letter A, with refinements 1 / 2 / 3 · maintainer 「21185 同意 1/2/3」 2026-10-01T15:39Z
Director seat, summon #32,
session_016tKoy8NJa35Yih1FdzrVmn. Written asobjectstack-fleet[bot]through the relay. ⛔ Classes and positions only: the reproduction stays with the seats.- The discussion. The maintainer opened this card with the director in the live director chat. The director agreed with triage's direction (5933970973, both halves) and proposed three refinements. The maintainer answered 「21185 同意 1/2/3」.
- The freshness gate.
domain:engineseat 1's decision request 5934820328 (15:36Z) was read before this record. It followed the maintainer's earlier word in that seat's session 「先不派,进决策箱」. Its option A is triage's both-halves direction, and its confidence gaps (the error code, and how each dialect decides atomically) are exactly what refinements 2 and 3 settle. So this record rules A on that table, refined.
The ruling
A: a conflict that lands on another organization's row is refused with nothing written, AND the tenant column is insert-only, from the one list
insertOnlyUpsertColumns. Refined as follows.- The predicate is the landed row's organization, for ANY conflict target, the primary key included.
- Read from source at objectstack
main0b12b9ea8f(packages/drivers/driver-sql/src/sql-driver.ts,upsert); not measured. - The default conflict target is
['id'], the tenant column sits in the merge set, and a tenant-scoped call is stamped with its caller's organization on entry. So an upsert carrying another organization'sidmerges into and re-parents that row, on the same mechanism as the business-key case. - The business-key case is narrower than the body suggests: under ADR-0120 D1 a field's
unique: trueis per-organization on a tenanted object. Only an explicitunique: 'global'(or a declared index's baretrue, until protocol 18) is installation-wide. The primary key always is. - So the pins cover the
['id']target as well as aunique: 'global'business column.
- Read from source at objectstack
- The refusal answers
UNIQUE_VIOLATION, the registered code, exactly ascreate()answers the same collision.- From the caller's organization the conflicting row does not exist (it cannot read it), so this upsert IS an insert, and that insert collides on an installation-wide key.
- The same answer adds no cross-organization information; ADR-0120 records that oracle as the accepted cost of a
'global'key. - ⛔ No new code, and ⛔ no message, field or detail that says the row belongs to another organization or names one. A dedicated answer would be a sharper oracle than the one
create()already is. - Reference: PostgreSQL's own row-level security makes
INSERT … ON CONFLICT DO UPDATEraise an error, rather than skip or write, when the conflicting row fails the UPDATE policy.
- The mechanism: one predicate inside the merge statement, and no new machinery.
- SQLite, PostgreSQL and the remote face (libsql):
ON CONFLICT (…) DO UPDATE SET … WHEREthe stored row's tenant columnIS NOT DISTINCT FROMthe written row's (SQLite:IS).- Another organization's row is left untouched, in one atomic statement, with no added transaction or round trip.
- The tenant-scoped read-back by conflict-key values that already follows the statement then finds no row, and that is when the call throws
UNIQUE_VIOLATION. - On the remote face, whose read-back today carries no tenant scope, the read-back is scoped too, or the statement's
rowsAffecteddecides. The dev states which in the PR.
- MySQL (
ON DUPLICATE KEY UPDATEhas noWHERE): reuse 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. The statement andassertMergeLandedOnSuppliedIdentityrun as one transaction; the check reads the row under the written tenant, and its failure rolls the write back. This is owed whenever the call is tenant-scoped on a tenanted object, not only when a rival UNIQUE key exists. - Half 2 (the tenant column insert-only) is a no-op for a tenant-scoped call once the predicate holds. Its job is the call with no tenant context: an upsert never re-parents a row.
update()is the deliberate path, which is the upsert 每次「合并到既有行」都烧一个自增号,并覆写该行已有的业务号(健康计数器上实测,与陈旧无关) #7011 / drivers(sql): an upsert that merges on a non-PK conflict key silently REWRITES the existing row's primary key — measured on SQLite and MySQL alike #8622 argument forauto_numberandid.
- SQLite, PostgreSQL and the remote face (libsql):
Not taken:
- B: insert-only alone still overwrites the other organization's columns.
- C: folding the tenant column into the conflict key gives dialect errors outside the platform envelope.
- D: documentation only.
Governing text and prior rulings read
- ADR-0131 D8 (the tenant predicate reaches every driver door).
- ADR-0074 Decision §2 (the single insert-only list).
- ADR-0120 D1 (the unique-scope vocabulary, and the oracle note on
'global'). - AGENTS.md 「Prefer failing to falling back」.
- Thread: triage 5933970973; unlock 5934416748; seat 1's request 5934820328 (whose Governing-text and premise lines this record adopts).
四棱(本裁决新记录)
- ① 长远:upsert 不再是租户谓词之外的唯一一扇门(ADR-0131 D8);判据落在「冲突行属于谁」,对任何冲突目标一样,不为业务键另立特例;insert-only 仍是一份列表。
- ② 拉动:仓内零已知生产者(seat 2 普查,未复验),暴露在已发布 driver 契约上;按主键
id的这条路同样可达。安全边界不按拉动降级。 - ③ 防 AI:AI 写的连接器拿业务键或外泄的
id去 upsert,得到响亮的UNIQUE_VIOLATION,与create()同一答案;不新增探测口,也不再静默改写别家数据。 - ④ 不扩散:零新键、零新错误码、零新机制(语句内判据,MySQL 复用 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)。
- 只看①选 A;②③④ 是否翻转:否。
Execution parameters (ruled here; no further decision card)
needs-user-decision→pm:queuein this act, indomain:engine, level M,mode:subagent.- Seat 1 offered to claim on ruling A (5934820328), and seat 2 listed the card first in its line (5934416748). The claim protocol decides: the first
Claim:wins and the other yields.
- Seat 1 offered to claim on ruling A (5934820328), and seat 2 listed the card first in its line (5934416748). The claim protocol decides: the first
Clause-②: no (narrowing). It becomesyes (narrowing)if the same PR corrects anIDataDriver.upsertdocblock inpackages/spec/src/contracts/that states merge semantics this ruling makes false; that is the security(plugin-security): a non-system caller who resolves no permission set is admitted to every object and read with no row scope; an empty set list grants by absence instead of answering the deny baseline #21079 precedent, and the docblock moves in the same PR.- The changeset: BREAKING for
driver-sqlanddriver-turso. FROM → TO: an upsert whose conflict lands on another organization's row is refused withUNIQUE_VIOLATIONand writes nothing, and an upsert never changes a row's organization; to move a row between organizations, useupdate(). - Pins:
- SQLite, PostgreSQL, MySQL (CI's live leg) and the remote face. On each, a cross-organization conflict on the
['id']target and on aunique: 'global'column is refused withUNIQUE_VIOLATION, and the other organization's row reads back byte-identical. - A same-organization upsert on the same key merges as before (the control).
- A call with no tenant context does not change the row's organization (half 2).
- The error body names no organization.
- SQLite, PostgreSQL, MySQL (CI's live leg) and the remote face. On each, a cross-organization conflict on the
- Triage's p0 raise rule stands: an in-repo producer that reaches this, or a measured hit on the remote face hosted tenants run, raises the card to p0, stated here.
- Noted, not in scope:
driver-memoryrefuses tenant-scoped calls outright, so it is unaffected.driver-mongodb's own docblock says it ignoresDriverOptions.tenantId, so it has no isolation at all. That is a different question, ⛔ not widened into this card.
Generated by Claude Code
2 remaining items
objectstack-fleet commented
on Oct 1, 2026 ContributorAuthorMore actionsClaim: PM loop round 1
Session:session_017xfMoEjKUuSh2xYB8sCozp
Account:huangyiirene(the seat's linked user asGET /useranswers it; the card's assignee)
Branch:claude/issue-21185-upsert-cross-org-refusal
Worktree:objectstack-issue-21185
Domain:domain:engine
Seat:domain:engine#1(seat post #6367)
Ruling-ref: 5934879010
File surface:packages/drivers/driver-sql/src/sql-driver.ts(upsert,insertOnlyUpsertColumns, the MySQLassertMergeLandedOnSuppliedIdentitypath),packages/drivers/driver-turso/src/turso-driver.tsandremote-transport.ts(theupsertregions only), pins on SQLite, PostgreSQL, MySQL (CI's live leg) and the remote face, and changesets fordriver-sqlanddriver-turso. ⛔ Notdriver-memoryordriver-mongodb(the ruling's "not in scope"). ⛔ Nopackages/specedit without a report first (the ruling'sClause-②parameter).
Container & model:M,mode:subagent,model: opus(dispatch-gates --tierat0d421041: no path-derived mandate, floor sonnet · default opus · ceiling fable)
Clause-②: no (narrowing)
Thread-read: 5934879010
Serial constraints cleared: read at 2026-10-01T16:38Z againstorigin/main0d421041. In flight on these files, all this seat's and region-disjoint: #21178 (remote-transport.tsbuildWhereSQL, plus a new resolver setter wired inturso-driver.ts's constructor) and #21163 (PR #21206,sql-driver.ts's autonumber sequence scan). Whichever lands second mergesmain. Seat 2's in-flight branches touch neither package. The ruling 5934879010 was retrieved in this act; the dispatch quotes it verbatim, and the ruling's p0 raise rule stands.
Generated by Claude Code
objectstack-fleet commented
on Oct 1, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 21185,
"status": "blocked",
"branch": "claude/issue-21185-upsert-cross-org-refusal",
"pr": "#21225",
"session": "session_017xfMoEjKUuSh2xYB8sCozp — the dispatching PM's (subagent run)",
"premise_still_valid": true,
"summary": "P0 RAISE RULE — MEASURED HIT ON THE REMOTE FACE: on main (base 5e5ce48), through the libSQL stub that runs RemoteTransport's own upsert SQL on better-sqlite3, a tenant-scoped upsert whose conflict landed on another organization's row (both the ['id'] target and a unique:'global' column) was NOT refused: it returned with no error, and the merge statement carried no organization predicate. Turso pins red on the remote face: 5 of 7 face pins, plus the parity pin. With no tenant context, a payload's organization re-parented the other organization's row (measured diff: organization_id other -> caller, on both faces). The remote insert leg wrote the row with NO organization ('expected null to be org_os21185_caller'), because that face never stamped options.tenantId. Not measured against a hosted Turso endpoint. In-repo producer: none reaches the fenced path (census at e746da2). The lifecycle archiver calls cold.upsert(object,row,['id']) without a tenant context, so it gets half 2 only. The sandbox body runner's ql.upsert falls back to insert. The knowledge adapters' upsert is another interface. IMPLEMENTED (ruling A, refinements 1/2/3): a tenant-scoped upsert is fenced to the organization the row is WRITTEN under, for any conflict target, the primary key included. SQLite and PostgreSQL carry the predicate in the merge statement (DO UPDATE ... WHERE stored IS / IS NOT DISTINCT FROM excluded). The read-back now reads under the written organization exactly, and a miss throws UNIQUE_VIOLATION/409. MySQL runs the statement plus a landed-row read under the written organization in one transaction whenever the call is fenced (a savepoint inside a caller's transaction). The read carries #8807's verdict, and a rival-key probe keeps #8807's own refusal for same-organization rival landings. The remote face carries the same predicate, stamps the caller's organization on entry, and uses a fenced read-back: the read-back was chosen over rowsAffected because DO NOTHING affects 0 rows on a same-organization conflict. Half 2: the tenant column joined insertOnlyUpsertColumns, the one list both faces read. The refusal is code UNIQUE_VIOLATION, status 409, with the engine's duplicate-record sentence. It names no organization or row value and carries no cause. BLOCKED ON THE SEAT'S WORD for two packages/spec edits that the dispatch forbids without a report. Both gates are red on e746da2 for exactly these, and nothing else is red locally. (1) The error-code provenance row: 'UNIQUE_VIOLATION' under '@objectstack/driver-sql' in packages/spec/src/api/error-code-ledger.zod.ts. It is provenance only: the ErrorCode union is byte-unchanged, and api-surface records ERROR_CODE_LEDGER by name only. (2) The ADR-0087 semantic ledger entry entries/semantic/17.driver-upsert-cross-organization-conflict-refused.ts, plus gen:migration-registry / gen:spec-changes / gen:upgrade-guide. The changeset already names 'registered driver-upsert-cross-organization-conflict-refused'; the ruled FROM -> TO is a prescription, so the gate refuses not-required(no-migration-prescription). It also needs '@objectstack/spec': patch in the changeset front matter. Neither edit moves Clause-② (precedent: the changeset for driver-sql-calendar-day-methods-removed is registered + 'Clause-②: no (narrowing)' + spec patch). H4 holds: the IDataDriver.upsert docblock is one line and states no merge semantics, so Clause-② stays no (narrowing). DEVIATIONS: (a) MySQL reuses #8807's MECHANISM (transaction + post-statement read + rollback) through a new read keyed on the conflict-key values, assertMergeLandedInWrittenOrganization. The literal assertMergeLandedOnSuppliedIdentity reads by the supplied id, which falsely refuses the same-organization business-key control, since id is insert-only. (b) A savepoint inside a caller's transaction, so that 'its failure rolls the write back' holds there too. #8807 alone does not do this; its own path is unchanged. (c) The ruled premise 'the tenant-scoped read-back then finds no row' is false for a row with NO organization, because applyTenantScope admits NULL rows. The fenced read-back adds equality on the tenant column, so such a conflict is refused (pinned) instead of being a silent no-write. (d) The remote face now stamps injectTenantOnInsert on upsert. Without it the 'written organization' is empty and the same-organization control would be refused. (e) #8807's identity read no longer scopes to the payload's tenant on a call without a tenant context. Measured on MariaDB 10.11: half 2 otherwise produced a false #8807 refusal. (f) The MySQL leg was measured locally on a throwaway MariaDB 10.11 (binaries from apt-get download + dpkg -x, no system install), not on MySQL 8.0. MySQL 8.0 is NOT MEASURED locally; CI's Temporal Conformance job runs it. (g) Throwaway PG 16 and MariaDB ran under /tmp/os21185-* because the scratchpad's parents are not traversable by the non-root PG user. Both servers were stopped by recorded PID and both dirs removed. PR BODY ADDITION REQUESTED (the seat writes it; dev wrote the body once): add to Acceptance notes the out_of_scope_findings[1] observation about SqlDriver.create on the MySQL family. files_changed: .changeset/21185-upsert-cross-org-refusal.md; packages/drivers/driver-sql/src/sql-driver.ts; packages/drivers/driver-sql/src/sql-driver-upsert-cross-organization-refusal.test.ts; packages/drivers/driver-turso/src/remote-transport.ts; packages/drivers/driver-turso/src/turso-driver.ts; packages/drivers/driver-turso/src/turso-local-remote-upsert-cross-organization-parity.test.ts. line_budget: not applicable (no skills/** or governed surface). Hypotheses: H1 confirmed: at base 5e5ce48 the list was created_at, id and autonumber, with no tenant column. H2 confirmed: assertMergeLandedOnSuppliedIdentity ran only on the MySQL pre-flight verdict identity-at-risk (PK target plus a rival UNIQUE key). H3 confirmed, plus the remote face did not stamp the tenant. H5: today a read-back miss raised nothing; it returned the payload (local formatOutput(...) || toUpsert, remote rows[0] || toUpsert). The SQL drivers' create() throws the raw dialect error; the engine wraps it as DUPLICATE_RECORD and REST answers 409 UNIQUE_VIOLATION. The driver-level construction here follows driver-memory's {code:'UNIQUE_VIOLATION', status:409}, which is why the provenance row is owed.",
"tests": "All on HEAD e746da2 (git rev-parse --short HEAD), after merging origin/main 097ef80 (which includes #21178's 862f12c). PINS: driver-sql src/sql-driver-upsert-cross-organization-refusal.test.ts (dialect cells sqlite/pg/mysql) and driver-turso src/turso-local-remote-upsert-cross-organization-parity.test.ts (local + remote faces + parity). Live run: OS_TEST_POSTGRES_URL (throwaway PG 16) + OS_TEST_MYSQL_URL (throwaway MariaDB 10.11), vitest run of the new file + sql-driver-upsert-conflict-target-dialects.test.ts -> 'Tests 92 passed (92)', VERDICT command-exit 0. Full suites, SQLite cell: driver-sql 'Test Files 209 passed | 11 skipped (220) / Tests 3491 passed | 192 skipped' exit 0; driver-turso 'Test Files 85 passed (85) / Tests 2301 passed | 33 skipped' exit 0; driver-sqlite-wasm (inherits the door) 'Tests 675 passed (675)' exit 0. Typecheck: driver-sql and driver-turso exit 0 (after a rebuild of the turso dependency closure). REVERSE VERIFICATION, committed fix first: main's sql-driver.ts restored with git restore --source=5e5ce48c under trap 'git checkout HEAD -- path'. Mutation landed: on-disk blob a7a0da5e == base blob, grep -c upsertTenantGuard = 0. Result on sqlite+pg+mariadb: 'Tests 25 failed | 14 passed (39)'. Red: every refusal pin and half 2 on each cell, and the fresh-id business-key pin on MariaDB only (SQLite/PG already answer it natively). Restored blob ed1603d2 == HEAD blob, git diff HEAD empty. Turso file on main (driver-sql dist = main): 'Tests 10 failed | 5 passed (15)', including remote 'expected null to be org_os21185_caller' (insert leg wrote no organization). Earlier red run of the driver-sql file on main: sqlite+pg 14 failed / 10 passed / 1 skipped (MySQL cell declared un-run). GATES on e746da2: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran -> 'Run reconciliation — 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN' exit 0. All exit 0 except: node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 1 ('claims registered driver-upsert-cross-organization-conflict-refused but 1 of those id(s) do not exist' = owed item 2). Notable greens: pnpm check:tenant-chokepoint :: exit 0 ('22 getBuilder() binding(s) across 3 file(s); every read builder routes through applyTenantScope()'); pnpm check:nul-bytes :: exit 0; pnpm check:dual-build-cjs-loads and pnpm check:lean-entry-closure :: exit 0 (exit 3 PREREQUISITE NOT MET on the pre-merge tree, measured after the closure rebuild). Outside the derivation: pnpm --filter @objectstack/spec check:error-code-provenance :: exit 1 ('@objectstack/driver-sql stamps UNIQUE_VIOLATION (constdef) at sql-driver.ts:2361 — not listed under its own owner key' = owed item 1); pnpm check:error-code-casing :: exit 0. check:driver-conformance BEFORE first edit (base 5e5ce48) and AFTER last commit (e746da2): both 'OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt' (ledger unchanged). LINT, narrowed: eslint --no-inline-config --format json over the 5 changed .ts files -> files 5, errors 0, warnings 0. Population is the changed TypeScript set. eslint.config.mjs states it enables no type-aware linting (no parserOptions.project), so this diff cannot move the verdict on any untouched file. The repo-wide pnpm lint is CI's. NOT MEASURED: MySQL 8.0 live cell (no server in the container; CI's Temporal Conformance (live PG + MySQL) job runs the whole driver-sql suite with OS_EXPECT_LIVE_DIALECT_MATRIX=1). CI convergence is the PM's.",
"mcp_calls": "0 — no MCP GitHub tool was called",
"api_writes": "3 — each one relay dispatch (POST /repos/objectstack-ai/objectstack/dispatches) executed as objectstack-fleet[bot]: POST /repos/objectstack-ai/objectstack/pulls (draft #21225, body read back 9412/9412 bytes identical); POST /repos//issues/21225/assignees (huangyiirene, read back MATCHES); POST /repos//issues/21185/comments (this os-dev-report). Reads were REST GETs only. No label written: the dispatch named none, and skip-changeset does not apply since a changeset ships. git push is not a REST write.",
"open_questions": [
{
"question": "Authorize the two packages/spec edits that the ruled answer requires, so a patch round on PR #21225 can make it landable? (1) error-code-ledger.zod.ts: 'UNIQUE_VIOLATION' under '@objectstack/driver-sql', provenance only. (2) An ADR-0087 semantic entry 17.driver-upsert-cross-organization-conflict-refused plus its generated artifacts, and '@objectstack/spec': patch in the changeset.",
"options": [
"A — authorize both in this PR. The Clause-② line stays 'no (narrowing)', per the 20822 precedent: registered + no (narrowing) + spec patch.",
"B — land the semantic entry in its own PR first and switch this changeset to not-required (already-registered ...), the 19922 precedent. The provenance row still has to ride here.",
"C — answer the refusal without a driver-level stamp (re-issue a plain INSERT so the server raises its own unique violation). Rejected: the ruling says no new machinery, and the code would be the dialect's, not UNIQUE_VIOLATION."
],
"recommendation": "A, because: (real business need) the stamp is the ruled answer and the ledger row is what makes it visible; (long-term soundness) one PR keeps code, changeset and ledger together; (AI-proofing) the gates stay strict and nothing is waived; (no sprawl) no new code, key or gate, just one provenance row and one ledger entry."
}
],
"out_of_scope_findings": [
"class: b · reach: exception: security (could expose data across organizations if a remote database holds more than one) · The remote face applies no tenant scope on its other doors. The remote branches of TursoDriver find/findOne/count/update/delete/updateMany/deleteMany pass no options to RemoteTransport, whose update/delete compile WHERE "id" = ? only. The remote create does not stamp options.tenantId. Read at e746da2 (turso-driver.ts remote branches, remote-transport.ts update/delete). The distinct() docblock already records 'no remote read applies the tenant scope'. This PR fixes the upsert door only. Contract: ADR-0131 D8 'threads the same value ... to every driver'. Seam: spec:DriverOptions.tenantId → runtime:TursoDriver remote branches (find/update/delete/create) | renderer: consumer: none. Not measured end to end; whether hosted tenant databases hold several organizations decides real reach · dedupe words: remote face tenant scope, TursoDriver tenantId ignored, remote create organization stamp, libsql transport tenancy, RemoteTransport update where id",
"carrier: 承接者:无 · noted, not filed — SqlDriver.create on the MySQL family answers a value that is not the stored row: knex returning('*') is a no-op on mysql2. Measured on MariaDB 10.11 in this run: create() answered a value whose id was undefined. No public door was measured, so there is no reach. The seat is asked to add it to PR #21225 Acceptance notes.",
"carrier: 承接者:无 · noted, not filed (in PR #21225 Acceptance notes) — the ruled predicate compares against the WRITTEN organization. A tenant-scoped payload that names another organization (injectTenantOnInsert's documented admin authority) still merges into that organization's row, which update() under the caller's scope would not reach. Under the group posture an upsert onto another member organization's row is refused, where update()'s union scope reaches it. Ruled semantics, recorded for the maintainer."
]
}
Generated by Claude Code
objectstack-fleet commented
on Oct 1, 2026 ContributorAuthorMore actionsClaim: PM loop round 1 (amendment after the dev's report 5937894588: the grade is raised to p0 by the ruling's own rule, and the file surface gains two
packages/specedits; same session, branch and PR #21225)
Session:session_017xfMoEjKUuSh2xYB8sCozp
Account:huangyiirene(the seat's linked user asGET /useranswers it; the card's assignee)
Branch:claude/issue-21185-upsert-cross-org-refusal
Worktree:objectstack-issue-21185
Domain:domain:engine
Seat:domain:engine#1(seat post #6367)
Ruling-ref: 5934879010
File surface: as the first claim (5935961155), plus twopackages/specedits:packages/spec/src/api/error-code-ledger.zod.ts: one provenance row,UNIQUE_VIOLATIONunder@objectstack/driver-sql. It is a pure append in that package's own section, and theErrorCodeunion is unchanged.packages/spec/src/migrations/entries/semantic/17.driver-upsert-cross-organization-conflict-refused.ts, plus the artifactsgen:migration-registry,gen:spec-changesandgen:upgrade-guideregenerate, and'@objectstack/spec': patchin the changeset.
Container & model:M,mode:subagent,model: opus(unchanged)
Clause-②: no (narrowing)
Thread-read: 5937894588
Serial constraints cleared: read at 2026-10-01T18:32Z againstorigin/main. [finding] driver-turso remote: RemoteTransport.buildWhereSQL has no JSON-column gate —$containsmatches a substring instead of a member,$ninfails open, and the refused families compile over the serialized array (the orphaned #20987 remote item) #21178 (862f12c0b) and rest(import): a column for a formula field passes the dry run, then fails the row at commit with the driver's SQL error, where the create door answers 400 INVALID_FIELD #20701 (bbcd20c52) have landed. The branch mergedmainat097ef802. No open PR editserror-code-ledger.zod.ts'sdriver-sqlsection orentries/semantic/, per this seat's read of the open PR list at this act. [finding] driver-sql's JSON-column refusal is about 800 characters and is cut at the REST envelope's 500, so no caller reads the sentence saying the field and operator were withheld #21067 (PR fix(core): the JSON-column refusal reads true on every face and reaches a REST caller whole #21213) and [finding] SQLite: an autonumber format whose prefix contains_or%seeds its counter from 0 — scanMaxNumericTail escapes the prefix but Knex emits no ESCAPE clause, so the bootstrap and #5495 re-seed scans match nothing #21163 (PR fix(driver-sql): an autonumber prefix carrying_,%or\seeds its counter from the stored MAX on SQLite (#21163) #21206) are queued in other regions ofsql-driver.tsandcore.
p0 raise (the ruling's rule, measured)
The ruling 5934879010 says: 「an in-repo producer that reaches this, or a measured hit on the remote face hosted tenants run, raises the card to p0」. The dev measured the remote face on
mainthrough the libSQL stub that runsRemoteTransport's ownupsertSQL:- a tenant-scoped upsert whose conflict lands on another organization's row, on both the
['id']target and aunique: 'global'column, is not refused; - with no tenant context, the payload's organization re-parents the other organization's row;
- the remote insert leg writes a row with no organization.
It was not measured against a hosted endpoint, and no in-repo producer reaches the fenced path (census at
e746da26). This act raises the card topriority:p0. PR #21225 already carries the fix on every face.Seat answer to the dev's open question: A, open to the maintainer's veto
Both
packages/specedits ride PR #21225, andClause-②staysno (narrowing).- The provenance row is pre-approved by the
domain:speclane's standing commitment for cross-laneerror-code-ledger.zod.tsappends, and all three of its conditions hold:- the code's existence is ruled on this card (refinement 2:
UNIQUE_VIOLATION, no new code); - the at-tier contract review before enqueue still applies, because the diff now touches
packages/spec/src/**; - it is a pure append under the package's own section.
- the code's existence is ruled on this card (refinement 2:
- The ADR-0087 semantic entry follows this lane's precedent: PR 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 registered
driver-sql-calendar-day-methods-removedwithClause-②: no (narrowing)and a specpatch. The ruled FROM → TO is a prescription, so the disposition isregistered. - B splits one ruling across two PRs for no gain. C contradicts refinement 2 (the dialect's code instead of
UNIQUE_VIOLATION) and adds machinery the ruling forbids.
The dev's other outputs
- Deviations (a) to (g): each is recorded in the report, and each is for the at-tier review to judge.
- Out-of-scope finding 1, the remote face applying no tenant scope on its other doors, is filed as [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: a security-class exception, with the reach to be measured first.
- Finding 2,
SqlDriver.createon the MySQL family answering a value that is not the stored row, is under a separate read-only measurement by this seat. It goes into PR fix(driver-sql,driver-turso)!: refuse an upsert whose conflict lands on another organization's row (#21185) #21225's Acceptance notes at the next body edit. - Finding 3 is ruled semantics, recorded in the PR's Acceptance notes.
Generated by Claude Code
- addedpriority:p0Critical: blocker, must ship before MVPCritical: blocker, must ship before MVPand removedpriority:p1High: required for production / M2High: required for production / M2
on Oct 1, 2026 objectstack-fleet commented
on Oct 1, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 21185,
"status": "done",
"branch": "claude/issue-21185-upsert-cross-org-refusal",
"pr": "#21225",
"session": "session_017xfMoEjKUuSh2xYB8sCozp — the dispatching PM's (subagent run), patch round 1",
"premise_still_valid": true,
"summary": "P0 (unchanged from round 0, raised by the seat in claim amendment 5937974892): a measured hit on the remote face through the libSQL stub; no in-repo producer reaches the fenced path. PATCH ROUND 1 — the seat's answer A, executed on the same branch and PR, with no second claim. The newest Claim: 5937974892 names this branch. (1) packages/spec/src/api/error-code-ledger.zod.ts: ONE row, 'UNIQUE_VIOLATION', appended at the end of the '@objectstack/driver-sql' section with its wire-path comment (door none, provenance only). No other ledger line moved, and the ErrorCode union is byte-unchanged; UNIQUE_VIOLATION was already registered by @objectstack/types, @objectstack/plugin-security and @objectstack/driver-memory. (2) The ADR-0087 semantic entry, modelled on the lane precedent driver-sql-calendar-day-methods-removed. Regenerated with the repo's generators only: pnpm --filter @objectstack/spec build, then check:generated --fix, which proved exactly 1 of 15 stale (src/migrations/registry.ts) and ran gen:migration-registry. A second --fix pass found 'All 15 generated artifacts are up to date'. spec-changes.json and docs/protocol-upgrade-guide.md did not move, because step 18 is inert until the protocol major reaches 18; the precedent's id appears in neither file (0 hits each). (3) The changeset front matter gained '@objectstack/spec': patch, keeping 'Clause-②: no (narrowing)' and 'registered driver-upsert-cross-organization-conflict-refused'. MERGE: origin/main merged once with a merge, not a rebase: 35d01c9, carrying #21163's c6b6889 (the scanMaxNumericTail region of sql-driver.ts, disjoint from this change, merged clean) and #21067. The driver-sql and driver-turso suites were re-run on the merged head. DEVIATION, NEW AND MEASURED: the entry file is 18.driver-upsert-cross-organization-conflict-refused.ts, not the 17. prefix the dispatch named. The 17. in my round-0 report was my own guess. registry.ts records 'Protocol 18 step — accumulating, uncut. v17.0.0 was cut before these narrowings landed, so their migration prescriptions belong to the NEXT major'. The lane precedent is 18.driver-sql-calendar-day-methods-removed.ts, and current semantic entries split 77 at 17 and 257 at 18. The id is unchanged. Deviations (a) to (g) from round 0 stand as reported, for the at-tier review. CI interim reds the seat named: Lint & Repo Gates step 11 (check:migration-registry) was registry.ts not yet regenerated at interim head 82f8120; it is regenerated in 5682e8f and green locally. Check Changeset's ADR-0087 disposition red was the registered id not yet in registry.ts at 82f8120; locally on 35d01c9 check-adr-0087-registration --self-test and --base origin/main both exit 0. PR BODY EDITS REQUESTED (the seat writes them; dev wrote the body once): (i) the '⛔ Owed before this can land' section is now discharged by 82f8120 and 5682e8f; (ii) its item 2 names the file with a 17. prefix, and the landed file is 18.; (iii) the Tests line should name head 35d01c9; (iv) add round 0's out_of_scope_findings[1] (SqlDriver.create on the MySQL family) to Acceptance notes, which the seat already planned. files_changed this round: packages/spec/src/api/error-code-ledger.zod.ts (+21, one row and its comment); packages/spec/src/migrations/entries/semantic/18.driver-upsert-cross-organization-conflict-refused.ts (new); packages/spec/src/migrations/registry.ts (+51 in 5682e8f, generated; the merge brought main's own entry beside it); .changeset/21185-upsert-cross-org-refusal.md (+1 front-matter line). line_budget: not applicable. Upstream moved again after the merge (origin/main e47355b, #21218: the doc-authoring baseline shrink). dispatch-gates flags the tree as STALE for scripts/doc-authoring-prose-id.baseline.json only. This diff adds no runtime string carrying a tracker number. Not merged, per 'merge origin/main once'.",
"tests": "HEAD 35d01c9 (git rev-parse --short HEAD), pushed; origin/claude/issue-21185-upsert-cross-org-refusal = 35d01c9. Spec gates the seat named: node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 ('1 declared-breaking changeset(s), each carrying an ADR-0087 disposition ... registered driver-upsert-cross-organization-conflict-refused (new here: ...)'); pnpm --filter @objectstack/spec check:error-code-provenance :: exit 0 ('scanned 2650 files; 336 registered-code stamp site(s): 317 listed, 19 waived — OK'); pnpm --filter @objectstack/spec check:generated :: exit 0 ('All 15 generated artifacts are up to date'); pnpm check:error-code-casing :: exit 0. Spec package tests for the ledger and the migration registry: vitest --project local over src/api/error-code-ledger.test.ts, src/migrations/migrations.test.ts, src/migrations/retired-tables-not-floor-scoped.test.ts, src/migrations/spec-changes-surface-scope.test.ts, scripts/build-migration-registry-entry.test.ts and scripts/check-error-code-provenance.test.ts -> 'Test Files 6 passed (6) / Tests 210 passed (210)' exit 0. Driver suites on the merged head, SQLite cell: driver-sql 'Test Files 211 passed | 11 skipped (222) / Tests 3545 passed | 196 skipped' exit 0; driver-turso 'Test Files 86 passed (86) / Tests 2313 passed | 33 skipped' exit 0; driver-sqlite-wasm 'Tests 675 passed (675)' exit 0. driver-sql and driver-turso typecheck exit 0. Live cells on 35d01c9: throwaway PG 16 plus throwaway MariaDB 10.11 (for the MySQL family), the new pin file plus sql-driver-upsert-conflict-target-dialects.test.ts -> 'Tests 92 passed (92)' exit 0. Both servers were stopped by recorded PID and their dirs removed. MySQL 8.0 itself is NOT MEASURED locally; that is CI's Temporal Conformance (live PG + MySQL) job. GATES: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands re-derived on 35d01c9 -> 93 families (the spec paths added 30). All 93 were run, with exit codes recorded; --ran -> 'Run reconciliation — 93 derived, 93 run, 0 NOT-MEASURED, 0 UNRUN' exit 0. All 93 exit 0. Three first answered exit 3 (PREREQUISITE NOT MET on a fresh worktree) and were re-run after pnpm build (72 tasks, 71 turbo-cached) to exit 0: pnpm --filter @objectstack/lint run check:doc-formula-expressions, pnpm check:dual-build-cjs-loads and pnpm check:lean-entry-closure. Key families :: exit 0: check:migration-registry, check:spec-changes, check:upgrade-guide, check:api-surface, check:authorable-surface, check:error-code-provenance, check:error-code-casing, check-changeset-no-major --base, check-empty-changeset --base, check:driver-conformance ('OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt', unchanged), check:tenant-chokepoint, check:nul-bytes. LINT, narrowed: eslint --no-inline-config --format json over the 8 changed .ts files (5 from round 0, plus the ledger, the semantic entry and the generated registry) -> files 8, errors 0, warnings 0. eslint.config.mjs enables no type-aware linting, so untouched files cannot move. The repo-wide lint is CI's. CI: not awaited, per contract; the PM reads convergence.",
"mcp_calls": "0 — no MCP GitHub tool was called this round",
"api_writes": "1 this round — one relay dispatch (POST /repos/objectstack-ai/objectstack/dispatches) executed as objectstack-fleet[bot]: POST /repos//issues/21185/comments (this os-dev-report). Reads were REST GETs only (the issue comments, to verify the newest Claim:). No PR body PATCH, no label write, no assignee write this round. git push is not a REST write.",
"open_questions": [],
"out_of_scope_findings": [
"carrier: the seat (PR #21225 body, at its next edit) · noted, not filed — the PR body's owed-edits section and its 17. filename are stale after this round; the four edits are listed in summary."
]
}
Generated by Claude Code
objectstack-fleet commented
on Oct 1, 2026 ContributorAuthorMore actionsACCEPT — PR #21225 at head
35d01c9d7fedae909f93a171ac2c319ed17000a6domain:engine#1·session_017xfMoEjKUuSh2xYB8sCozp· read at 2026-10-01T19:37Z. Judged against GitHub.- Shape: draft, base
main. The first body line isFixes #21185, and there is no other closing keyword. No path is governed; 1,285 changed lines. The net diff is 9 files:sql-driver.ts'supsertfence, its refusal, andinsertOnlyUpsertColumns;- the remote face (
remote-transport.ts,turso-driver.ts); - two pin files (the
driver-sqldialect-cell matrix and thedriver-tursolocal/remote parity file); - the
UNIQUE_VIOLATIONprovenance row inerror-code-ledger.zod.ts; - the semantic entry
18.driver-upsert-cross-organization-conflict-refusedand its generatedregistry.tsregion; - the changeset:
driver-sqlanddriver-tursominor,specpatch, a!title,Clause-②: no (narrowing),adr-0087: registered driver-upsert-cross-organization-conflict-refused.
- The ruling (record 5934879010), refinement by refinement:
- The fence is the written row's organization for any conflict target. Pinned on the default target, an explicit
['id']and aunique: 'global'column, on SQLite, PostgreSQL and MySQL, and on bothTursoDriverfaces. - The refusal is
UNIQUE_VIOLATION/ 409 from one constructor that both faces throw. It carries no new code, names no organization, has nocause, and gives no value of the landed row. - On SQLite, PostgreSQL and the remote face, an in-statement
DO UPDATE … WHERENULL-safe predicate, then a read-back scoped exactly to the written organization. On MySQL, 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 transaction plus a read of the landed row, with a savepoint inside a caller's transaction. The tenant column is insert-only.
- The fence is the written row's organization for any conflict target. Pinned on the default target, an explicit
- Rounds:
- Round 0 (
e746da26) carried the fix and pins. - Round 1, on the seat's answer A (claim amendment 5937974892):
82f8120cadded the twopackages/specedits,5682e8feregeneratedregistry.ts, and35d01c9dmergedorigin/mainc6b68891([finding] SQLite: an autonumber format whose prefix contains_or%seeds its counter from 0 — scanMaxNumericTail escapes the prefix but Knex emits no ESCAPE clause, so the bootstrap and #5495 re-seed scans match nothing #21163 and [finding] driver-sql's JSON-column refusal is about 800 characters and is cut at the REST envelope's 500, so no caller reads the sentence saying the field and operator were withheld #21067, clean). The dev's report 5938821270: 93 gate families derived, 93 run, all exit 0;check:driver-conformanceunchanged (50 covered, 0 DEBT).
- Round 0 (
- Contract review: PASS at
CONTRACT_REVIEW_TIERon this head, record 5939108359. It judges round 0's deviations (a) to (g) and round 1's18.prefix right, and the remote-face organization stamp and the 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 scope adjustment inside the ruling. - CI on this head, read at the review (19:34Z):
- The seven required contexts are
success,Temporal Conformance (live PG + MySQL)included; that is the MySQL 8 cell the dev could not run locally. - Skips:
Build Docs,Console Pin Gate,Packed-tarball smoke (opt-in), and the edited-trigger re-runs ofAuto LabelandCheck PR Size. All five are roster entries incheck-expected-skips.mjs.
- The seven required contexts are
- PR body: the seat edited it once for round 1. It records the discharged spec section, the
18.filename, the Tests line naming35d01c9d, the 93-family gate line, and the 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 note. - Out-of-scope findings:
SqlDriver.createon the MySQL family answers the insert id: filed 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, now dispatched. It does not bear on this PR.- The remote face's other doors carry no tenant scope: filed [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,
pm:blockedon this card. It reuses this PR's remote organization stamp. sys_activityschema sync on MySQL: 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's claim carries it as an out-of-scope finding.- The reviewer's pre-existing observation, also at
main: on a rotation-managed object,upsert's read-back names the base table rather than the current shard. Dropped — not filed: no in-repo object configures rotation, and the reach is not measured.
Next:
pr_ready, thenautomerge_enable, as two relay acts.Fixescloses this card at merge. #21226 unblocks then, and #21227 mergesmainif it lands second.
Generated by Claude Code
- Shape: draft, base
objectstack-fleet commented
on Oct 1, 2026 ContributorAuthorMore actionsLanded: PR #21225 →
95e24b009onmain, verified at 2026-10-01T20:05Z.domain:engine#1·session_017xfMoEjKUuSh2xYB8sCozp.- The squash has one parent (
3ddd3d0c) and is an ancestor oforigin/main. - Present at the squash and absent at its parent:
upsertTenantGuardinpackages/drivers/driver-sql/src/sql-driver.ts(4, parent 0);upsertConflictRefusalinpackages/drivers/driver-turso/src/turso-driver.ts(1, parent 0);- the
[#21185]UNIQUE_VIOLATIONprovenance row inpackages/spec/src/api/error-code-ledger.zod.ts; - the semantic entry
18.driver-upsert-cross-organization-conflict-refused.ts; .changeset/21185-upsert-cross-org-refusal.md.
- Records it landed on: contract review PASS 5939108359 at
35d01c9d, and ACCEPT 5939123561 on this card. It executes the ruling record 5934879010 (refinements 1, 2 and 3). Fixes #21185closed this card ascompleted. This act stripspm:dispatchedand clears the assignee. No other card was named by a closing keyword.- Unblocked by this landing: [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 (
Blocked-by: #21185, the remote face's other doors). This seat's unlock scan returns it to the queue in a separate act. 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 (in flight,create/bulkCreatein the same file) mergesmainif it lands second.
Generated by Claude Code
- The squash has one parent (
Ruled: 5934879010 · letter A (refinements 1/2/3) · 2026-10-01T15:40Z
Filing gate: ① a reproducible defect with a named landing site: the driver
upsertmerge set (packages/drivers/driver-sql/src/sql-driver.ts,upsertandinsertOnlyUpsertColumns; the remote face reads the same list since PR #21184).reach:exception: security. It was measured once, by #21166's dev, onSqlDriverover better-sqlite3 through a direct driver call. The remote face, PostgreSQL and MySQL were not measured.Filed by the
domain:engineexecution seat 2 (seat post #20966,session_01Ujdtvqs7ree7WyQmEDwEnG) from #21166's report,out_of_scope_findings[0]. ⛔ Filed bare: grading and routing belong to triage. ⛔ Not a claim. Reader: the maintainer first (a tenant-isolation boundary), then triage.What is known publicly
IDataDriver.upsertwith a conflict key on a column declared unique across tenants. The call merged into a row that another tenant owns. The row's tenant column and its other columns were overwritten, with no error. The row then belonged to the caller's tenant, and was readable there.upserttreats as insert-only. That is the family drivers(sql): an upsert that merges on a non-PK conflict key silently REWRITES the existing row's primary key — measured on SQLite and MySQL alike #8622 fixed forid, and [finding] driver-turso remote: upsert keyed on a business column replaces the existing row's primary key (the #8622 re-key) — RemoteTransport's merge set keepsid, which the local face declares insert-only #21166 for the remote face.conflictKeys.LifecycleServicepasses['id'].ObjectQLhas noupsertmethod, so the sandbox body runner'supsertfalls back toinsert.id([finding] driver-turso remote: upsert keyed on a business column replaces the existing row's primary key (the #8622 re-key) — RemoteTransport's merge set keepsid, which the local face declares insert-only #21166, H2).Detail withheld pending the maintainer. The reproduction stays with the seat.
Scope for whoever takes it (⛔ not a ruling)
upsertmerge set on every face, read from the one list (insertOnlyUpsertColumns).Dedupe
#8622 (the
idre-key, local face) and #21166 (theidre-key, remote face; PR #21184) are the same family. Neither covers the tenant column. The 200 most recent objectstack issues contain no card on a cross-tenant upsert merge.Generated by Claude Code