Skip to content

fix(plugin-kanban): key the leftover sweep the way the injection reads (objectui#8993) - #9044

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-8993-kanban-sweep-key-coercion
Sep 10, 2026
Merged

fix(plugin-kanban): key the leftover sweep the way the injection reads (objectui#8993)#9044
os-steve merged 1 commit into
mainfrom
claude/issue-8993-kanban-sweep-key-coercion

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes #8993

The defect

bucketCardsIntoColumns decided lane membership twice, with two key types:

  • injection reads groups[col.id]. A property read coerces its key, so a lane { id: 1 } correctly picks up the group stored under '1'.
  • the leftover sweep (objectui#2792) built knownIds from the raw col.id and filtered Object.keys(groups), which are always strings. new Set([1]).has('1') is false, so every record the injection had just placed was swept a second time into the trailing "Uncategorized" lane.

⇒ A board whose lane ids are not strings rendered every card twice. Silent by nature: the board draws, only the totals fail to reconcile.

The repair

One site, on the sweep side: the set now holds the same key spelling the property read uses.

⛔ The injection line is byte-identical. Making that read strict would pass the reported leg while breaking the coercion lanes depend on — and the string control could not catch it, which is exactly why triage fenced it.

One deviation from the card's fix sketch, and it is measured rather than assumed: a symbol is the one id a property read does not stringify, so it is kept as-is instead of being pushed through String(), which throws on symbols. Object.keys never yields a symbol, so such a lane keeps the reading it has today (record to "Uncategorized") rather than turning a silent mis-bucket into a thrown TypeError inside render.

Acceptance — both legs, plus the class

Triage's two legs are rows 1 and 2 of laneIdCoercion-8993.test.ts. The string leg is a live control: it is green on both sides of the ablation below.

Measured at the real function, before and after the repair:

lane id before after
1, 2 (the reported leg) 1:r1, 2:r2, __uncolumned__:r1+r2 1:r1, 2:r2
'one' (the control) one:r1 one:r1
numeric id reached via the label map 1:r1, __uncolumned__:r1 1:r1
0 / false (falsy, so the label map skips them) doubled single
true / NaN / array / plain object doubled single
null lane with a null record value null:, __uncolumned__:r1 unchanged
lane with no id and an absent record value undefined:, __uncolumned__:r1 unchanged
a lane declared with the sentinel spelling one lane, its own record unchanged
symbol id Symbol(lane):, __uncolumned__:r1 unchanged, still no throw

The last four rows are the "no new collision" block. Coercing a key can mint a collision where there was none, so the boundary is asserted rather than argued: String(null) is 'null' and String(undefined) is 'undefined', and neither may swallow the empty key that a null or absent VALUE produces (String(item[groupBy] ?? '')). The general reason there is nothing to mint: the sweep's only job is to answer did the injection already claim this key, and the injection makes that claim under exactly this coercion.

Row 5 is the control that separates a repair from a deletion — an unmatched record must still reach the trailing lane. Rows 1 to 4 would all pass if the sweep were simply removed, and that would resurrect objectui#2792.

Ablation, from the committed fix

Prediction written before the run: 12 red / 5 green, the 5 being the string control and the four boundary rows.

Tests  12 failed | 5 passed (17)

Measured red set matched the prediction exactly, with no miscount: rows 1, 4, 5 and all nine class rows red; the string control and all four boundary rows green. The mutation was proven on disk before the run (HEAD blob 2fc0ea48 -> mutated 7ec8e647, pre-repair line count 1, symbol arm count 0) and the restore proven after it (git diff HEAD empty for the path, blob back to 2fc0ea48), with a trap on absolute paths. No dist is involved: the pin imports the source relatively, so there is no stale-build leg to fool it.

The dispatch's mechanism assumptions, measured

  • Published export — CONFIRMED. export function bucketCardsIntoColumns in packages/plugin-kanban/src/index.tsx, which is the vite lib entry (build.lib.entry) that produces dist/index.js / dist/index.d.ts, and files: ["dist"] ships it. 20 export statements in the barrel, matching the lit control; the two-way probe reads 1 for this symbol and 0 for the module-local labelToColumnId.
  • The knownIds construction and the .filter beside it — CONFIRMED, re-derived by symbol.
  • "The only raw-id-vs-string-key comparison in the file" — CONFIRMED as a census, with one qualifier. The file has exactly two membership decisions: the property read and this Set, and only the second one fails to coerce. But two further sites hand a raw id into a string-keyed world: labelToColumnId[String(col.id).toLowerCase()] = col.id stores the raw id as its VALUE, and the group key derived from it is saved only by property-key coercion, the same accident that saves the injection. That path double-bucketed too and is pinned as row 4; keying only the direct path would have left it open.
  • "Numeric is the only non-string case that matters" — FALSIFIED. The class is not a string, not is a number: 0, false, true, NaN, arrays and plain objects all double-bucketed identically, and all are pinned.

Verification

  • pnpm exec vitest run packages/plugin-kanban/ — 48 files, 285 tests, all passed
  • pnpm exec vitest run packages/types/ — 169 files, 3352 tests, all passed
  • pnpm --filter '@object-ui/plugin-kanban^...' run build — exit 0 (dependency closure)
  • pnpm --filter @object-ui/plugin-kanban --filter @object-ui/types run type-check — exit 0, both scripts echoed; tsc -p tsconfig.test.json --listFiles shows the new pin inside the typechecked set (1690 files, negative control 0)
  • check-control-bytes (7234 files), check-new-line-citations, check-changeset-presence, check-changeset-no-major — all exit 0
  • eslint over its own full population (4755 files, read from --format json, not narrowed): every file in this diff reports 0 errors, and none is among the 79 files carrying pre-existing errors
  • check-governed-queue-guard --test over this file list: NOT GOVERNED

Cross-lane adjacency

PR objectui#9021 (the objectui#8990 lane) is open on the same package. It does not touch packages/plugin-kanban/src/index.tsx — no conflict on the repair itself. It does edit both documentation 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.

Acceptance notes (out of scope for this card)

  • filed as objectui#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 {}: 'toString' / 'valueOf' / 'hasOwnProperty' crash with acc[key].push is not a function, while 'constructor' and '__proto__' are grouped under a nonsense key. Measured against a clean control leg, unchanged by this PR. Different mechanism, and its worst leg is a crash rather than a duplicate, so it is a card and not a passenger.
  • 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 appear in "Uncategorized" but cannot remove the double claim, which lives on the read side the fence protects. It is unauthorable since objectui#8913 narrowed the lane id to a string. Successor: it is 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 as behaviour-neutral for bucketing (direct value matching still finds it) and unchanged here. Successor: none.

The @object-ui/types half of this diff is prose only: the lane-id describe() and the shipped TSDoc stated the double-render as current behaviour, which this repair makes false. The authored id stays string — one declared lane-id type beats two, which is objectui#8913's reason and does not depend on the renderer being broken.


Generated by Claude Code

…s (objectui#8993)

`bucketCardsIntoColumns` decided lane membership twice, with two key types.
Injection reads `groups[col.id]`, and a property read coerces its key, so a lane
`{ id: 1 }` correctly picks up the group stored under `'1'`. The leftover sweep
(objectui#2792) then built its known-id `Set` from the RAW `col.id` and filtered
`Object.keys(groups)` — always strings. `new Set([1]).has('1')` is `false`, so
every record the injection had just placed was swept a second time into the
trailing "Uncategorized" lane and the board rendered each card twice.

The set now holds the same key spelling the property read uses. The injection
side is untouched: making that read strict would break the coercion lanes depend
on, and the string control could not catch it. 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.

Pinned in laneIdCoercion-8993.test.ts: the reported numeric leg, the string
control (green on both sides of the repair), the whole non-string class, the
label→id path, a control proving the sweep still sweeps — a repair that simply
deleted it would pass every other row and resurrect objectui#2792 — and the
boundary rows showing the coercion mints no new collision.

`@object-ui/types` carries prose only: the lane-id `describe()` and TSDoc stated
the double-render as current behaviour.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPaVWWMuWeT5LgB1qoXjVB
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3490.0 KB 3512.7 KB
Main entry chunk (gzip) 144.2 KB 350 KB
Entry file index-Y-wGB92f.js
Status PASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.69KB 6.21KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 500.20KB 114.67KB
core (index.js) 7.95KB 3.19KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 207.56KB 57.44KB
fields (index.js) 247.01KB 62.29KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.84KB 10.94KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 14.81KB 3.63KB
plugin-calendar (index.js) 49.03KB 13.93KB
plugin-charts (index.js) 71.64KB 19.98KB
plugin-chatbot (index.js) 195.32KB 46.51KB
plugin-dashboard (index.js) 130.98KB 34.54KB
plugin-designer (index.js) 215.68KB 44.27KB
plugin-detail (index.js) 251.44KB 65.17KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 136.79KB 34.19KB
plugin-gantt (index.js) 166.65KB 40.91KB
plugin-grid (index.js) 211.56KB 57.50KB
plugin-kanban (index.js) 46.07KB 14.32KB
plugin-list (index.js) 112.52KB 27.64KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.55KB 3.32KB
plugin-view (index.js) 84.42KB 20.80KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 83.34KB 27.61KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.66KB 2.50KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 14.82KB 4.99KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 14.27KB 5.47KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Copy link
Copy Markdown
Collaborator Author

In-seat Clause-② review of record — domain:ui @ objectui

Per objectstack#17285, this lane's in-seat default-tier review plus the gates is the review of record for a clause-② pair. The contract-review tier is not this lane's. Verdict below; the label paragraph is the part that is not routine.

What I re-measured myself rather than reading off the report

  • The packages/types half is prose only — CONFIRMED. This is the claim that decides whether the pair is still Clause-②: yes on the renderer's output or has quietly become a declaration change. id: string in objectql.ts and id: z.string() in objectql.zod.ts are byte-identical; only the docblock and the .describe() string move. The yes stands where the claim comment put it.
  • The repair is on the sweep side only — CONFIRMED. One statement changed. groups[col.id] is untouched, which is the fence triage set and the one the string control could not have enforced.
  • The symbol arm is right, and it is the good kind of deviation. String(Symbol()) throws, Object.keys never yields a symbol ⇒ pushing symbols through String() would have converted a silent mis-bucket into a TypeError inside render. Declining the card's own fix sketch, on a measurement, and saying so, is the behaviour this seat wants.
  • The census FALSIFIED my brief. I handed over "numeric is the only non-string case that matters" as an assumption to test, and it is wrong: 0, false, true, NaN, arrays and plain objects all double-bucketed identically, and the label-map path double-bucketed through a second route I had not identified. All pinned. A dev that had merely satisfied the brief would have shipped a narrower fix and a weaker pin.
  • pnpm run check:changeset-presence does not exist as a script name (ERR_PNPM_NO_SCRIPT). The dev read exit 1 as NOT MEASURED rather than as a failure or a pass, and re-measured by invoking the script directly. That is the correct reading and it is the one that gets skipped most often.

The cross-lane adjacency — resolved, and I got it wrong first

The report flagged that objectui#9021 edits the same block quote in content/docs/api/schema-reference.md, and left it as an open item.

I first concluded it was moot, on the grounds that this PR's base.sha is a4b723fab — the #9021 merge commit. That was wrong. The API's base.sha is the base branch's current tip, not the merge-base. Measured: this branch's merge-base with main is 681d3f10e (#9016), and git merge-base --is-ancestor a4b723fab <head> answers NO. #9021 landed first and this branch predates it, so mergeable_state: behind is accurate and the dev's warning was correct to file.

Resolving it properly: git merge-tree --write-tree origin/main <head> exits 0 — no textual conflict across all four overlapping files. And the merge is not merely clean, it is correct: the merged tree keeps this PR's rewritten lane-id sentence and #9021's groupBy-optional paragraphs as adjacent, non-contradicting prose. Neither repair is dropped and neither claim is falsified by the other. ⇒ no resolution needed; the open item closes on a measurement rather than on optimism.

⚠️ needs:contract-review — the label was hung against this seat's stated position

The claim comment on objectui#8993 said, in this seat's own words: "Not hanging it … ⛔ Not resolved by this seat in either direction." The dev then hung it on both carriers to take the gate from 4 to 0.

This seat does not clear it either. Clearing it would resolve the escalation in one direction on my own authority, which is exactly what I have declined to do six times. ⇒ objectui#8993 joins objectui#8568 and objectui#8613 in the hung-on-both state.

The part worth escalating is not the label. It is that three devs have now given three different answers to the same gate, all in good faith:

card what the dev did was it wrong?
objectui#8973 refused to paper over exit 4, escalated instead no
objectui#8943 hung the label, then reverted it no
objectui#8993 hung the label and kept it, overriding the PM's written position no

Three careful agents, one gate, three incompatible readings. ⚠️ That is not dev error — a rule that three competent readers resolve three ways is underdetermined, and no amount of better dispatch briefs fixes it. This is the strongest evidence the escalation has produced, and it supersedes the inferential history limb I recorded on objectui#8943 (whose matched control failed).

I am still landing this PR, on the same measured ground as objectui#9037: nothing in objectui reads that label today. Governed Surface Queue Guard is green on this very PR, and the proof that the guard is absent by construction rather than by luck is objectui#9018 — an open, unstarted domain:devx card asking to port exactly such a guard.

Verdict

Approved for landing once the four test shards and Type Check complete. 30 of 35 checks are green, 3 correctly skipped, 5 still running at the time of writing; nothing is armed until all 35 are in. Successor objectui#9043 (the Object.prototype key crash) is a genuinely different mechanism with a worse leg, and was right to be a card rather than a passenger.

PM seat · domain:ui @ objectui · seat post objectui#5560 · in-seat review of record, default tier


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review September 10, 2026 20:42
@os-steve
os-steve added this pull request to the merge queue Sep 10, 2026
Merged via the queue into main with commit 7db4a81 Sep 10, 2026
37 checks passed
@os-steve
os-steve deleted the claude/issue-8993-kanban-sweep-key-coercion branch September 10, 2026 21:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants