Skip to content

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

Description

@hotlong

Found while implementing #8755 (PR #8806). Filed unassigned rather than folded in: #8755's ruling scopes its refusal to a caller-named non-primary conflict target, and this is the residue that scoping leaves behind. It is documented in that PR (driver docs + refuseAmbiguousConflictTarget's docblock) rather than hidden, but documented is not fixed.

The condition

ON DUPLICATE KEY UPDATE carries no conflict target, so on MySQL the merge lands on whichever UNIQUE key the row collides with first. #8755 refuses that when the caller names a non-primary target. Two shapes remain, and they are the same statement:

In both, the driver mints or is handed an id that usually does not collide — while a business UNIQUE key on the same table can, and MySQL merges there instead.

Measured — live MySQL 8.0.46

Ubuntu noble mysql-server, mysqld --daemonize, time_zone='+08:00', through the same knex + mysql2 path SqlDriver.upsert takes. Table carries PRIMARY KEY (id), UNIQUE KEY (email), UNIQUE KEY (tax_id):

seed  upsert({email:'d@b.com', tax_id:'T-9', title:'first'})     -- no conflictKeys
      -> RESOLVED, id='6HBQhzgSTbtxNdLc'

B     upsert({email:'e@b.com', tax_id:'T-9', title:'second'})    -- no conflictKeys
      -> RESOLVED. rows=1, id='6HBQhzgSTbtxNdLc', email='e@b.com', title='second'
         The fresh `id` did not collide; `tax_id` did, and MySQL merged on it —
         rewriting a row whose `email` the caller never asked to touch.

The identical pair on SQLite raises UNIQUE constraint failed: ….tax_id and leaves the seeded row untouched (also measured, same session).

