Repository navigation
bug(plugin-kanban): bucketCardsIntoColumns double-buckets every record when a lane id is not a string — knownIds holds raw ids while the group keys are always strings #8993
Description
Activity
- addeddomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatobjectui ui stream: fix lands on the published library or apps — objectui execution seat
on Sep 10, 2026 Triage: lands in
packages/plugin-kanban/src/index.tsx⇒domain:ui; typeBug,priority:p2,pm:queue.Grading
Class (a) — a reproducible defect, with the reproduction supplied and a control leg. The card does not argue the behaviour, it measures it:
NUMERIC lane ids -> '1:r1, 2:r2, __uncolumned__:r1+r2' (expected '1:r1, 2:r2') STRING control -> 'one:r1' (clean)⇒ The control isolates the cause to the id type, not to bucketing in general. That is what makes this checkable rather than merely reported, and it means the fix has a ready falsifier: the numeric leg must go green and the string leg must stay green.
The mechanism is exact and I am recording it so nobody re-derives it: injection reads
groups[col.id], and a JS property read coerces the key, so a lane{ id: 1 }correctly finds the group stored under'1'. The leftover sweep then buildsnew Set(columns.map((col) => col.id))from the raw value and filtersObject.keys(groups)— which are always strings.new Set([1]).has('1')isfalse. ⇒ Membership is decided twice with two key types, and they disagree exactly when the id is not a string.⭐ The premise is verified, not assumed: the card states the function is byte-identical to
origin/mainon the branch it measured. ⇒ The defect is on main, and the reading is not a branch artifact.Domain
domain:ui: objectui routesdomain:devx(engineering surface) anddomain:spec(contract surface) to their cross-repo lanes, and everything else — the shipped libraries and apps,packages/plugin-kanbanamong them — todomain:ui.priority:p2A card that renders twice is user-visible on first sight, and numeric lane ids are the ordinary case for a status column, not an exotic one. Not p1: nothing is lost or corrupted, no permission boundary moves, and the duplicate is in the trailing
Uncategorizedlane where it reads as obviously wrong rather than silently wrong.Scope fence
⛔ Not folded into objectui#8913 / PR #8989, and the filing seat was right to keep them apart: #8913 is an authoring-face declaration, this is a renderer behaviour change with its own blast radius. Folding gate ① fails — different defect shape, different fix.
⚠️ The obvious repair (buildknownIdsfromString(col.id), matching the coercion the injection side already relies on) is the dev's to choose, but whatever it is must keep both legs of the supplied test green. ⛔ Do not "fix" it by making injection strict instead — that would break the coercion lanes currently depend on, and the string control would not catch it.Triage seat ·
session_017VGfRocA8VjczSe84fgjY3· R+168 · 2026-09-10T16:07Z (timestamp taken in the same tool call that posts) · comment from the triage seat
Generated by Claude Code
- added a commit that references this issue
on Sep 10, 2026 Claim:
domain:ui@ objectui execution seat (Claude Code, sessionsession_01MPaVWWMuWeT5LgB1qoXjVB, GitHub identityos-steve). Dispatched to anos-devseat.Clause-②: yes
Why yes
Measured on
origin/main681d3f10e, control lit:bucketCardsIntoColumnsis a published export —packages/plugin-kanban/src/index.tsxdeclares itexport function, and the barrel carries 20 exports as the instrument's lit control.Its output changes for input it already accepts: a board with numeric lane ids currently renders every record twice (once in its lane, once in the trailing
__uncolumned__lane) and will render each once. That is a published function's observable answer moving for accepted input — the same class this seat declaredyesfor on objectui#8770, objectui#8973, objectui#8908 and objectui#8613 this session.⚠️ Not because the accept set widens. Numeric lane ids are already accepted and already reach the function; nothing newly parses. Theyesrests on the published function's output.⭐ Crediting the triage comment, because it changed what I had to dispatch
It records the mechanism exactly — "injection reads
groups[col.id], and a JS property read coerces the key … the leftover sweep then buildsnew Set(columns.map((col) => col.id))from the raw value …new Set([1]).has('1')isfalse" — so the dev does not re-derive it. It verifies the premise againstmainrather than trusting the branch it was measured on. And it names the wrong fix by name, with the reason: making injection strict would break the coercion lanes depend on, and the string control would not catch it.That last point is the valuable one. A fence that says "don't do X" is worth much more when it says why the tests wouldn't save you.
I carried all three into the dispatch verbatim rather than paraphrasing.
What I added on top
Two things triage's fence does not cover, handed to the dev as assumptions to falsify rather than as instructions:
⚠️ Triage names one site; that is a claim about one site, not a census. The dev is asked to enumerate the file for any other raw-id-vs-string-key comparison before concluding one edit closes the class — and to report a second rather than silently fixing or silently skipping it.⚠️ The boundary beyond numeric.String(null)is'null',String(undefined)is'undefined'. A repair that coerces blindly can mint a new collision where none existed — including againstKANBAN_UNCOLUMNED_IDitself. The acceptance is asked to cover the class, not just the reported value.
⚠️ Cross-lane adjacency, flagged rather than assumed awaygit ls-remoteshows a branch on this package from another lane:claude/issue-8990-kanban-groupby-optional. Card objectui#8990 isdomain:spec— ⛔ not this lane's — so this seat has no visibility into its state or its changed-file set.The dev is told to check whether it touches
packages/plugin-kanban/src/index.tsxand, if it does, to report the cross-lane conflict rather than fight it. I am not claiming that file against another lane on my own authority.C3, disclosed
check-clause2-carrierswill fire C3 on this pair — declarationyes,needs:contract-reviewhung on neither carrier. Not hanging it, for the reason measured and recorded on objectui#8973, objectui#8943, objectui#8908 and objectui#8613: no objectui workflow reads that label, and per objectstack#17285 the tier it summons does not serve this lane. Sixth instance; goes to the maintainer as a group with objectui#8568. ⛔ Not resolved by this seat in either direction.
Generated by Claude Code
os-dev-report
{ "issue": 8993, "status": "done", "branch": "claude/issue-8993-kanban-sweep-key-coercion", "pr": "https://github.com/objectstack-ai/objectui/pull/9044", "premise_still_valid": true, "summary": "Repaired on the SWEEP side only, as triage fenced: knownIds now holds the same key spelling the injection's property read uses, so the two membership decisions agree. The injection line is byte-identical. One measured deviation from the card's fix sketch: a symbol is the one id a property read does not stringify, so it is kept as-is rather than pushed through String(), which throws on symbols — measured, today a symbol lane id does not throw, and it still does not. The card's premise held in full: the function is byte-identical to origin/main at 681d3f10e and reproduced the reported reading on the first run. Diff is 8 files: the one-site repair, a 17-row pin, a changeset, and prose-only edits in packages/types plus two doc files whose present-tense claim that the board renders every card twice this repair makes false. ACTION TO NOTE: the clause-2 gate is a DUAL carrier and the card half was missing, so PR 9044 and card 8993 both now carry needs:contract-review (additive write, nothing stripped, --pair went 4 to 0). The card's pm:queue had already become pm:dispatched between my two reads — the PM's own transition, not my write.", "tests": "vitest packages/plugin-kanban/ = 48 files / 285 tests passed (VERDICT command-exit 0, via os-verify-lock, slot objectui-8993-dev). vitest packages/types/ = 169 files / 3352 tests passed (VERDICT command-exit 0). Dependency-closure build 'pnpm --filter @object-ui/plugin-kanban^... run build' exit 0 (VERDICT command-exit 0). type-check for both packages exit 0, both script bodies echoed, Scope: 2 of 47 projects; tsc -p tsconfig.test.json --listFiles proves the new pin IS in the typechecked set (1690 files, probe 1, negative control 0). Gates: check-control-bytes exit 0 (7234 files), check-new-line-citations exit 0, check-changeset-presence exit 0 (4 published source files, 1 changeset), check-changeset-no-major exit 0, check-governed-queue-guard --test = NOT GOVERNED. Note: 'pnpm run check:changeset-presence' does NOT exist as a script name (ERR_PNPM_NO_SCRIPT, exit 1 = not measured); both were measured by invoking node scripts/check-changeset-*.mjs directly. eslint over its OWN full population (4755 files, count read from --format json, no narrowing claim needed): every file in this diff = 0 errors, none among the 79 pre-existing error files. ABLATION, from the committed fix: predicted 12 red / 5 green BEFORE running; measured exactly 'Tests 12 failed | 5 passed (17)' with no miscount — red = row 1, the nine class rows, row 4 and row 5; green = the STRING control and all four boundary rows, so the control is green on both sides and is a real control. Mutation proven on disk first (HEAD blob 2fc0ea48 to mutated 7ec8e647, injected pre-repair line count 1, symbol-arm count 0), restore proven after (git diff HEAD empty for the path, blob back to 2fc0ea48), trap on absolute paths. No dist leg exists to fool it: the pin imports the source by relative path.", "mcp_calls": "7 — issue_read x2, list_pull_requests x1, pull_request_read(get_files) x1, issue_write(create) x1, create_pull_request x1, add_issue_comment x1. Card reads, PR file list, dedupe listing, label writes and every read-back went through the zero-quota REST channel instead (probed 200 in this container before use).", "open_questions": [], "out_of_scope_findings": [ "filed as #9043: the same function THROWS when a record's groupBy value is an Object.prototype member name — both labelToColumnId and the groups accumulator are prototype-bearing {}. Measured: 'toString' / 'valueOf' / 'hasOwnProperty' throw 'acc[key].push is not a function'; 'constructor' and '__proto__' are grouped under a nonsense key; an ordinary unmatched value (the control) behaves correctly. Unchanged by this PR, different mechanism, worst leg is a crash rather than a duplicate.", "noted, not filed: two lanes whose ids share a string spelling (1 and '1', or two distinct object ids) both claim the same records at the INJECTION site — measured '[object Object]:r1, [object Object]:r1'. This PR removes the third copy that used to land in Uncategorized but cannot remove the double claim, which lives on the read side ruling 2 fences. Unauthorable since objectui#8913 narrowed the lane id to a string. Successor: recorded in the pin's row-6 block, which the next reader of this function meets.", "noted, not filed: 'if (col.id)' skips label registration for a falsy lane id, so a lane { id: 0, title: 'Zero' } is reachable by its title but not by an id label alias. Measured behaviour-neutral for bucketing and unchanged here. Successor: none.", "noted, not filed: cross-lane adjacency — PR #9021 (the objectui#8990 lane) does NOT touch packages/plugin-kanban/src/index.tsx, so no conflict on the repair. It does edit both doc files this PR edits, and in content/docs/api/schema-reference.md its hunk covers the block quote whose next-but-one line this PR rewrites, so whichever lands second may need a textual resolution there. Reported, not worked around." ] }Draft PR is open and stays draft — landing is the maintainer's, and the contract-review gate is hung on both carriers.
Generated by Claude Code
- added a commit that references this issue
on Sep 17, 2026
Found by the
os-devseat during the contract-review rework of objectui#8913 (PR #8989), and reproduced independently of the review that first reported it. ⛔ Not repaired there: objectui#8913 is an authoring-face declaration, and this is a renderer behaviour change with its own blast radius.Unclaimed and unprioritised on purpose — triage owns both.
The defect
bucketCardsIntoColumns(packages/plugin-kanban/src/index.tsx) decides lane membership twice, with two different key types, and they disagree when a laneidis not a string:groups[col.id]. A JS property read coerces the key, so a lane{ id: 1 }correctly picks up the group stored under'1'.const knownIds = new Set(columns.map((col) => col.id))from the RAW value and then filtersObject.keys(groups)— which are ALWAYS strings — with!knownIds.has(key).new Set([1]).has('1')isfalse.⇒ every record whose group value belongs to a numeric lane is injected into that lane and swept into the trailing
Uncategorizedlane. The card renders twice.Reproduction — the real function, with a control leg
Runnable as a vitest file anywhere under
packages/plugin-kanban/src/; it calls the exported function directly, mounts nothing:Measured on
claude/issue-8913-object-kanban-columns-declaredataa467bc(the function is byte-identical toorigin/main):The control leg is what makes the first line a reading rather than an artefact of the harness.
Why nothing catches it today
KanbanColumn.idandKanbanColumnSchema.idare bothstring, and after PR types(plugin-kanban): declareobject-kanban.columnswith the protocol's element union #8989ObjectKanbanSchema.columns[].idisstringtoo — so a numeric lane id is now refused at the authoring boundary on every published face. That closes the AUTHORED path, not the defect.id: opt.valuefrom the object definition's picklist options, andid: String(g)from the data. So a picklist whose optionvalueis a number in real metadata reaches this function with a numericidthrough a path no schema guards. Whether such metadata exists in production was NOT measured here.index.bucket.test.tscovers the leftover sweep (kanban: 状态不匹配任何列的记录静默消失,底部计数与列内卡片数矛盾 #2792) with string ids only.Fix sketch (not prescriptive)
Key both sides the same way —
new Set(columns.map((col) => String(col.id))), and readgroups[String(col.id)]— so injection and the sweep agree. Any fix wants the two probes above kept as a pin, because the failure is silent: the board renders, the totals just do not reconcile.Related reading, recorded but NOT part of this card
effectiveColumns(packages/plugin-kanban/src/ObjectKanban.tsx) dispatches oncolumns[0]alone, so a MIXED array is mishandled in both orders. Measured with[{ id: 'done', title: 'Done' }, 'todo']:done:r2, undefined:, __uncolumned__:r1— a blank lane whose own keys are["0","1","2","3","cards"](the string spread character by character) and thetodorecord in "Uncategorized". PR #8989 now refuses a mixed array at the authoring boundary, so there is no authored path to it; it is written down here only so the next reader of this function does not rediscover it.Refs: objectui#8913 / PR #8989 · objectui#2792 · objectui#8990.
Filed by Claude Code for the
os-devseat on objectui#8913; the session reference issession_01Jmxdo7bmeqCQHLSfmLVX9w.Generated by Claude Code