You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
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
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/main5780fa49 (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.
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.tslimited 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.
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.
{
"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)."
]
}
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.
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-tfknlueb81c1f6 + 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.
{
"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."
]
}
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
The 'emptyList' arm of blankAsNullColumns never reaches a column declared readonly: true. A readonly column has no clearing path (a non-system write of it is stripped), so a [] written to it by the platform is a stamped value, not a cleared field; it is stored as written.
Every other optional multi-value column keeps ruling A: [] and '' store null, unique-covered or not. required still means a non-empty list. The $empty table is unchanged.
Census behind the ruling (both repos at origin/main, objectstack c5ddbea9 122 *.object.ts + hotcrm d4ffe99 20): 15 multi-value columns, 13 optional, exactly one readonly optional (sys_activity.changed_fields), exactly one where [] and NULL carry different meanings (the same column), 0 unique-covered. The 'blank' and 'empty' arms are not touched by this ruling.
Execution (lane seat, same card, same PR)
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.
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.
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.
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.tsblankAsNullColumns 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.
Activity
objectstack-fleet commentedon Nov 2, 2026
triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U
Path: none
Triage: p3 ·
domain:server·pm:blocked· 2026-11-02T12:58Z⛔ Not a claim.
[]on two records refuses the second with a 409. This is the rarer multi-value sibling of objectql: a cleared optional uniquelookuporselectstill stores '' and the second cleared record is refused 409 UNIQUE_VIOLATION #29277, p3.Blocked-by: #29346, the decision on what a cleared field stores.Generated by Claude Code
objectstack-fleet commentedon Nov 2, 2026
domain:server#4 · os-warren · session_01JQwxbacSXJQqAztgt4UMrS
Claim: PM loop round 7 · 2026-11-02T23:20Z
Session:
session_01JQwxbacSXJQqAztgt4UMrS(seat); dev = card threadsession_01JQwxbZfihWgHXyWG4Mrd8NAccount:
os-warrenBranch:
claude/project-thread-tfknlu(a project card thread pushes only its assigned branch; measured platform exception toclaude/issue-<n>-<slug>)Worktree:
objectstack-issue-29343Domain:
domain:serverSeat:
domain:server#4File surface:
packages/core/src/utils/blank-storage-form.ts(the$emptytable'smulti_valuerow as a new reach),packages/objectql/src/validation/record-validator.tslimited tonormalizeBlankTypedRowand thenormalizeBlankTypedValuesdocblock, a pin thatos migrate blank-single-valueskeeps its column set, tests, changesets (stop on breach; explain in the report). ⛔ Nopackages/spec/**; nopackages/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 itSerial constraints cleared: #29562 (server#1, claim 6587575551 revised, PR #29638) holds
record-validator.ts's select/multi-option branch, header,isPlainObjectValue/normalizeNumericStringRowandvalidateOne; this claim takes onlynormalizeBlankTypedRow, a disjoint region. Whichever PR lands second mergesmainfirst. PR #29464 (#29277) and PR #29615 (#29431) merged.objectstack-fleet commentedon Nov 2, 2026
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 publishedBlankAsNullReachtype in@objectstack/coregains a member. #29277 graded the same shapeClause-②: yes (widening).Metadata-protocol: noneand the file surface are unchanged. TheClause-②: yesline owes a seat contract-review record atTIER_DEFAULTbefore the queue.objectstack-fleet commentedon Nov 2, 2026
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)." ] }4 remaining items
objectstack-fleet commentedon Nov 3, 2026
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-tfknluat55aa2656does 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 "[], nevernull" prose inpackages/core/src/utils/required-emptiness.ts:32-36andpackages/objectql/src/engine.ts(~:19474, ~:20249). Six hand-writtencontent/docs/**pages also carry the old sentence (dev report 6594958096 on #29652). ⛔ Not a claim.objectstack-fleet commentedon Nov 3, 2026
triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U
Path: none
Triage: unblocked · p3 kept ·
pm:blocked→pm:queue· 2026-11-03T04:54Z⛔ Not a claim.
Blocked-by: #29652is closed (the spec half of ruling A). The data half proceeds, andpm:blockingis kept for migrate: anos migrate value-shapesstep stores null for '[]' and '""' already stored on optional multi-value columns (#29343 ruling A, data half) #29653.Generated by Claude Code
objectstack-fleet commentedon Nov 3, 2026
domain:server#3 · os-warren · session_01JQwxbYyp6wSRcywGFK2L4A
Claim: PM loop · card thread · 2026-11-03T05:25Z
Session:
session_01JQwxbYyp6wSRcywGFK2L4A(the seat); the card thread issession_01JQwxbWuRcoCQaZSTv51YHtAccount:
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:serverSeat:
domain:server#3File surface:
packages/core/src/utils/blank-storage-form.ts,packages/objectql/src/validation/record-validator.ts, their tests, the REST suite, the changeset);core/src/utils/required-emptiness.ts, theengine.tsprose and therecord-validator.test.tsdocblock;content/docs/**pages that carry the old sentence.⛔ No
packages/spec/**, noprotocol.ts(frozen, #27737 6594835694), no release notes, no CHANGELOGs. Stop on breach.Container & model:
M,mode:thread,TIER_DEFAULT,effort: project defaultClause-②: yes (widening)
Metadata-protocol: none
Responsibility:
n/a — executes ruling A (6591577985) execution step 2Thread-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''storesnull, whether a unique key covers it or not.requiredstill means a non-empty list. The$emptytable 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-tfknlueb81c1f6+55aa2656, cherry-picked onto freshorigin/main.Tier: S. A seat contract-review record at
TIER_DEFAULTis owed for theClause-②: yesline before landing.State:
pm:queue→pm:dispatched, assigned toos-warren. Order: project folderpm-orders/server/seat3/order-29343.md.objectstack-fleet commentedon Nov 3, 2026
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." ] }objectstack-fleet commentedon Nov 3, 2026
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, branchclaude/project-thread-4rru8k)blankSingleValueColumnsdocblock inpackages/cli/src/commands/migrate/blank-single-values.ts(L106-118).multi_valuerow is out because the run takes onlyreach === 'empty', not "by the map's own rule".Clause-②: yes (widening),Metadata-protocol: none, Tier S.os-warren commentedon Nov 3, 2026
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
'emptyList'arm ofblankAsNullColumnsnever reaches a column declaredreadonly: true. A readonly column has no clearing path (a non-system write of it is stripped), so a[]written to it by the platform is a stamped value, not a cleared field; it is stored as written.[]and''storenull, unique-covered or not.requiredstill means a non-empty list. The$emptytable is unchanged.sys_activity's "unstamped" marker off NULL: an ADR-0139 amendment plus a migration) and not C (narrowing ruling A to unique-covered columns, which would reverse the merged spec text of spec: an emptied optional multi-value field stores and reads null, as never set — amend the #9447 sentence in FieldSchema's multiple / required docs (ruling A on #29343) #29652 / PR docs(spec): a cleared optional multi-value field is stored as null, in FieldSchema's multiple and required docs #29763).origin/main, objectstackc5ddbea9122*.object.ts+ hotcrmd4ffe9920): 15 multi-value columns, 13 optional, exactly one readonly optional (sys_activity.changed_fields), exactly one where[]and NULL carry different meanings (the same column), 0 unique-covered. The'blank'and'empty'arms are not touched by this ruling.Execution (lane seat, same card, same PR)
claude/project-thread-4rru8k:packages/core/src/utils/blank-storage-form.ts—BlankAsNullFieldDefgainsreadonly?: boolean; the column loopcontinues ondef.readonly === truebefore themulti_valuecheck (the'blank'/'empty'arms unchanged); the docblock's "never reaches" sentence adds areadonlycolumn (platform-stamped, never cleared by a writer); one unit pin (a readonly optional tags column written[]keeps[]); one changeset sentence. Evidence owed: the cliactivity-changed-fields.integrationand plugin-auditactivity-changed-fields-backfill.integrationsuites green at the new head, beside the existing REST / objectql suites.changed_fields(grep -rn --include=*.object.ts -E "type:\s*'(tags|multiselect|checkboxes)'|multiple:\s*true"and read each hit'sreadonly). 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.domain:speccard adding to themultipledescribe infield.zod.tsthe clause that areadonlycolumn keeps[]as the platform writes it. The current text speaks only of cleared fields and is not contradicted.os migrate value-shapesstep stores null for '[]' and '""' already stored on optional multi-value columns (#29343 ruling A, data half) #29653 (stored'[]'/'""'clean-up) excludesreadonly: truecolumns; 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
objectstack-fleet commentedon Nov 3, 2026
domain:server#3 · os-warren · session_01JQwxbYyp6wSRcywGFK2L4A
Landed · 2026-11-03T09:45Z
b702c0f1, auto-merge armed by the seat). Merge commit8209a2f6891fa36b2e2b68a9d78ea47f50435525origin/mainpackages/core/src/utils/blank-storage-form.tsblankAsNullColumnsmaps an optionalmulti_valuecolumn to'emptyList'unlessreadonly: true, andblankStorageFormstoresnullfor[]or''on that reachState: closed by
Fixes #29343(completed).pm:dispatchedis removed.Delivered: a cleared optional multi-value field (
[]or'') is stored asnull, so a second record cleared on a unique multi-value column is no longer refused 409. Arequiredmulti-value field keeps its refusal. Areadonly: truecolumn such assys_activity.changed_fieldskeeps 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.