Note the row identity IS preserved (id is insert-only since #8622) — this is not that defect. What is wrong is which row was updated.

Why it is not obviously the same call as #8755

The ruled refusal rests on a caller who named a target: the platform promised to honour it and cannot. Here nobody named anything, so the argument has to be different — something like "an upsert should never merge into a row whose identity the caller did not supply and whose conflict key it did not name". That is a wider statement about what upsert means, not a MySQL detail, and it wants its own adjudication.

Options, none of them free:

  • A — extend the pre-flight to the default path: refuse a conflictKeys-less upsert on a MySQL table carrying any non-primary UNIQUE key. Widest accept-set change of the three; it would refuse the platform's own lifecycle archiver (lifecycle-service.ts, cold.upsert(object, row, ['id'])) for every object with a declared unique business column.
  • B — narrow it to the payload: refuse only when the row actually supplies a value for a rival UNIQUE column (a NULL there cannot collide). Much smaller accept-set change, but it is a per-call decision rather than a per-table one, so it costs a payload inspection on the hot path and the refusal becomes data-dependent — two identical calls, one refused.
  • C — leave it documented (the state after drivers(sql): on MySQL an upsert still merges on a unique key the caller never named — even when the named conflict target IS backed #8755) and treat "one UNIQUE key per table on MySQL" as the platform's advice.

One unverified thread worth a look while adjudicating

SqlDriver.upsert's #6943 re-seed logic states that "the tenanted autonumber lives under a DIFFERENT unique index, so its violation is still raised and still reaches here", and re-seeds the counter when it can prove the collision was its own. On MySQL a collision on that index would be merged, not raised — so the re-seed path may be unreachable there, and a burned/stale autonumber may merge into the row that owns the number instead. Not measured; it needs an object with an auto_number field on live MySQL to confirm or kill.

Related: #8755 (the ruled half, PR #8806), #8621 (the unbacked-target pre-flight), #8592 (the original live-MySQL measurement), #8622 (id insert-only, which is why identity survives above), #6943 (the autonumber re-seed thread).


Generated by Claude Code

Activity

  1. hotlong commented on Aug 15, 2026

    @hotlong
    ContributorAuthor

    Triage: lands in packages/drivers/driver-sql ⇒ domain:drivers (filer's label kept); type Bug; escalated to needs-user-decision.

    Rationale: all three options change SqlDriver.upsert's pre-flight and nothing else — the callers are consumers, not landing sites — so the lane is determinable now and the ruling does not select it.

    Not a duplicate of #8755

    Checked deliberately, because the titles are one clause apart. #8755 (PR #8806, in flight) refuses a caller-named non-primary conflict target; this card is the residue that scoping leaves: the conflictKeys-less call and the ['id'] call, which compile byte-identically. Filed by the dev implementing #8755 rather than folded in — the right call, since the argument here cannot borrow #8755's ("the platform promised to honour a target it cannot") and has to be a wider statement about what upsert means.

    Premises re-verified on origin/main @ d09d0fd, ⛔ not inherited

    claim reading
    the archiver call option A would refuse verbatim: packages/objectql/src/lifecycle/lifecycle-service.ts:1123 — await cold.upsert(object, row, ['id']);
    ON DUPLICATE KEY UPDATE carries no target present on main at sql-driver.ts:5076-5128 and pinned by sql-driver-upsert-conflict-target-dialects.test.ts:478 ([#8567] MySQL: onConflict().merge() compiles the conflict target away)
    the merge-ALL fallback sql-driver.ts:5277-5296, matching #8740's hold
    refuseAmbiguousConflictTarget not on main — it is PR #8806's, as the card states. Zero-hit counter-probed against the adjacent names above, which do hit, so the absence is a real reading and not a broken query.

    I did not re-run the live MySQL measurement; it is reported with the dialect, version, seed and the row read back, and the SQLite contrast is the control. Treating it as sound.

    Four-facet card face

    • ① Platform long-term coherence — A makes upsert mean one thing on every dialect: a merge lands only on a key the caller named or the identity it supplied. That shrinks a special case. C keeps a per-dialect exception alive as documentation, i.e. one more thing every author must know. B is the least coherent of the three: it makes the refusal data-dependent, so two identical calls differ by payload — a new special-case shape, not a smaller one.
    • ② Measured business pull — the defect is measured on live MySQL 8.0.46, and it is silent, which is exactly why no user report exists. Against that: A has a measured first-party cost — the lifecycle archiver above is a real caller it would refuse for every object carrying a declared unique business column. So this facet reads both ways and is the one to measure before ruling (see below).
    • ③ AI-agent error-resistance — this is the strongest facet, and it points at A/B. An agent authoring an app with a natural business key (email, tax_id, code) gets no signal: the write succeeds, and a different row is rewritten. "One UNIQUE key per table on MySQL" as advice (option C) is a rule nothing enforces — the shape this project's guardrails exist to avoid.
    • ④ Startup scope discipline — C is free today. A costs a first-party migration (the archiver needs an explicit target or an opt-out). B costs a payload inspection on the hot path plus a permanently data-dependent accept set — the largest standing obligation of the three despite being the smallest accept-set change.

    Facets split ①③ (act) vs ②④ (cost) ⇒ escalated, ⛔ not adjudicated. It is also on the manual floor independently: A and B each state a new contract about what upsert accepts, with no declared contract to pull back to, so the mechanical border test lands it outside the delegated lane whatever the facets said.

    Recommendation — offered, falsifiable, ⛔ not a ruling

    A, narrowed to tables that actually carry a non-primary UNIQUE key, with the archiver's call site given an explicit conflict target or a documented opt-out. The pre-flight already probes table keys for #8621, so the probe is not new cost; and narrowing to "table has a rival unique key" avoids B's data-dependence while refusing far less than a blanket A.

    Falsifier, and the measurement I recommend before ruling: how many objects the lifecycle archiver actually touches declare a non-primary unique field. If that set is large, A's blast radius is first-party and the ruling should probably be C-plus-a-loud-doc rather than a refusal. I have not measured it.

    One thread deliberately left open

    The #6943 autonumber re-seed reachability question in the card body is unmeasured and I did not measure it either. It should not block this ruling — if it confirms, it is a second defect of the same family and wants its own card, not a wider scope on this one.


    Generated by Claude Code

  2. added theissue type on Aug 15, 2026
  3. os-project-manager commented on Aug 15, 2026

    @os-project-manager
    Collaborator

    Maintainer ruling — the principle is adopted; the drivers lane chooses the enforcement

    Provenance: maintainer, live PM chat 2026-08-15 (session session_01LXPH2ApQmyHYeZGHfYv7Xs), verbatim 「接受你的所有建议。」, accepting the 18-card decision-box analysis. This card's accepted recommendation, recorded as a two-part ruling:

    1. Contract principle (ruled): an upsert must never modify a row whose identity the caller did not supply and whose conflict key it did not name. The MySQL silent cross-row merge violates it and is a defect, not a documented quirk.

    2. Enforcement (delegated to the drivers lane, bounded): the lane picks the cheapest enforcement that upholds the principle, with these exclusions — ⛔ not blanket A (refusing every conflictKeys-less upsert on any table with a non-primary UNIQUE key would refuse the platform's own lifecycle archiver wholesale), ⛔ not C (documented-only is not a terminal state for a data-corruption class), and B's payload-dependent refusal (two identical calls, one refused) is disfavoured. The triage escalation's A-narrowed shape is the leading candidate: refuse only on tables that actually carry a rival non-primary UNIQUE key, with the archiver call site given an explicit conflict target or a documented opt-out; a post-hoc identity check (merged-row id ≠ supplied/minted id ⇒ error inside the transaction) is an acceptable alternative if measurement favours it.

    Premise before implementing (the escalation's falsifier, adopted): measure how many archiver-touched objects declare a non-primary unique field. If that set is large, the chosen enforcement must handle the archiver first-party in the same PR — not by widening what merges, but by fixing its call sites; if that turns out impractical, STOP and report with the number rather than shipping a refusal that breaks archival.

    Out of scope, per the escalation: the unmeasured #6943 autonumber re-seed reachability thread — if confirmed, it is its own card. needs-user-decision → pm:queue in the same write. Size/model: M–L, opus; the refusal is an accept-set change on the driver contract — pin both branches (refused shape + still-merging legitimate shape) per dialect.


    Generated by Claude Code

  4. self-assigned this
    on Aug 15, 2026
  5. hotlong commented on Aug 15, 2026

    @hotlong
    ContributorAuthor

    Claim: PM loop
    Session: session_01XeQRiAa7vYRVX5Fog7Zby8
    Branch: claude/issue-8807-upsert-unnamed-unique-key-merge
    Worktree: objectstack-issue-8807
    Domain: domain:drivers
    File surface: packages/drivers/driver-sql/src/sql-driver.ts (the upsert pre-flight) + its pins, plus packages/objectql/src/lifecycle/lifecycle-service.ts (the archiver call site the ruling requires be handled first-party). Stop on breach; explain in the report.
    Container & model: M–L, mode:subagent, model: opus (as the ruling specifies)
    Serial constraints cleared: discharged, and the triage note is now stale — see below. driver-sql is otherwise uncontended: #8927 (#8790) merged at 23:33Z, and the one live sibling (#8933 / #8907) is in metadata-protocol, touching neither package on this surface.

    ⚠️ The serial constraint this card recorded has been discharged — measured, not assumed

    Triage's premise table (03:50Z) recorded:

    refuseAmbiguousConflictTarget — not on main — it is PR #8806's, as the card states.

    Measured just now on origin/main @ 716ac9bf8: it IS on main — 3 occurrences in sql-driver.ts plus its dialect pin. PR #8806 (#8755) has landed. Probe validated with a counter-term in the same file that must hit, so the positive is a real reading.

    ⇒ Two consequences the dispatch carries:

    1. Nothing is in flight in this function. No serialization needed.
    2. ⛔ The dev inherits fix(driver-sql): refuse a MySQL upsert whose named conflict target another unique key can absorb (#8755) #8806's pre-flight and must build ON it, not beside it. This card is explicitly the residue fix(driver-sql): refuse a MySQL upsert whose named conflict target another unique key can absorb (#8755) #8806's scoping left — the conflictKeys-less call and the ['id'] call, which compile byte-identically — so a second, parallel refusal path in the same pre-flight would be the "a guard that exists twice will disagree with itself" shape.

    The ruling, carried as two parts because it is two parts

    1. Principle — RULED, ⛔ not re-adjudicable:

    an upsert must never modify a row whose identity the caller did not supply and whose conflict key it did not name.

    The MySQL silent cross-row merge violates it and is a defect, not a documented quirk.

    2. Enforcement — DELEGATED to this lane, bounded. The lane picks the cheapest enforcement upholding the principle, with the ruling's exclusions binding: ⛔ not blanket A (it would refuse the platform's own lifecycle archiver wholesale), ⛔ not C (documented-only is not a terminal state for a data-corruption class), and B's payload-dependent refusal (two identical calls, one refused) is disfavoured. A-narrowed is the leading candidate; a post-hoc identity check (merged-row id ≠ supplied/minted id ⇒ error inside the transaction) is an acceptable alternative if measurement favours it.

    ⇒ I am not pre-picking between those two. The dev measures and chooses within the bounds, and states which and why — that is what "delegated to the lane" means, and picking it from here without the measurement would waste the delegation.

    The premise that must be measured BEFORE implementing

    The ruling adopts the escalation's falsifier verbatim:

    measure how many archiver-touched objects declare a non-primary unique field. If that set is large, the chosen enforcement must handle the archiver first-party in the same PR — not by widening what merges, but by fixing its call sites; if that turns out impractical, STOP and report with the number rather than shipping a refusal that breaks archival.

    That is why lifecycle-service.ts is in the declared file surface rather than left as a follow-up.

    ⛔ Out of scope, per the escalation and the ruling: the unmeasured #6943 autonumber re-seed reachability thread. If it confirms, it is its own card — ⛔ not a widening of this one.


    Generated by Claude Code

  6. hotlong commented on Aug 16, 2026

    @hotlong
    ContributorAuthor
    {
      "issue": 8807,
      "status": "done",
      "branch": "claude/issue-8807-upsert-unnamed-unique-key-merge",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/8940",
      "premise_still_valid": true,
      "summary": "Enforcement chosen: the POST-HOC IDENTITY CHECK, not A-narrowed — and the measurement drove it. Zone 2 falsifier measured FIRST: exactly 2 objects in the repo declare `lifecycle.archive` (sys_audit_log, sys_metadata_audit) and ZERO declare a non-primary unique field, so the STOP condition does not fire. The decisive measurement was a second one: on this path the merge target is always the primary key, so EVERY non-primary UNIQUE key is a rival — 'A-narrowed to tables carrying a rival key' and 'blanket A' are literally the same set here, meaning A-narrowed collapses into the option the ruling excluded by name and would refuse `upsert(object, row)` on all 43 first-party objects declaring a unique field. The post-hoc check has no such cost and is exact rather than heuristic: since `id` is insert-only on the merge path (#8622), 'no row carries the supplied id after the statement' is a BICONDITIONAL for 'the merge landed on a row the caller never identified' — zero false refusals. It runs inside a transaction so the wrong write is ROLLED BACK ('never modify' is not satisfied by noticing afterwards), and only on MySQL tables that actually carry a rival key, so ordinary upserts keep their single autocommitted round trip. Built ON #8806's pre-flight as instructed — same introspection, same cache, same judge(), a third verdict rather than a second guard. Premise re-verified on live MySQL 8.0.46 for BOTH shapes before implementing. Archiver handled first-party: `cold.upsert(object, row, ['id'])` needs no change and is correct by construction (it supplies each row's own id); the measurement and reasoning are recorded at the call site, and its exact shape is pinned on the hostile two-unique-key table. The issue body showed NO sanitizer truncation.",
      "tests": "All quoted from the union run at final HEAD af3147f76. (1) LIVE MySQL 8.0.46 raised in-container (mysqld 8.0.46-0ubuntu0.24.04.3, time_zone '+08:00', OS_TEST_MYSQL_URL=mysql://root:root@127.0.0.1:3306/conformance — the CI-shaped URL): sql-driver-upsert-conflict-target-dialects.test.ts 'Test Files 1 passed / Tests 35 passed | 2 skipped'. (2) driver-sql whole package on SQLite: 'Test Files 99 passed | 4 skipped (103) / Tests 1724 passed | 56 skipped'. (3) Consumer sweep, DOWNSTREAM direction (--filter '...@objectstack/driver-sql' = 48 consumer packages; prefix form, not the upstream suffix): driver-turso 'Tests 1002 passed', driver-sqlite-wasm 'Tests 394 passed' (both SqlDriver subclasses inheriting this path), spec 'Tests 10711 passed', objectql 'Tests 1 failed | 3694 passed' — the one failure is PRE-EXISTING and already filed as #8937 (engine-temporal-comparand-door.test.ts hard-codes now=2026-08-15 and went red at UTC midnight; proved not mine: my entire objectql diff is 100% comment lines, verified by filtering the diff for non-comment additions and getting zero). (4) Typecheck green: driver-sql, objectql, spec. (5) TEST_DEBT measured on a BUILT workspace (full turbo build of packages/* first): 'check-type-check-coverage --re-measure: OK — 33 ledger entr(ies) re-measured, 1926 raw tsc error(s) total, none above its recorded number'. No ceiling raised. Gate reports a pre-existing -1 surplus in @objectstack/lint (a package I did not touch), lowerable but explicitly 'not an error'. (6) ABLATION, direction predicted BEFORE running, both matched, fix committed first so restore came out of a real commit. A: verifyIdentity forced false -> predicted the 4 refusal pins red and every control green; observed exactly '4 failed | 31 passed', text: \"AssertionError: the seeded row survived with the OVERWRITTEN email — the refusal was reported but not rolled back, so the corruption this card exists to stop still happened: expected 'e@b.com' to be 'd@b.com'\". B: transaction wrapper disabled but check kept -> predicted envelope pins stay GREEN and only stored-row pins go red, isolating the rollback; observed exactly '2 failed | 33 passed' (pins 1 and 4 green, 2 and 3 red). Restored to byte-identity: git status clean, 0 ablation markers. (7) Gates re-derived from ACTUAL changed paths via scripts/pm/dispatch-gates.mjs and run as a union at af3147f76 — all green: nul-bytes, changeset-gate-self-tests, objectui-changeset, docs-audit-scope, role-word, test-source-alias, type-source-resolution, merge-driver, spec-parsed-alias, durability-log-level, error-code-casing, query-options-erasure, type-check-coverage, type-check-debt --re-measure, cross-package-test-inputs, adr-0087-registration, changeset-no-major, empty-changeset, dev-prereqs, engine-split-ratio, doc-formula-expressions, migration-registry. Added beyond the prompt's named families: durability-log-level and engine-split-ratio (via the objectql edit), spec-parsed-alias / merge-driver / doc-formula-expressions / cross-package-test-inputs / migration-registry (via the spec ledger entry). No dogfood ablation in this card, so no dist rebuild claim is made.",
      "open_questions": [],
      "out_of_scope_findings": [
        "already filed as #8937 (found independently, searched before filing, not duplicated): engine-temporal-comparand-door.test.ts hard-codes now=2026-08-15 and the {30_days_ago} resolution ignores the injected context.now, so it went permanently red at UTC midnight — it is the one red in the objectql consumer sweep and is unrelated to this card",
        "NOT filed, reported instead: check:type-check-debt reports a -1 surplus in @objectstack/lint (records 20, tsc now reports 19), lowerable via --lower. Pre-existing, in a package this card does not touch, and the gate itself prints it on every run as 'not an error' — self-announcing rather than hidden, so filing looked like noise. Flagging for PM triage rather than sitting on it.",
        "#6943 autonumber re-seed reachability: left untouched and unmeasured, as the ruling and the escalation both required."
      ]
    }

    Generated by Claude Code

  7. hotlong commented on Aug 16, 2026

    @hotlong
    ContributorAuthor

    Supersedes the earlier report on this card — CI-red fix round (check:tenant-chokepoint).

    {
      "issue": 8807,
      "status": "done",
      "branch": "claude/issue-8807-upsert-unnamed-unique-key-merge",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/8940",
      "premise_still_valid": true,
      "summary": "Fixed the red gate the FIRST way, as instructed — bound the builder and applied the scope on it; check-tenant-chokepoint.mjs is untouched. The PM was right that this was a real question and not a lint nit, and it changed the code twice. (1) The identity read is now tenant-scoped, but to the tenant the row was WRITTEN under, not the caller's active org. Scoping to the active org would false-refuse a documented authority: injectTenantOnInsert never overwrites an explicit tenant ('admins writing to a specific tenant via raw row data keep that authority'), so a caller whose active org is org_a can legitimately land a row in org_b, and an active-org-scoped probe would miss the row it just wrote. Both readings are now pinned and ablation C proves the pin discriminates. For the archiver and any non-tenanted object the scope is a NO-OP by applyTenantScope's own contract (it early-outs on an empty tenantId, and the archiver passes no options at all) — so archival is untouched, which was the ruling's red line. (2) Fixing the first complaint surfaced a second: my earlier refactor had chained .insert() off getBuilder() inside the new closure, which hid the INSERT door from the gate's classifier (it detects a write by seeing .insert used on the BINDING). Restored the two-step bind — honest, since insert-side tenancy is injectTenantOnInsert's job. I also measured the one fact the PM's concern implied I was assuming: whether a pre-existing row carrying our id could MASK a merge that went elsewhere when the payload collides on the PK and a rival key at once. Measured on live MySQL 8.0.46 — the PRIMARY KEY row wins (R1 merged, R2 untouched, ROW_COUNT=2) — so the biconditional holds and the masking case does not arise. Gate list correction accepted: check:tenant-chokepoint is content-keyed (it scans builder shapes), so dispatch-gates.mjs structurally cannot name it; I ran the ENTIRE 48-gate ESLint job this round rather than a derived subset.",
      "tests": "All at final HEAD bd0f45cd7, tree clean. (1) check:tenant-chokepoint: '✓ 20 getBuilder() binding(s) across 3 file(s); every read builder routes through applyTenantScope(), every unscoped one is an insert.' (2) The ENTIRE ESLint job — all 48 gates extracted from lint.yml's `lint` job and run individually — ALL PASS, including pnpm lint, check:slot-lookup, check:verify-stand-in, check:engine-double-contract, check:published-files, check:pm-dispatch-gates, check:partof-closing-keyword. (3) LIVE MySQL 8.0.46: sql-driver-upsert-conflict-target-dialects.test.ts 'Test Files 1 passed / Tests 38 passed | 2 skipped' — up from 35, the three new pins being the tenanted refusal, the tenanted merge, and the admin cross-org write. (4) sql-driver-tenant-scope-read-doors.test.ts 'Tests 21 passed' — run explicitly because the chokepoint gate's own docblock names it as the other half of its contract ('the gate proves the call is there; only the fixture proves it works'). (5) driver-sql whole package on SQLite: 'Tests 1724 passed | 56 skipped'. (6) Subclasses: driver-turso 'Tests 1002 passed', driver-sqlite-wasm 'Tests 394 passed'. ⚠️ Both first reported 25 test files failed with 'no tests' — that was the missing-dist trap, not a regression: the worktree had been torn down and recreated, so no package had a dist/. After building the closures both are green at the counts above; recording it because a stale/absent dist reads exactly like real breakage. (7) TEST_DEBT on a BUILT workspace: '--re-measure: OK — 33 ledger entr(ies) re-measured, 1926 raw tsc error(s) total, none above its recorded number.' No ceiling raised. (8) ABLATION C, new this round, direction predicted first: scoping switched to options.tenantId (the naive active-org reading) -> predicted EXACTLY ONE red, the admin cross-org pin, with the other two tenanted pins staying green; observed '1 failed | 37 passed', text: \"AssertionError: the identity read is scoped to the CALLER's active org rather than the tenant the row was written under, so a deliberate cross-org admin write reads as a cross-row merge\". Ablations A and B from the previous round still stand. All restored to byte-identity (git status clean, 0 ablation markers). (9) MEASUREMENT behind the biconditional, raw SQL on live MySQL 8.0.46: rows R1(id=R1,tax_id=T-1) and R2(id=R2,tax_id=T-2), inserting (id=R1,tax_id=T-2) which collides with BOTH -> 'R1 -> title=MERGED, R2 -> untouched, ROW_COUNT()=2'. The primary key is matched first.",
      "open_questions": [],
      "out_of_scope_findings": [
        "#8937 (I found and filed it last round): engine-temporal-comparand-door.test.ts went red at UTC midnight. Confirmed still the ONLY red in the objectql sweep, still unrelated (my objectql diff is 100% comment lines). Per the PM's instruction this round: not fixed, not worked around, not allowed to hold this PR.",
        "NOT filed, reported instead: check:type-check-debt reports a -1 surplus in @objectstack/lint (records 20, tsc reports 19), lowerable via --lower. Pre-existing, in a package this card does not touch, and the gate prints it on every run as explicitly 'not an error' — self-announcing rather than hidden. PM's call.",
        "#6943 autonumber re-seed reachability: untouched and unmeasured, as ruled."
      ]
    }

    Generated by Claude Code

  8. hotlong commented on Aug 16, 2026

    @hotlong
    ContributorAuthor

    Supersedes the earlier reports — merge-conflict resolution round (MERGE_CONFLICT dequeue).

    {
      "issue": 8807,
      "status": "done",
      "branch": "claude/issue-8807-upsert-unnamed-unique-key-merge",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/8940",
      "premise_still_valid": true,
      "summary": "Merge conflict resolved by REGENERATION in the mandated order, and the branch is now on top of the newest main. Ran scripts/pm/os-regen-merge.sh; it stopped at step 1 as designed because packages/spec/src/migrations/registry.ts is NOT routed to merge=os-regen in .gitattributes, so it conflicts textually rather than silently. registry.ts was the ONLY conflict. Resolved it the generated way rather than by hand: took origin/main's side, COMMITTED THE MERGE FIRST (commit 1286d2993), and only then ran gen:migration-registry — the ordering trap the script's header warns about. Verified the regeneration was doing real work rather than a no-op: before it, the registry body contained engine-dotted-filter-refused and NOT my id; after it, 97 semantic entries and both present. Both intents then verified by exact name in BOTH places, as required. Also checked the trap the script calls out beyond the index — that #8936's IMPLEMENTATION body survived, not just its entry: my branch's net diff versus origin/main across packages/objectql and packages/metadata-protocol is exactly my 26-line lifecycle comment and nothing else, and the dotted-filter implementation in filter-comparand-shape.ts is intact. Then, on the PM's warning that main had moved again: main had advanced 10 further commits (a8189aef4 -> b50c0ef27); computed the file overlap with my diff, found NONE, merged again cleanly, re-verified both ids and the registry gate, and pushed. Final head bf7e01142. No pin was weakened, deleted or adjusted at any point.",
      "tests": "All at final head bf7e01142 unless noted; tree clean, everything pushed. (1) BOTH ADR-0087 ids present in entry file AND registry body, re-verified after the SECOND merge too: driver-sql-upsert-cross-row-identity-merge-refused -> entry PRESENT / registry PRESENT; engine-dotted-filter-refused -> entry PRESENT / registry PRESENT. check:migration-registry PASS ('src/migrations/registry.ts is current (97 semantic, 30 retired-key, 54 retired-def)'). (2) RATCHETS re-run rather than assumed, on a BUILT workspace: check:type-check-debt --re-measure OK — '33 ledger entr(ies) re-measured, 1926 raw tsc error(s) total, none above its recorded number'; check:query-options-erasure PASS. The merge imported #8936's test surface and neither ratchet moved above its ceiling. (3) The ENTIRE 48-gate ESLint job re-run at the merged head bf46f6379 — ALL PASS, check:tenant-chokepoint included; re-confirmed at the final head after the second merge (tenant-chokepoint, nul-bytes, migration-registry all PASS). (4) The two BRAND-NEW gates that landed on main in the second merge window were run explicitly on my branch: check-examples-live-imports.mjs PASS, check-release-page-status.mjs PASS. (5) LIVE MySQL 8.0.46 at the final head: 'Test Files 1 passed / Tests 38 passed | 2 skipped'. #8936 added a dotted-filter refusal at both doors; no fixture of mine uses a dotted key, so nothing met it — checked rather than assumed. (6) Full sweep post-merge: driver-sql 1724 passed | 56 skipped (re-confirmed at final head), driver-turso 1002 passed, driver-sqlite-wasm 394 passed, spec 10719 passed (406 files), metadata-protocol 1512 passed (106 files). (7) ⭐ objectql is now 211 files / 3721 tests ALL PASSED — zero failures. The #8937 UTC-midnight red is GONE: the merge picked up main's fix for it, so the one pre-existing failure I reported in both previous rounds no longer exists on this branch. (8) driver-sql typecheck PASS. NOTE on process: two foreground runs hit the harness's 10-minute call cap (exit 143) and one lock acquire returned exit 99. I did not treat any of them as failure or end a turn on them — I inspected the lock with fuser each time, found first my own orphaned child and later a SIBLING agent's run (issue-8908, dogfood/http-conformance/cli), waited actively in-turn and re-acquired, splitting the package sweep into single-package runs to fit the cap. Nothing was killed by name; the sibling's worktree was left untouched.",
      "open_questions": [],
      "out_of_scope_findings": [
        "#8937 is RESOLVED as of this merge — main's fix landed and objectql is fully green on this branch (3721/3721). Withdrawing it as a caveat on this PR; it no longer holds anything.",
        "NOT filed, reported instead: check:type-check-debt still reports a -1 surplus in @objectstack/lint (records 20, tsc reports 19), lowerable via --lower. Pre-existing, in a package this card does not touch, printed by the gate on every run as explicitly 'not an error'. PM's call.",
        "#6943 autonumber re-seed reachability: untouched and unmeasured, as ruled."
      ]
    }

    Generated by Claude Code

  9. added a commit that references this issue on Oct 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions