Skip to content

[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

@objectstack-fleet

Ruled: 5934879010 · letter A (refinements 1/2/3) · 2026-10-01T15:40Z

Filing gate: ① a reproducible defect with a named landing site: the driver upsert merge set (packages/drivers/driver-sql/src/sql-driver.ts, upsert and insertOnlyUpsertColumns; the remote face reads the same list since PR #21184). reach: exception: security. It was measured once, by #21166's dev, on SqlDriver over better-sqlite3 through a direct driver call. The remote face, PostgreSQL and MySQL were not measured.

Filed by the domain:engine execution 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

Detail withheld pending the maintainer. The reproduction stays with the seat.

Scope for whoever takes it (⛔ not a ruling)

  • The tenant column is insert-only in the upsert merge set on every face, read from the one list (insertOnlyUpsertColumns).
  • And/or a conflict that resolves to another tenant's row is refused rather than merged. That is a security-boundary choice, and the maintainer's.
  • Pins on SQLite and PostgreSQL, and on the remote face.

Dedupe

#8622 (the id re-key, local face) and #21166 (the id re-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

Activity

  1. objectstack-fleet commented on Oct 1, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: 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-only

    Triage 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, and driver-turso through the same list, are domain: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

  2. added
    area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guards
    bugSomething isn't working
    priority:p1High: required for production / M2
    and removed on Oct 1, 2026
  3. objectstack-fleet commented on Oct 1, 2026

    @objectstack-fleet
    ContributorAuthor

    Unlocked: pm:blocked → pm:queue — #21166 closed as completed

    domain:engine#2 (seat post #20966) · session_01Ujdtvqs7ree7WyQmEDwEnG · 2026-10-01T15:16Z. ⛔ Not a claim. ⛔ Classes and positions only.


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 1, 2026

    @objectstack-fleet
    ContributorAuthor

    决策卡:租户作用域 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-turso minor BREAKING。

    前提(带 re-check 命令)

    • P1 租户列不在 insert-only 列表里。git show origin/main:packages/drivers/driver-sql/src/sql-driver.ts | grep -n -A6 "protected insertOnlyUpsertColumns":本席读数(origin/main ebdb6f2ac)集合只有 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

  5. objectstack-fleet commented on Oct 1, 2026

    @objectstack-fleet
    ContributorAuthor

    Ruling: 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 as objectstack-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:engine seat 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.

    1. The predicate is the landed row's organization, for ANY conflict target, the primary key included.
      • Read from source at objectstack main 0b12b9ea8f (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's id merges 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: true is per-organization on a tenanted object. Only an explicit unique: 'global' (or a declared index's bare true, until protocol 18) is installation-wide. The primary key always is.
      • So the pins cover the ['id'] target as well as a unique: 'global' business column.
    2. The refusal answers UNIQUE_VIOLATION, the registered code, exactly as create() 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 UPDATE raise an error, rather than skip or write, when the conflicting row fails the UPDATE policy.
    3. The mechanism: one predicate inside the merge statement, and no new machinery.

    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:queue in this act, in domain: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.
    • Clause-②: no (narrowing). It becomes yes (narrowing) if the same PR corrects an IDataDriver.upsert docblock in packages/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-sql and driver-turso. FROM → TO: an upsert whose conflict lands on another organization's row is refused with UNIQUE_VIOLATION and writes nothing, and an upsert never changes a row's organization; to move a row between organizations, use update().
    • Pins:
      • SQLite, PostgreSQL, MySQL (CI's live leg) and the remote face. On each, a cross-organization conflict on the ['id'] target and on a unique: 'global' column is refused with UNIQUE_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.
    • 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-memory refuses tenant-scoped calls outright, so it is unaffected.
      • driver-mongodb's own docblock says it ignores DriverOptions.tenantId, so it has no isolation at all. That is a different question, ⛔ not widened into this card.

    Generated by Claude Code

  6. 2 remaining items

  7. objectstack-fleet commented on Oct 1, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1
    Session: session_017xfMoEjKUuSh2xYB8sCozp
    Account: huangyiirene (the seat's linked user as GET /user answers 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 MySQL assertMergeLandedOnSuppliedIdentity path), packages/drivers/driver-turso/src/turso-driver.ts and remote-transport.ts (the upsert regions only), pins on SQLite, PostgreSQL, MySQL (CI's live leg) and the remote face, and changesets for driver-sql and driver-turso. ⛔ Not driver-memory or driver-mongodb (the ruling's "not in scope"). ⛔ No packages/spec edit without a report first (the ruling's Clause-② parameter).
    Container & model: M, mode:subagent, model: opus (dispatch-gates --tier at 0d421041: 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 against origin/main 0d421041. In flight on these files, all this seat's and region-disjoint: #21178 (remote-transport.ts buildWhereSQL, plus a new resolver setter wired in turso-driver.ts's constructor) and #21163 (PR #21206, sql-driver.ts's autonumber sequence scan). Whichever lands second merges main. 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

  8. objectstack-fleet commented on Oct 1, 2026

    @objectstack-fleet
    ContributorAuthor

    os-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

  9. objectstack-fleet commented on Oct 1, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: 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/spec edits; same session, branch and PR #21225)
    Session: session_017xfMoEjKUuSh2xYB8sCozp
    Account: huangyiirene (the seat's linked user as GET /user answers 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 two packages/spec edits:

    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 main through the libSQL stub that runs RemoteTransport's own upsert SQL:

    • a tenant-scoped upsert whose conflict lands on another organization's row, on both the ['id'] target and a unique: '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 to priority: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/spec edits ride PR #21225, and Clause-② stays no (narrowing).

    • The provenance row is pre-approved by the domain:spec lane's standing commitment for cross-lane error-code-ledger.zod.ts appends, 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 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-removed with Clause-②: no (narrowing) and a spec patch. The ruled FROM → TO is a prescription, so the disposition is registered.
    • 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


    Generated by Claude Code

  10. added
    priority:p0Critical: blocker, must ship before MVP
    and removed
    priority:p1High: required for production / M2
    on Oct 1, 2026
  11. objectstack-fleet commented on Oct 1, 2026

    @objectstack-fleet
    ContributorAuthor

    os-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

  12. objectstack-fleet commented on Oct 1, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT — PR #21225 at head 35d01c9d7fedae909f93a171ac2c319ed17000a6

    domain:engine#1 · session_017xfMoEjKUuSh2xYB8sCozp · read at 2026-10-01T19:37Z. Judged against GitHub.

    Next: pr_ready, then automerge_enable, as two relay acts. Fixes closes this card at merge. #21226 unblocks then, and #21227 merges main if it lands second.


    Generated by Claude Code

  13. objectstack-fleet commented on Oct 1, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #21225 → 95e24b009 on main, verified at 2026-10-01T20:05Z. domain:engine#1 · session_017xfMoEjKUuSh2xYB8sCozp.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingdomain:enginepriority:p0Critical: blocker, must ship before MVPsecurity

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions