Skip to content

objectql: a multi-value unique column (multiselect, or lookup with multiple: true) cleared to [] on two records refuses the second with 409 UNIQUE_VIOLATION #29343

Closed

Description

@objectstack-fleet

domain:server#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67

Ruled: 6591577985 · letter A · 2026-11-02T23:53Z
Blocked-by: #29652

Filing gate: ① out-of-scope finding of the #29277 dev (os-dev-report 6577038603, out_of_scope_findings class a), measured with a scratch probe at origin/main 5780fa49 (SQLite SqlDriver through ObjectQL). Filed by domain:server seat 3. ⛔ Not a claim. Suggested-grade: p3 · the multi-value sibling of #29277, a rarer shape (a unique multi-value column).

Reach: an object with a multiselect field, or a lookup with multiple: true, declared unique (or in a unique indexes[] entry), when two records are saved with that field empty ([], or '').
Reader who acts: a domain:server seat (packages/objectql/src/validation/record-validator.ts, the write door #29176 extended), after the ruling on the #29277 decision card, because the same "what does a cleared field store" question governs it.

What happens (dev's scratch probe)

  • multi-value unique column written [] on record 1 → lands.
  • the same column written [] on record 2 → refused DUPLICATE_RECORD (409 UNIQUE_VIOLATION at REST). The same happens with ''.

Wanted

Take the ruling on the #29277 decision card first (whether a cleared field stores null). Then either store an empty multi-value as null on unique-covered optional columns, so two empty records do not collide, or give the reading the ruling implies. The multi_value row of the $empty table already reads [] as empty, so check whether [] → null moves any reader before choosing, and report what moves.

Dedupe: searched unique multiselect empty list 409, multi lookup UNIQUE_VIOLATION empty; nearest #29277 (single-value). New.

Links back: #29277, #29176.

Activity

  1. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    ContributorAuthor

    triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

    Path: none

    Triage: p3 · domain:server · pm:blocked · 2026-11-02T12:58Z

    ⛔ Not a claim.


    Generated by Claude Code

  2. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#4 · os-warren · session_01JQwxbacSXJQqAztgt4UMrS

    Claim: PM loop round 7 · 2026-11-02T23:20Z
    Session: session_01JQwxbacSXJQqAztgt4UMrS (seat); dev = card thread session_01JQwxbZfihWgHXyWG4Mrd8N
    Account: os-warren
    Branch: claude/project-thread-tfknlu (a project card thread pushes only its assigned branch; measured platform exception to claude/issue-<n>-<slug>)
    Worktree: objectstack-issue-29343
    Domain: domain:server
    Seat: domain:server#4
    File surface: packages/core/src/utils/blank-storage-form.ts (the $empty table's multi_value row as a new reach), packages/objectql/src/validation/record-validator.ts limited to normalizeBlankTypedRow and the normalizeBlankTypedValues docblock, a pin that os migrate blank-single-values keeps its column set, tests, changesets (stop on breach; explain in the report). ⛔ No packages/spec/**; no packages/metadata-protocol/src/protocol.ts (seat 5 freeze until 2026-11-03 06:00Z).
    Container & model: M, mode:thread (the os-dev agent type is not loaded in seat sessions), model: TIER_DEFAULT, effort: project default (the maintainer sets effort)
    Clause-②: no
    Metadata-protocol: none
    Responsibility: n/a — executes #29346 ruling B execution step 2 (record 6579813296)
    Thread-read: 6577739107
    Premise: to measure (dev reproduces the second [] refused 409 per type first); source reading: the door's row walk skips non-string values before the core rule, so [] never reaches it
    Serial constraints cleared: #29562 (server#1, claim 6587575551 revised, PR #29638) holds record-validator.ts's select/multi-option branch, header, isPlainObjectValue / normalizeNumericStringRow and validateOne; this claim takes only normalizeBlankTypedRow, a disjoint region. Whichever PR lands second merges main first. PR #29464 (#29277) and PR #29615 (#29431) merged.

  3. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#4 · os-warren · session_01JQwxbacSXJQqAztgt4UMrS

    Claim amendment · 2026-11-02T23:31Z (amends 6591005948)
    Clause-②: yes (widening)

    The measured diff lands a second [] / '' on a unique multi-value column that used to be refused 409, and the published BlankAsNullReach type in @objectstack/core gains a member. #29277 graded the same shape Clause-②: yes (widening). Metadata-protocol: none and the file surface are unchanged. The Clause-②: yes line owes a seat contract-review record at TIER_DEFAULT before the queue.

  4. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#4 · os-warren · session_01JQwxbZfihWgHXyWG4Mrd8N
    os-dev-report

    {
      "issue": 29343,
      "status": "needs_decision",
      "branch": "claude/project-thread-tfknlu",
      "pr": null,
      "session": "session_01JQwxbZfihWgHXyWG4Mrd8N",
      "premise_still_valid": true,
      "summary": "Defect reproduced red per type at origin/main 322cf906 on SQLite through the REST doors: a second record created or PATCH-cleared with [] (or '') on a unique multiselect, a unique multi-value lookup, a unique tags field, or a unique-index member is refused 409 UNIQUE_VIOLATION ([] is stored as the JSON text '[]', '' as '\"\"'). The ordered route (store [] as null through blankAsNullColumns, new reach 'emptyList') is built and pushed (eb81c1f6 + changeset 55aa2656, no PR) and turns the new REST suite green 15/15, but it breaks a binding maintainer contract: #9447's ruling, published in packages/spec/src/data/field.zod.ts (the multiple and required doc blocks and the multiple describe shipped to authors), says an emptied multi-value lookup reads back as [], never null, and that this binds every writer (cascade repair, form clears, API writes). The card asked to check which readers [] to null moves before choosing; it moves that contract, so I stopped before the PR. Which ruling wins is the maintainer's call (#29346 B step 2 does not name #9447).",
      "tests": "Red first, base dist at 322cf906: pnpm --filter @objectstack/rest exec vitest run src/rest-data-multi-value-blank.test.ts: 12 failed / 3 passed (15); every per-type unique case [201, 409] or [200, 409], SQLite 'UNIQUE constraint failed: mvb_obj.u_multiselect' / u_lookup_multi / u_tags / (grp, i_tags). After core+objectql rebuild: the same file plus rest-data-single-value-blank and rest-data-optional-unique-blank: 3 files, 34/34 passed. core test exit 0, core typecheck exit 0, objectql typecheck exit 0. objectql test exit 1: 3 failed / 10512 passed (542 files): engine-cascade-delete-multivalue-probe.test.ts 'removing the LAST member writes [], never null, the ruled representation' and 'every dependent row loses the member, each keeping its own others'; engine-cascade-delete-user-field.test.ts 'multi-valued: only the deleted member is removed, and an emptied set is []'. Same contract also pinned in packages/runtime/src/cascade-delete-multivalue-lookup-real-driver.integration.test.ts (line 550), packages/services/service-automation/src/builtin/delete-record-predicate-walk-batched.integration.test.ts (line 216) and record-validator.test.ts (docblock line 646); those were not run. rest local, metadata-protocol seed-loader and the cli integration pin were not run (stopped). No ablation (no PR).",
      "gates": "NOT MEASURED: dispatch-gates --commands and pnpm lint not run, reason: stopped at needs_decision before a PR.",
      "dogfood_battery": "n/a: no PR, no (narrowing) arm",
      "mcp_calls": "0",
      "api_writes": "1: this os-dev-report comment via post-stamped.mjs (POST issues/29343/comments). git push of the branch is not a REST write.",
      "open_questions": [
        {
          "question": "A cleared optional multi-value field: which representation wins on the write door, #9447's [] (never null) or #29346 B step 2's null?",
          "options": [
            "A: null wins for optional columns (the branch as built). The write door stores [] and '' as null on optional multi_value-row columns; cascade set_null repair that empties a set then also stores null through the door. Owes: a spec amendment of the multiple and required doc blocks and the multiple describe (packages/spec, spec seat, Metadata-protocol: semantic, contract review), the 3 objectql pins and the runtime / service-automation integration pins moved to null, and a maintainer word because it reverses a maintainer ruling. Cost: small code (done), one spec PR, test moves. Readers already meet null: a never-set multiselect reads back null today (measured through engine and REST).",
            "B: [] wins (keep #9447). The door writes '' as [] on these columns and leaves [] alone; the 409 is fixed in the drivers instead: the unique index on a multi-value column treats an empty list as no value (driver-sql partial unique index where the column is not null and not '[]', memory driver key model skips []). Cost: driver DDL change plus an index rebuild for deployed tables, MySQL has no partial index (generated column needed), every driver measured. Larger, and a second reading of empty beside the single-value arm.",
            "C: refuse unique on a multi-value field at parse. A unique over a whole JSON list is ill-defined (['a','b'] and ['b','a'] are distinct). Spec narrowing, BREAKING, owes a migration for existing declarations; the 409 disappears with the declaration."
          ],
          "recommendation": "A, because the platform already reads null for a multi-value field that was never set, so the [] guarantee covers only emptied rows and readers already need the null branch; A matches the direction #29346 ruling B states for the single-value half and keeps one reading of empty at the door. It reverses #9447, so it needs the maintainer's word and a spec PR first; this card would then land serial after it."
        }
      ],
      "security_findings": "none",
      "out_of_scope_findings": [
        "carrier: seat, noted, not filed: packages/cli/src/commands/migrate/blank-single-values.ts docblock says the blankAsNullColumns map holds three arms and that multiple: true columns are out by the map's own rule; under option A they are in the map on reach 'emptyList' and out of that walk by its reach filter (pinned on the branch in blank-single-values.integration.test.ts).",
        "carrier: seat, noted, not filed: stored [] / '\"\"' rows on multi-value columns would need a clean under option A (order step 3: report, not build)."
      ]
    }
  5. 4 remaining items

  6. objectstack-fleet commented on Nov 3, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:spec#3 · os-support-ai · session_01WGufzfgvGexKn9vLHsy89K

    For the seat that lands this card: the spec half is PR #29763 (#29652), contract review PASS 6595089655. That record (section ③) names prose and three pins the built branch claude/project-thread-tfknlu at 55aa2656 does not move but its door change will turn red: packages/objectql/src/engine-cascade-delete-multivalue-probe.test.ts:463,525,533, packages/runtime/src/cascade-delete-multivalue-lookup-real-driver.integration.test.ts:550, packages/services/service-automation/src/builtin/delete-record-predicate-walk-batched.integration.test.ts:216, plus the "[], never null" prose in packages/core/src/utils/required-emptiness.ts:32-36 and packages/objectql/src/engine.ts (~:19474, ~:20249). Six hand-written content/docs/** pages also carry the old sentence (dev report 6594958096 on #29652). ⛔ Not a claim.

  7. objectstack-fleet commented on Nov 3, 2026

    @objectstack-fleet
    ContributorAuthor

    triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

    Path: none

    Triage: unblocked · p3 kept · pm:blocked → pm:queue · 2026-11-03T04:54Z

    ⛔ Not a claim.


    Generated by Claude Code

  8. objectstack-fleet commented on Nov 3, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#3 · os-warren · session_01JQwxbYyp6wSRcywGFK2L4A

    Claim: PM loop · card thread · 2026-11-03T05:25Z
    Session: session_01JQwxbYyp6wSRcywGFK2L4A (the seat); the card thread is session_01JQwxbWuRcoCQaZSTv51YHt
    Account: os-warren (the seat's linked user; the card's assignee)
    Branch: claude/project-thread-4rru8k (the card thread's harness-pinned branch; it names no issue, so this claim is the identity record)
    Worktree: the card thread's own container checkout
    Domain: domain:server
    Seat: domain:server#3
    File surface:

    • the built branch's 8 files (packages/core/src/utils/blank-storage-form.ts, packages/objectql/src/validation/record-validator.ts, their tests, the REST suite, the changeset);
    • the pins and prose that spec record 6595089655 names (via 6595097212): the objectql, runtime and service-automation cascade pins, core/src/utils/required-emptiness.ts, the engine.ts prose and the record-validator.test.ts docblock;
    • the hand-written content/docs/** pages that carry the old sentence.

    ⛔ No packages/spec/**, no protocol.ts (frozen, #27737 6594835694), no release notes, no CHANGELOGs. Stop on breach.
    Container & model: M, mode:thread, TIER_DEFAULT, effort: project default
    Clause-②: yes (widening)
    Metadata-protocol: none
    Responsibility: n/a — executes ruling A (6591577985) execution step 2
    Thread-read: 6595801732
    Premise: holds. Ruling A is recorded, its spec half #29652 / PR #29763 is merged, and triage unblocked the card at 04:54Z. The built form is claude/project-thread-tfknlu (eb81c1f6 + 55aa2656, os-dev-report 6591387317), which seat 4's release 6591530898 kept.
    Seat reading: an optional multi-value column written [] or '' stores null, whether a unique key covers it or not. required still means a non-empty list. The $empty table is unchanged. Cleaning up values already stored as '[]' / '""' is #29653's job, not this card's.
    Serial constraints cleared: no seat-3 card in flight touches these files; seat 4's claim 6591005948 was released (6591530898).
    Resume-from: claude/project-thread-tfknlu eb81c1f6 + 55aa2656, cherry-picked onto fresh origin/main.
    Tier: S. A seat contract-review record at TIER_DEFAULT is owed for the Clause-②: yes line before landing.

    State: pm:queue → pm:dispatched, assigned to os-warren. Order: project folder pm-orders/server/seat3/order-29343.md.

  9. objectstack-fleet commented on Nov 3, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#3 · os-warren · session_01JQwxbWuRcoCQaZSTv51YHt
    os-dev-report

    {
      "issue": 29343,
      "status": "done",
      "branch": "claude/project-thread-4rru8k",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/29837",
      "session": "session_01JQwxbWuRcoCQaZSTv51YHt",
      "premise_still_valid": true,
      "summary": "Executes ruling A (6591577985) step 2. Cherry-picked eb81c1f6 and 55aa2656 cleanly onto origin/main 97c2982c (now ce827aa2 and f147106e): an optional column on the $empty table's multi_value row written [] or '' stores null (core blankAsNullColumns reach 'emptyList', objectql door row walk passes the empty list). Commit f0afc329 moves to null what that left red or wrong: the objectql probe pins (probe :463 docblock, :525/:533 LAST-member pin, the every-dependent-row pin) and the engine-cascade-delete-user-field multi-valued pin, the runtime real-driver pin (:550) and the service-automation batched-walk pin (:216); replaces the '[], never null' prose for optional columns in core required-emptiness.ts, objectql engine.ts (set_null doc and member-removal comment) and the record-validator.test.ts docblock, keeping the required half; and rewrites the six hand-written docs pages (data-api, field-types, fields, types, validation-rules, troubleshooting). The changeset now names the ruling, the Clause-2 widening and the read change. Merged origin/main b62463f1 (8e4ba024) before the PR. No packages/spec, no protocol.ts, no release notes or CHANGELOGs; stored '[]' cleanup left to #29653.",
      "tests": "Red first at origin/main 97c2982c (own build): rest src/rest-data-multi-value-blank.test.ts 12 failed / 3 passed (15), every unique per-type case 409 on the second [] or ''. At head 8e4ba024: objectql test 545 files 10767/10767 (also green before the merge); core test 132 files 2936/2936; REST multi-value-blank + single-value-blank + optional-unique-blank 34/34; objectql targeted 5 files 191/191; runtime cascade-delete-multivalue-lookup-real-driver.integration 7 passed 1 skipped (Postgres cell, needs OS_TEST_POSTGRES_URL); service-automation delete-record-predicate-walk-batched.integration 4/4; metadata-protocol seed-loader 26 files 257/257; cli blank-single-values.integration 6/6 (integration project). Typecheck exit 0: core, objectql, runtime, service-automation. Ablation: blankStorageForm 'emptyList' arm made to return the value unchanged via scripts/ablation-replace.mjs, core rebuilt, ablation-dist-preflight exit 0; objectql 5 failed / 38 passed (two probe pins, the user-field pin, two door unit pins), REST 12 failed / 3 passed. Restore: blob == HEAD, git diff HEAD empty, rebuilt, preflight --absent exit 0, same files 43/43 and 15/15. A first ablation attempt put its marker in a comment, which the build strips; preflight exit 1, that run void and repeated with a code marker.",
      "gates": "dispatch-gates --repo objectstack-ai/objectstack --commands at 8e4ba024: 101 derived. All run, exit codes captured before any pipe. 94 exit 0 on the first pass; 7 did not measure on the first pass: check-plugin-teardown-shape --self-test exit 3 (pinned fixture commit 621a4876 absent on the shallow clone; fetched it), spec check:skill-examples exit 3, check:dual-build-cjs-loads exit 3, check:i18n / check:i18n-coverage / check:i18n-walk-parity exit 3 (PREREQUISITE NOT MET: client SDK and CLI not built), check:dts-closure exit 1 (driver-sql and platform-objects dist d.ts missing while a sibling build of mine was rewriting them). After building the client-react closure and the packages closure: all 7 re-ran exit 0. dispatch-gates --ran: 101 derived, 101 run, 0 NOT-MEASURED, 0 UNRUN, exit 0. check-platform-checklist not owed (no export renamed or deleted; BlankAsNullReach gains a member). PR 29837 check-runs at head 8e4ba024 when this was written: 34 total, 22 in_progress, 5 success, 7 skipped, 0 failed; CI owns pnpm lint, test:repo and the full farm.",
      "dogfood_battery": "n/a: no ! in the PR title and Clause-2 carries the (widening) arm, not (narrowing)",
      "mcp_calls": "0 GitHub MCP calls. (One non-GitHub claude-code-remote get_session call to read this session's own id.)",
      "api_writes": "2: pr_create via fleet-write relay (POST pulls, PR 29837, 8162 bytes sent and stored identical, run 41592717857); this os-dev-report comment via post-stamped.mjs (POST issues/29343/comments). git push is not a REST write.",
      "open_questions": [],
      "security_findings": "none",
      "out_of_scope_findings": [
        "carrier: seat, noted, not filed: packages/cli/src/commands/migrate/blank-single-values.ts docblock (near line 109) says the blankAsNullColumns map holds three arms; it now holds four ('emptyList' joins). Outside this claim's file surface. Behaviour is pinned by blank-single-values.integration.test.ts (6/6, the walk keeps its column set). Prose only.",
        "carrier: #29653: stored '[]' and '\"\"' rows on optional multi-value columns still collide on a unique column and still read [] until that data step runs; stated in the changeset."
      ]
    }
  10. objectstack-fleet commented on Nov 3, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#3 · os-warren · session_01JQwxbYyp6wSRcywGFK2L4A

    Claim amendment · #29343 · 2026-11-03T06:57Z · amends the claim on this card (same seat session, card thread session_01JQwxbWuRcoCQaZSTv51YHt, branch claude/project-thread-4rru8k)

  11. os-warren commented on Nov 3, 2026

    @os-warren
    Collaborator

    decision thread · os-warren · session_01JQwxbZDLhDmQ5mv7SJjZzt

    Ruling: decision card (thread "Decide empty multi-value null exceptions") · letter A · maintainer tap on 「A:emptyList 臂跳过 readonly 列」 — 「清空存 null 的规则,readonly 列怎么处理?」 2026-11-03T08:17Z

    Analysis-tier: CONTRACT_REVIEW_TIER (the decision thread's analysis, under the maintainer's standing order 「需要决策的问题,开fable thread 分析后和我沟通」 2026-11-02T23:48Z). This ruling is an execution detail of ruling A (record 6591577985), which stands unchanged; it answers contract-review FAIL record 6597870397 on PR #29837.

    Governing text: ruling A 6591577985 ("a cleared optional multi-value field … is stored as null"); FieldSchema.readonly (packages/spec/src/data/field.zod.ts:2408: a readonly field is written only by the engine, a non-system write is stripped on INSERT and UPDATE); ADR-0139 D4 / E6 (sys_activity.changed_fields: [] = stamped, nothing changed; NULL = unstamped, withheld from every non-system reader; activity-field-redaction.ts:407-411); blank-storage-form.ts's existing exclusion of engine-owned lifecycle columns from every arm.

    What the ruling binds

    Execution (lane seat, same card, same PR)

    1. PR fix(objectql): a cleared optional multi-value field stores null, so a second record cleared on a unique one is no longer refused 409 #29837 takes a patch round on claude/project-thread-4rru8k: packages/core/src/utils/blank-storage-form.ts — BlankAsNullFieldDef gains readonly?: boolean; the column loop continues on def.readonly === true before the multi_value check (the 'blank' / 'empty' arms unchanged); the docblock's "never reaches" sentence adds a readonly column (platform-stamped, never cleared by a writer); one unit pin (a readonly optional tags column written [] keeps []); one changeset sentence. Evidence owed: the cli activity-changed-fields.integration and plugin-audit activity-changed-fields-backfill.integration suites green at the new head, beside the existing REST / objectql suites.
    2. Premise the dev re-checks before the patch: the readonly optional multi-value columns of both repos are still only changed_fields (grep -rn --include=*.object.ts -E "type:\s*'(tags|multiselect|checkboxes)'|multiple:\s*true" and read each hit's readonly). A second such column with the same []/NULL meaning changes nothing; a column that contradicts the premise is reported as a fork, ⛔ not patched around.
    3. Optional, not blocking: a prose-only domain:spec card adding to the multiple describe in field.zod.ts the clause that a readonly column keeps [] as the platform writes it. The current text speaks only of cleared fields and is not contradicted.
    4. migrate: an os migrate value-shapes step stores null for '[]' and '""' already stored on optional multi-value columns (#29343 ruling A, data half) #29653 (stored '[]' / '""' clean-up) excludes readonly: true columns; its dispatch order cites this record.

    Full analysis (census table, options, four-axis reasoning) is in the project folder: pm-orders/decisions/decision-29343-r2-empty-list-readonly.md.


    Generated by Claude Code

  12. objectstack-fleet commented on Nov 3, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#3 · os-warren · session_01JQwxbYyp6wSRcywGFK2L4A

    Landed · 2026-11-03T09:45Z

    Probe Reading
    PR #29837 MERGED at 2026-11-03T09:44Z by the merge queue (seat ACCEPT 6599722699 on head b702c0f1, auto-merge armed by the seat). Merge commit 8209a2f6891fa36b2e2b68a9d78ea47f50435525
    origin/main packages/core/src/utils/blank-storage-form.ts blankAsNullColumns maps an optional multi_value column to 'emptyList' unless readonly: true, and blankStorageForm stores null for [] or '' on that reach

    State: closed by Fixes #29343 (completed). pm:dispatched is removed.

    Delivered: a cleared optional multi-value field ([] or '') is stored as null, so a second record cleared on a unique multi-value column is no longer refused 409. A required multi-value field keeps its refusal. A readonly: true column such as sys_activity.changed_fields keeps the [] the platform stamps (ruling A, 6591577985; round 2 A, 6598733139). Stored '[]' rows are not migrated here: #29653 owns that, scoped by note 6598764965 to exclude readonly columns. Nothing else is left on this card.

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions