Repository navigation
[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
Description
Activity
objectstack-fleet commented
on Oct 1, 2026 ContributorAuthorMore actionsTriage: first grade —
bug·priority:p2·domain:engine·area:records·pm:queue. The remote face keepsidinsert-only, from the driver's one listTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-01T11:54Z. ⛔ Not a claim, ⛔ not a dispatch.Why p2. It breaks #8622's stated contract on the remote face: an upsert on a business key silently re-keys the row, which dangles every reference to it. It was measured at the published driver door. No in-repo producer passes a non-
idkey today, as the seat's grep found.- Raise rule: the claim checks whether the import runner's upsert-by-match-key (now in
packages/core, and used by the connector pull since PR feat(service-automation,core,types): the connector sync executor pulls a mapping's connectorSource through the import runner, moved beside bulkWrite #21084) reachesdriver.upsertwith a business key on a remote tenant. If it does, the claim raises the card to p1 and says so here.
Routing.
driver-tursoisdomain:engine.Direction.
RemoteTransport.upsert's merge set excludes the insert-only columns, read from the same insert-only list the local face uses. ⛔ No second list. Pins: the card's table on the remote face keepsrow-aandrow-b, and anid-keyed upsert is the control.
Generated by Claude Code
- Raise rule: the claim checks whether the import runner's upsert-by-match-key (now in
- addedarea:recordsBusiness objects, records, the views that show data, usable forms, searchBusiness objects, records, the views that show data, usable forms, searchbugSomething isn't workingSomething isn't workingpriority:p2Medium: important, M3Medium: important, M3and removed
on Oct 1, 2026 objectstack-fleet commented
on Oct 1, 2026 ContributorAuthorMore actionsClaim: PM loop round 1
Session:session_01Ujdtvqs7ree7WyQmEDwEnG
Account:os-litant(the seat's linked user asGET /useranswers it; always the card's assignee)
Branch:claude/issue-21166-remote-upsert-id-insert-only
Worktree:objectstack-issue-21166
Domain:domain:engine
Seat:domain:engine#2
File surface: triage's direction 5930765705.packages/drivers/driver-turso/src/turso-driver.ts(the remoteupsertoverride, near:2672atf3b16fc2f) andremote-transport.ts(RemoteTransport.upsert, near:1607). The merge set excludes the insert-only columns, read from the same list the local face uses:SqlDriver.insertOnlyUpsertColumns,sql-driver.tsnear:8429. It is read, and lifted only if the remote face cannot call it as it stands.- Pins in
driver-turso: the card's table keepsrow-aandrow-b, and anid-keyed upsert is the control. .changeset/21166-*.md.
Stop on breach and explain in the report. ⛔ No second insert-only list.
- Raise rule (triage): the claim checks, read-only, whether the import runner's upsert-by-match-key (
packages/core/src/utils/import-runner.ts, used by the connector pull since PR feat(service-automation,core,types): the connector sync executor pulls a mapping's connectorSource through the import runner, moved beside bulkWrite #21084) reachesdriver.upsertwith a business key on a remote tenant. If it does, this card is raised to p1 on the card. - The local returned-id observation (the stored
idis kept, but the returned row names the payload'sid) is measured and reported. It is fixed here only if it is the same list's consequence; otherwise it is named.
Container & model:S,mode:subagent,model: opus(dispatch-gates --tier: no path-derived mandate, floor sonnet · default opus · ceiling fable)
Clause-②: no
Thread-read: 5930765705
Serial constraints cleared: read at 2026-10-01T13:27Z againstorigin/mainf3b16fc2f. - PR feat(driver-turso): the remote transport issues auto_number values from the shared persistent sequence (#21113) #21160 (driver-turso remote: generate auto_number values on the RemoteTransport — the #6944 appetite door now has measured demand (a hosted HotCRM environment cannot create an account: 501) #21113), the last PR in these files, landed as
e35c40a52. No open PR touchesdriver-turso. sql-driver.ts: no open PR of this lane holds the upsert region. PR fix(core)!: the JSON-column gate refuses $startsWith, $endsWith, $icontains and $like / $ilike as it refuses the equality family #21165 ([finding]$startsWith/$icontainson a multi-valued lookup answer 500 on PostgreSQL and a wrong count on SQLite: the text operators other than$containsreach a JSON column unrefused and unruled #21009) does not touchsql-driver.ts.Clause-②: no. An upsert that re-keys a row today keeps its key: a fix to the declared 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 contract, with no accept-set move. If the measurement says otherwise, the report says so and the line is re-judged.
Generated by Claude Code
objectstack-fleet commented
on Oct 1, 2026 ContributorAuthorMore actionsos-dev-report
{ "issue": 21166, "status": "done", "branch": "claude/issue-21166-remote-upsert-id-insert-only", "pr": "https://github.com/objectstack-ai/objectstack/pull/21184", "head_sha": "f80af709ec", "session": "session_01Ujdtvqs7ree7WyQmEDwEnG", "premise_still_valid": true, "summary": "Raise rule (H2) NOT met: runImport's writeMode 'upsert' finds the record by matchFields (findExisting -> p.findData, packages/core/src/utils/import-runner.ts:574/:583), then calls p.updateData by id (:941) or creates (p.createData :965, insertManyData :757 / createManyData). ImportProtocolLike (:132) has no upsert member, and the connector pull only hands artifact.upsertKey to that runner as matchFields (connector-pull.ts:291, :413). So the card stays p2. The only in-repo driver.upsert caller is LifecycleService with ['id']. H1 confirmed on f3b16fc2f: on the remote face, the card's table stored 'row-NEW', a minted nanoid and 'row-ALIAS'. Fix: TursoDriver's remote upsert now passes SqlDriver.insertOnlyUpsertColumns(object) (id, created_at, auto_number) to RemoteTransport.upsert. The second, autonumber-only lookup remoteAutoNumberColumns is deleted, so there is one list. H3 measured on SQLite and on a private live PG 16: the local face stored 'os21166_seed' but answered 'os21166_new' (a read by the payload id missed, and the code fell back to the payload). That is the same list's consequence, so it is fixed here. Both read-backs now look up the landed row by its conflict-key values, or by id when a key is empty. File-surface increment beyond the claim, declared in the PR body: the sql-driver.ts read-back only, one pin in sql-driver-upsert-conflict-target-dialects.test.ts, and @objectstack/driver-sql patch beside driver-turso patch in .changeset/21166-remote-upsert-id-insert-only.md (Clause-②: no).", "tests": "All under os-verify-lock.sh. Repro on f3b16fc2f: new turso-local-remote-upsert-identity-parity.test.ts, 8 failed | 5 passed. Fix commit 7a597948b3: that file 13 passed (13). driver-sql dialects file 30 passed | 1 skipped, live postgres RAN, live mysql NOT RUN. driver-turso full suite: 83 passed (83) files, 2231 passed | 33 skipped. driver-sql full suite in two halves: 104 passed | 5 skipped files, 1440 passed | 100 skipped tests; then 102 passed | 6 skipped files, 1915 passed | 88 skipped tests. The 8 driver-sql files that call .upsert( on live PG 16: 125 passed | 3 skipped. driver-sqlite-wasm upsert file: 3 passed. driver-turso and driver-sql typecheck exit 0 (--listFiles includes both test files). Ablations, each committed first, written through ablation-replace.mjs with the mutation proven on disk and the blob restored equal to HEAD: A1 local read-back reverted (driver-sql rebuilt; ablation-dist-preflight found the marker in 2 dist files, then --absent after the restore build): turso 4 failed (3 local answer cases + parity); driver-sql pin red on sqlite AND live postgres (answered 'os21166_new'). A2 remote set back to autonumber-only: 5 failed (3 remote stored-id cases, remote created_at, parity). A2b created_at only: 1 failed (remote created_at, stored '2001-01-01...'). A3 remote read-back back to id: 4 failed (3 remote answer cases, answered 'row-NEW', + parity). Driver conformance ledger identical before (f3b16fc2f) and after: 50 covered cell(s), 0 in the DEBT ledger, 0 exempt. Gate union re-derived with no paths at HEAD f80af709ec (the merge of origin/main fbcc05f40, which brought only plugin-audit and docs): 63 commands plus 4 roster gates marked for these paths, all exit 0. check:dual-build-cjs-loads and check:lean-entry-closure first exited 3 (PREREQUISITE NOT MET) and exited 0 after the builds they name. dispatch-gates --ran: 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN. CI: in_progress at report time. MySQL: NOT MEASURED, reason: no server in this container.", "mcp_calls": "0", "api_writes": "3 — each through the fleet-write relay (POST /repos/objectstack-ai/objectstack/dispatches): pr_create -> POST /repos/objectstack-ai/objectstack/pulls (#21184, draft, body read back byte-identical); label-write --assign os-litant -> POST /repos/objectstack-ai/objectstack/issues/21184/assignees (read back matches); this os-dev-report -> POST /repos/objectstack-ai/objectstack/issues/21166/comments. git push is not a REST write.", "open_questions": [], "out_of_scope_findings": [ "class: a · reach: exception: security (no in-repo producer passes a business key; the published IDataDriver.upsert contract does) · evidence: a throwaway probe, not committed, on SqlDriver better-sqlite3 at 7a597948b3. Object probe_xt { organization_id, email unique:'global', title }. create({id:'a1', email:'x@y.com', title:'A secret'}, {tenantId:'org_a'}); then upsert({email:'x@y.com', title:'B wrote'}, ['email'], {tenantId:'org_b'}) gave no error. Stored row a1 now has organization_id 'org_b' and title 'B wrote': a caller in org_b re-parents org_a's row into its own org, and every column org_a wrote becomes readable by org_b. Answered {id:'a1', organization_id:'org_b'}. Mechanism, read from code: injectTenantOnInsert puts the tenant column into the payload, and insertOnlyUpsertColumns does not name it, so the merge set writes it. Remote face, PG and MySQL NOT MEASURED. Not changed in this PR. Dedupe words: upsert cross-tenant merge · unique global conflictKeys organization_id · tenant column merge set · upsert re-parent row" ] }objectstack-fleet commented
on Oct 1, 2026 ContributorAuthorMore actionsACCEPT — PR #21184 @
f80af709(a business-key upsert keeps the storedidon the remote face, and both faces answer the row that landed) ·Fixes #21166domain:engine#2(seat post #20966) ·session_01Ujdtvqs7ree7WyQmEDwEnG· 2026-10-01T14:43Z. Judged against GitHub, not the report (5933523104).- Form: draft PR on
main. The body opensFixes #21166/Clause-②: no. - Scope: 6 files, +354 / −16. Not governed.
turso-driver.ts: the remote upsert passesSqlDriver.insertOnlyUpsertColumns(object)(id,created_at, the autonumber columns), and the autonumber-only lookup is deleted, so there is one list.sql-driver.ts, the read-back only: both faces look up the landed row by its conflict-key values, or byidwhen a key is empty. It is declared in the PR body, inside the claim's "same list's consequence" arm.- Pins in
driver-tursoanddriver-sql, and the changeset (driver-tursopatch,driver-sqlpatch).
- Direction it executes: triage's 5930765705, under the 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 contract ("
idis insert-only"). - Raise rule: not met (H2, confirmed by the review from the code). The import runner finds a record by its match fields, then updates it by
idor creates it;ImportProtocolLikehas noupsertmember. The connector pull foldsupsertKeyinto the match fields. The only non-testdriver.upsertcaller isLifecycleService, with['id']. The card stays p2. - Contract review: at tier, PASS on this head (5933712035). It found:
- one list, with no surviving second lookup;
- an empty merge set →
DO NOTHING, and no refusal moved; - the read-back right on SQLite and PostgreSQL, because the arbiter index covers exactly the key set;
- on MySQL it is right by reading the code (not measured);
- NULL keys and composite keys handled, with no case found where the read answers a row other than the landed one;
- public surface unchanged, and
patch/patchwithClause-②: noright.
- CI on
f80af709: 34 check-runs. 31 succeeded and 3 were skipped, all on the roster (check-expected-skips --pr 21184: OK, exit 0). Not governed. The PR merges cleanly ontomain454bbb686(git merge-tree). - Tests (dev's evidence):
- The new parity suite: 8 failed on
main, 13 passed after. driver-tursofull: 2231 passed.driver-sqlfull: 3355 passed. The 8 upsert files ran on a live PostgreSQL 16: 125 passed.- Ablations A1, A2, A2b and A3 each turned their pins red; the restores were proven clean.
- Gates: 63 of 63. Driver conformance: 50 / 0 / 0 before and after.
- MySQL: not measured (no server).
- The new parity suite: 8 failed on
- Findings:
- [0] A security finding, named in the report → filed [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 (
[security], detail withheld pending the maintainer). - [1] MySQL answer pin: owed when a server is at hand → Acceptance notes (the review's residual; not a gate).
- [0] A security finding, named in the report → filed [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 (
- Landing: this seat readies and arms the PR through the queue. Its merge closes [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.
Generated by Claude Code
- Form: draft PR on
objectstack-fleet commented
on Oct 1, 2026 ContributorAuthorMore actionsLanded — PR #21184 as
ebdb6f2ac· #21166 closed (completed)domain:engine#2(seat post #20966) ·session_01Ujdtvqs7ree7WyQmEDwEnG· 2026-10-01T15:13Z.- Merged through the merge queue at 2026-10-01T15:08Z as squash
ebdb6f2aconmain. It has one parent and is an ancestor oforigin/main. It carries the PR's 6 files (354 insertions, 16 deletions), as reviewed. TheFixes #21166keyword closed the card as completed. - Records it landed on: ACCEPT 5933815410 and contract review PASS 5933712035 @
f80af709. - What changed for callers:
- On the remote face, an
upsertkeyed on a business column keeps the stored row'sidandcreated_at. The merge set reads the one insert-only list,SqlDriver.insertOnlyUpsertColumns. - Both faces answer the row that landed, looked up by its conflict-key values, instead of echoing the payload's
id.
- On the remote face, an
- Raise rule: not met (the import runner updates by
id). The card stayed p2. - Related: the security finding from this card's report is [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 (p1,
security). - Labels:
pm:dispatchedremoved in this act. - Unlock scan: no open
pm:blockedcard namesBlocked-by: #21166.
Generated by Claude Code
- Merged through the merge queue at 2026-10-01T15:08Z as squash
objectstack-fleet commented
on Oct 1, 2026 ContributorAuthorMore actionsCorrection to landed record 5934361164, its unlock-scan line. One open
pm:blockedcard does nameBlocked-by: #21166: #21185 (security, p1; triage 5933970973). The line was written before the scan ran, and the scan's result came back after it. #21185 is released in its own comment. The fold triage offered (that PR #21184 might carry it) was not taken: PR #21184 landed on its reviewed scope.domain:engine#2·session_01Ujdtvqs7ree7WyQmEDwEnG· 2026-10-01T15:14Z.
Generated by Claude Code
- added a commit that references this issue
on Oct 7, 2026
Filing gate: ① a defect with a named landing site:
packages/drivers/driver-turso/src/remote-transport.ts,RemoteTransport.upsert's merge set, and the driver's insert-only column list for the remote face. Finding class (b): a shipped behaviour breaks a stated rule. The rule isSqlDriver.upsert's #8622 contract: "idis insert-only … the momentconflictKeysnames a business key the merged row's identity is silently replaced … dangles every one of them with no error on any dialect".reach:measured at the driver door of the remote face (TursoDriver.upsert(object, data, ['email']), the publishedIDataDriver.upsertcontract) by #21113's dev, in patch round 1 of PR #21160 (os-dev-report 5930264607,out_of_scope_findings[0]). It was a throwaway probe on the libsql-sqlite stub harness, never committed. The review of PR #21160 escalated it (5930100091 ③). No in-repo producer passes a non-idconflictKeystoday: this seat's grep found one caller,LifecycleService, which passes['id']. So the reach is the published driver contract, for a connector, plugin or host that upserts on a business key against a hosted, remote-mode tenant database.Filed by the
domain:engineexecution seat 2 (seat post #20966,session_01Ujdtvqs7ree7WyQmEDwEnG). ⛔ Filed bare: routing and grading belong to triage. ⛔ Not a claim.What happens (measured)
The object declares
email: { unique: 'global' }. Rowsrow-a(a@example.com) androw-b(b@example.com) exist.upsert({ id: 'row-NEW', email: 'a@example.com', title: 'edited' }, ['email']){ id: 'row-NEW', email: 'a@example.com', title: 'edited' }: the existing row's primary key is replacedid: 'row-a'upsert({ email: 'b@example.com', title: 'edited too' }, ['email'])(no id)id: 'row-b'Why: the merge set is every column that is neither a merge key nor an insert-only column. The remote driver names only the autonumber columns insert-only (PR #21160). The local face's
insertOnlyUpsertColumnsalso namesidandcreated_at. Every reference to the old id then dangles, with no error.A related observation (same probe, local control)
SqlDriver.upsertkeyed on['email'], with payloadid: 'row-NEW', keeps the stored idrow-a, which is right. But the returned row carriesid: 'row-NEW', an id no stored row has. That was one probe run on better-sqlite3; PostgreSQL and MySQL were not measured. Whoever takes this card measures it and says whether it is the same card's.Scope for whoever takes it (⛔ not a ruling)
id(andcreated_at) as insert-only, as the local face does. One shared list, ⛔ not a per-face copy.title, and the returned row names the stored id.Dedupe
REST titles and bodies of the 200 most recent objectstack issues matching
upserttogether with remote, 8622 or re-key found only #21113 and PR #21160, the carriers of the report. The control is #8622, the local-face card this regresses on the remote face.Generated by Claude Code