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 }}
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
Repository navigation
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
A null entry in grouping.fields[] throws a TypeError that takes down the whole grid, before any projection is built #7217
Filed by the domain:ui execution seat (session session_012wwHa4aaFybxXrfmfHioDM). Surfaced during objectui#7179's implementation by a malformed-block test the lane wrote, which it then narrowed to exclude null rather than ride the fix — correctly, since this is a different defect class from that card's silent wrong answer.
Filed unassigned.
The claim
A null entry inside grouping.fields[] crashes the entire grid. ObjectGrid's groupValueFormatter memo dereferences the entry's field property with no null guard, throwing a TypeError before any projection is built.
Confirmed PRE-EXISTING, not introduced by #7179: the lane checked the identical code at that branch's base commit 4f596e01a, where only the line number differs.
Why it is a different class from objectui#7179
#7179 is a silent wrong answer — one group labelled (empty) holding every row, no error, nothing on screen indicating a problem. This is the opposite failure mode: a loud total crash, and loud is the better half of that pair. A thrown TypeError is findable; the (empty) bucket is what cost a downstream repo a browser session to disprove.
That is also the argument for its lower priority. It fails safe in the sense that matters: nobody ships a view believing it works.
How reachable is it
That question is the card, and it is not yet answered. GroupingFieldSchema.field is a bare string and the block is authored JSON, so the reachable paths are worth enumerating before choosing a fix:
a hand-authored grouping.fields: [null], or a trailing comma in a source that permits it;
a generated or agent-written view block — this repo's premise is that agents write compact JSON, so a null from a generator is not exotic;
a runtime mutation from the grouping toolbar popover, if it can ever produce a hole.
If author-time validation already rejects a null entry, this is unreachable in practice and the fix is a cheap defensive guard. If it does not, the crash is a live authoring hazard and the schema should refuse it. Measure which before fixing — the answer decides whether this is a guard or a validation gap.
Already partly pinned
The harvester added by objectui#7179, collectGroupingFieldRefs in @object-ui/core, handles null correctly, and that behaviour is pinned in packages/core/src/utils/__tests__/grouping-fields.test.ts — a unit file that needs no grid to mount.
So the shared reader is already safe. What remains unguarded is ObjectGrid's own groupValueFormatter memo, which reads the raw block rather than going through the harvester. Routing it through the harvester may be the whole fix; that is a suggestion, not a ruling.
Provenance
The lane's reading, relayed by the PM. I have not independently reproduced the crash. Filed as a finding to verify — the first task is a failing test that mounts a grid with a null grouping entry.
Related: objectui#7179 (where this surfaced; its collectGroupingFieldRefs handles null and is the candidate fix route).
Claim: PM loop round 7 (domain:ui execution seat, objectui)
Session: session_01NRRumy89BYdW9ogbcdHTho
Branch: claude/issue-7217-grouping-null-entry-guard
Worktree: /home/user/objectui-issue-7217 (dedicated per-task worktree off origin/main)
Domain: domain:ui — fix lands in packages/plugin-grid (ObjectGrid.tsxgroupValueFormatter memo and the grouped-data hook it feeds); packages/core harvester collectGroupingFieldRefs is the candidate route and is read, not reshaped.
File surface: packages/plugin-grid/src/ObjectGrid.tsx (the grouping memo region only, lines 2036–2092 on 96802feec; ⛔ not the default-column synthesis at 2788–2810, which PR #7284 holds), packages/plugin-grid/src/hooks/** (grouped-data hook, if it also dereferences entries), packages/plugin-grid/src/__tests__/ (new pin), .changeset/. packages/types/src/** is NOT in this surface: if measurement shows the zod mirror accepts a null entry, that is a Clause-② accept-set change and comes back as a separate card, not a rider.
Container & model: os-dev subagent, opus, size S.
Clause-②: no (defensive guard + pin; no published validator's accept set moves).
Serial constraints cleared: ObjectGrid.tsx is a hot file held by PR #7284 (dogfood session, draft since 03:20Z) — its hunk is the fieldsToShow synthesis (2788–2810) and the @object-ui/core import line at 39; this card's hunk is the grouping memo at 2036–2092, disjoint by 700 lines. Region-level serialization applies: the dev must not touch the import line or the synthesis branch. Premise re-verified on origin/main (96802feec): the memo reads gf.field for every entry of grouping.fields with no null guard (ObjectGrid.tsx:2044-2045), while collectGroupingFieldRefs (packages/core/src/utils/grouping-fields.ts:71) guards entry?.field — the asymmetry the card describes is still present. Comments on this card before this claim: none.
Claim correction (file surface, one path): the grouped-data hook is packages/plugin-grid/src/useGroupedData.ts (not under a hooks/ directory). It is a second dereference site — useGroupedData.ts:251-260 reads f.field off each entry with no guard — so it is in scope for the same guard and the same pin. Everything else in the claim stands. Dispatched to an os-dev subagent (opus, S) at 08:58Z.
{
"issue": 7217,
"status": "done",
"branch": "claude/issue-7217-grouping-null-entry-guard",
"pr": "https://github.com/objectstack-ai/objectui/pull/7346",
"premise_still_valid": true,
"summary": "The card's premise held on the fork point e75f4c986 and still holds on current origin/main: ObjectGrid.tsx:2044-2045 read gf.field off every grouping entry with no guard, and useGroupedData.ts:251-290 is a second dereference site of the same list. Proven RED before fixing, with the exact error 'TypeError: Cannot read properties of null (reading \\'field\\')' thrown out of render. Both sites now read one normalized entry list, usableGroupingFields, exported from useGroupedData.ts; an entry is usable when it is an object carrying a non-empty string field, which is deliberately the same admission rule collectGroupingFieldRefs uses for the projection, so the grid can never group by an entry the query never asked for. An unusable entry is dropped, not the grouping: the usable levels still group. No core edit was needed and none was made, and no @object-ui/core import changed, so PR #7284's two held regions (line 39 and the fieldsToShow synthesis at roughly 2788-2810) were never touched; the only ObjectGrid hunks are at line 43 and inside the 2036-2092 memo region. The reachability question the card made the deciding one was measured, not assumed: author-time validation DOES reject a null entry (GroupingConfigSchema types fields as an array of strict objects; safeParse fails with invalid_type at fields.0), and objectui's own ListViewSchema inherits that refusal by reference through packages/types/src/zod/objectql.zod.ts:427, since grouping is absent from LIST_VIEW_LOCAL_OVERRIDES at objectql.zod.ts:310-325. So this is a defensive guard, not a validation gap, and no accept set moves: Clause-② stays no and packages/types/src/** is untouched. The crash is nevertheless live because nothing on the render path runs that validator: there is no safeParse or validateSchema call in packages/plugin-grid/src/** outside tests, and core's validateSchema at packages/core/src/validation/schema-validator.ts:458 is structural with zero occurrences of grouping anywhere under packages/core/src/validation/. Both halves of that measurement are pinned in the new test file rather than left as prose. The toolbar popover was cleared as a writer: GroupingEditor.writeFields only builds object literals and removes with .filter(), and useGroupReorder.ts reorders rendered group keys, never fields[] entries.",
"files_changed": [
"packages/plugin-grid/src/ObjectGrid.tsx (memo region only: line 43 import + lines 2037-2051)",
"packages/plugin-grid/src/useGroupedData.ts (usableGroupingFields added and the hook routed through it)",
"packages/plugin-grid/src/__tests__/groupingNullEntry-7217.test.tsx (new pin, 6 tests)",
".changeset/7217-grouping-null-entry-guard.md (@object-ui/plugin-grid patch)"
],
"tests": "All on the final commit 5eb7a8138, every heavy run through os-verify-lock.sh slot dev-7217, verdicts quoted from the printed VERDICT line rather than a bare exit code. RED FIRST on the unmodified tree: 4 failed | 2 passed (6), each failure 'TypeError: Cannot read properties of null (reading \\'field\\')' (the undefined variant reports the same for undefined); the 2 that passed are the schema measurements, which are about the validator and not the guard. GREEN after: Test Files 1 passed (1) / Tests 6 passed (6). ABLATION per site, each leg proving the mutation landed on disk by counting injected and removed text, restoring with git checkout HEAD -- and an ABSOLUTE path, and proving the restore by blob hash plus an empty git diff HEAD, under trap restore EXIT INT TERM: (A) memo guard removed, hook guard left in place, RED exit 1, Tests 4 failed | 2 passed (6), restored to blob b8053e5e91d98da07cd30525417411b1c57c0d93; (B) hook guard removed, memo guard left in place, RED exit 1, Tests 4 failed | 2 passed (6), restored to blob 98d98f05bdcf27510ff6d9c9f70229a13630b2f5. No build leg applies to the ablation: the pin imports ../ObjectGrid relatively and the root vitest config aliases @object-ui/* to each package's src, so both mutation targets are read from source with no dist in the path. SUITES: plugin-grid Test Files 110 passed (110) / Tests 999 passed (999); plugin-list (the downstream grouping composer, run as insurance for the memoization change) Test Files 62 passed (62) / Tests 787 passed (787). TYPE-CHECK: pnpm --filter @object-ui/plugin-grid run type-check (tsc --noEmit && tsc -p tsconfig.test.json) exit 0, after first building the dependency closure (pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-grid^...' build, exit 0) — before that build it reported TS2307 on every workspace import, which was a NOT MEASURED state and not a red gate. Proven to cover the new file: tsc -p tsconfig.test.json --listFiles | grep -c groupingNullEntry-7217 = 1. LINT: the package's own CI-identical eslint . over 148 files, E=0 W=777, exit 0; delta against the fork-point blobs (checked out into the same paths, proven landed, restored by hash) is ObjectGrid.tsx E=0 W=213 unchanged, useGroupedData.ts E=0 W=26 unchanged, and +1 warning on the new test file (@typescript-eslint/no-explicit-any on 'const schema: any', the same idiom every sibling grid test uses and deliberate, since the fixture is intentionally off-spec). GATES: check-changeset-presence '3 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)'; check-changeset-no-major 'No changeset declares a major bump'; check-control-bytes 'OK (scanned 6037 tracked text file(s); skipped 85 binary)' plus a direct control-byte grep over the four changed files, no match; check-phantom-dependencies exit 0; check-package-self-import 'No package names itself inside its own src/'; check-vi-mock-specifiers OK; check-vi-mock-inherit OK. GOVERNED: check-governed-merges.mjs --test on the final four paths, 'NOT governed — ordinary queue landing applies to a PR with exactly this file list', 0 of 4 paths hit the register. Scope of the local run, declared: repo-wide turbo run lint and the full vitest run are CI's; what ran locally is the whole touched package's own eslint . plus both affected packages' full suites, and no untouched package's verdict can have moved because eslint.config.js configures no type-aware linting (no projectService, no parserOptions.project, no project:).",
"deviations": [
"origin/main advanced from e75f4c986 to 2956d7af8 mid-run (PR #7284 and PR #7339 landed, both touching my file surface). Per the coordinator's instruction I stayed on the fork point rather than rebasing, after verifying both target lines are byte-identical AND at the same line numbers on current origin/main (ObjectGrid.tsx:2044, useGroupedData.ts:251). The merge queue rebuilds on current main.",
"PM assumption 4 came out half-false and is reported rather than worked around. 'No zod mirror declares the grouping entry shape' is true of packages/types/src/zod/ literally — git grep hits only comments at objectql.zod.ts:382,410 — but the shape arrives THERE BY REFERENCE from @objectstack/spec and does refuse a null entry. So the run took ZONE 1 item 3's 'validation DOES reject it' branch: the guard is still the fix, no schema-refusal card is owed, and the file:line evidence is in the PR body's measurement table.",
"Ran the plugin-list suite as well, which the order did not ask for. useGroupedData's fields binding changed from a raw reference to a useMemo, and plugin-list is the package that composes grouping at runtime through the toolbar popover, so it was the one place the identity change could have shown up. Green, 62 files / 787 tests.",
"The pin imports ListViewSchema from '@object-ui/types/zod', not '@object-ui/types' — the latter exports only the TypeScript interface of that name, so the first attempt failed with 'Cannot read properties of undefined (reading safeParse)'. Both specifiers stay inside the declared __tests__ surface.",
"The run was interrupted by a usage limit after the card and claim were read and resumed on the coordinator's message. No work was lost or repeated.",
"The PR body was edited once after creation, to repair a body-sanitizer bite (an angle-bracket fragment in the ablation paragraph was eaten) and to name the filed finding. Both reads-back confirm the body is intact; the PATCH dropped the platform's session-URL footer to the bare form, and the session URL survives in the body prose as the durable copy."
],
"mcp_calls": "10 — issue_read get, issue_read get_comments, create_pull_request, pull_request_read x2, search_issues x1 (the single targeted dedup query), issue_write create, update_pull_request, add_issue_comment, and the read-back of this comment. Repo-scoped REST is 403 in this session, as the dispatch stated, so every GitHub read and write went through MCP; the card body and its comments came through issue_read rather than a bulk channel.",
"open_questions": [],
"out_of_scope_findings": [
"filed as objectui#7347 (unassigned, unlabelled for PM triage): a grouping field name with surrounding whitespace passes author-time validation, is trimmed by collectGroupingFieldRefs on the way into $select, but is used untrimmed as the bucket key by both grid sites — so every row reads undefined and lands in one (empty) group. Measured, not inferred: the spec safeParse accepts a padded field name, the harvester yields the trimmed one, the grid buckets by the raw one. Same silent-wrong-answer class as objectui#7179, and unlike the null entry it SURVIVES validation, which makes it more reachable through the validated path. Not fixed here: this PR's guard admits such an entry unchanged (its field is a non-empty string), so behaviour is exactly as on main; normalizing the value as well as the admission set would be a behaviour change beyond this card, on a hot file."
]
}
PM review: ACCEPT — PR #7346 (head 5eb7a8138, fork e75f4c986). Landing once CI on the head is green (Type Check, Test shards 1–4 and Lint were still running at review time; every completed check is success, including Bundle Analysis, Doc Snippet Type Check, README Export Check). The dev's worktree is gone, so this is a content read of the fetched branch, the report, and CI.
Verified:
Diff is exactly the reported 4 files (+253 / −8). ObjectGrid.tsx has two hunks: the local ./useGroupedData import at line 43 (NOT the @object-ui/core import at line 39 that PR fix(core,app-shell,plugin-grid): lead the synthesized default list view with the object's name field #7284 holds) and the groupValueFormatter memo at 2034–2051; the fieldsToShow synthesis at ~2788–2810 is untouched. The hot-file constraint from the claim is met by content.
One normalized list feeds both sites: usableGroupingFields (exported from useGroupedData.ts) admits an entry iff it is an object with a non-empty string field — the same admission rule collectGroupingFieldRefs uses for the projection — and the hook memoizes it on the raw fields array (not on config, whose literal hosts rebuild every render). Unusable entries are dropped; the usable levels still group.
Reachability measured, not assumed (ZONE 2 item 4 came out half-false and the branch was taken as the order said): the spec's GroupingConfigSchema refuses a null entry and objectui's ListViewSchema inherits that by reference (objectql.zod.ts:427, grouping absent from LIST_VIEW_LOCAL_OVERRIDES), so no accept set moves and packages/types is untouched — Clause-② stays no. The crash is live anyway because no render-path code runs that validator (no safeParse in plugin-grid/src outside tests; core's validateSchema is structural with zero grouping occurrences). Both halves are pinned as tests, not prose.
PingroupingNullEntry-7217.test.tsx (6 cases): null / undefined sole entry renders; a null before / after a usable entry still groups; the two validator measurements. Red-first 4 failed / 2 passed with the exact TypeError: Cannot read properties of null (reading 'field'); ablation per site (memo guard removed → 4 failed; hook guard removed → 4 failed), restores proven by blob hash.
Verification: plugin-grid 110 files / 999 tests, plugin-list 62 / 787 (run as insurance for the memo identity change), package type-check green with the new file in the compiled set, package eslint E=0 with the two touched files' warning counts unchanged against the fork blobs; changeset @object-ui/plugin-grid: patch, presence / no-major / control-bytes / phantom-deps / vi-mock gates green.
Governed check: check-governed-merges.mjs --test over the 4 paths → NOT governed, exit 0.
Out-of-scope finding the dev filed for triage: #7347 (a grouping field name with surrounding whitespace passes validation, is trimmed into $select by the harvester, but is used untrimmed as the bucket key by both grid sites — every row lands in one empty group; the #7179 class, and it survives validation).
Round 2 — supersedes the round-1 report above. Same PR, same branch; head moved from 5eb7a81 to 4108232.
{
"issue": 7217,
"status": "done",
"branch": "claude/issue-7217-grouping-null-entry-guard",
"pr": "https://github.com/objectstack-ai/objectui/pull/7346",
"premise_still_valid": true,
"summary": "Round 2 fixes one red CI gate and changes nothing else. Test (shard 4/4) failed on 5eb7a8138 in scripts/__tests__/one-authority-per-exported-name-6273.test.ts: round 1's type alias published GroupingFieldEntry, a name @object-ui/components already declares at custom/grouping-editor.tsx:14. The two are genuinely different shapes, not one thing spelled twice — the components interface is the grouping editor's fully-populated value with order and collapsed REQUIRED, while the alias is z.input of the spec's GroupingConfig, where both carry defaults and are optional — so the remedy taken was the gate's rename branch, not re-pointing one at the other, which would have made one of the two lie. Renamed to UsableGroupingField, verified free across packages, apps and scripts before choosing; the components interface is untouched and keeps the name it already owned; nothing outside useGroupedData.ts referenced the alias. KNOWN_COLLISIONS was NOT touched, as it is shrink-only. A comment at the declaration now records why the two names differ, so the next reader does not unify them. The change is type-level only with no behaviour change, and every round-1 claim about the grid fix stands unchanged: the guard, the pin, the ablation and the reachability measurement are all as reported, and ObjectGrid.tsx was not touched again, so PR #7284's held regions remain untouched.",
"files_changed": [
"packages/plugin-grid/src/useGroupedData.ts (round 2: GroupingFieldEntry renamed to UsableGroupingField, 3 sites, plus the comment explaining the distinction)",
"packages/plugin-grid/src/ObjectGrid.tsx (round 1, unchanged in round 2)",
"packages/plugin-grid/src/__tests__/groupingNullEntry-7217.test.tsx (round 1, unchanged in round 2)",
".changeset/7217-grouping-null-entry-guard.md (round 1, unchanged in round 2)"
],
"tests": "Round 2, all on the final commit 410823207, every heavy run through os-verify-lock.sh slot dev-7217, verdicts read from the printed VERDICT line. THE FAILING GATE REPRODUCED RED LOCALLY FIRST, before any edit: pnpm exec vitest run scripts/__tests__/one-authority-per-exported-name-6273.test.ts gave Tests 1 failed | 10 passed (11), VERDICT command-exit 1, with the identical message CI reported ('GroupingFieldEntry — a NEW colliding name', naming grouping-editor.tsx:14 and useGroupedData.ts:213). GREEN after the rename, gate plus pin together: Test Files 2 passed (2) / Tests 17 passed (17), VERDICT command-exit 0. The rename was proven on disk before measuring: UsableGroupingField occurrences 3, and no 'export type GroupingFieldEntry' declaration left anywhere in plugin-grid (the single remaining textual mention is prose inside the explanatory comment, verified by reading the line). WHOLE scripts/ META-GATE FAMILY, which is the derivation round 1 should have made: Test Files 97 passed (97) / Tests 2738 passed (2738), VERDICT command-exit 0. plugin-grid suite re-run: Test Files 110 passed (110) / Tests 999 passed (999), VERDICT command-exit 0. Dependency closure build plus type-check (tsc --noEmit && tsc -p tsconfig.test.json) in one locked run: VERDICT command-exit 0. Lint on the one changed file: E=0 W=26, unchanged from the fork-point baseline, VERDICT command-exit 0. check-control-bytes: 'OK (scanned 6037 tracked text file(s); skipped 85 binary)'. Round-1 evidence still stands as reported and was not re-derived: RED-first 4 failed | 2 passed (6) with 'TypeError: Cannot read properties of null (reading field)', the per-site ablation with both legs red and both restores proven by blob hash under a trap, plugin-list 62 files / 787 tests, and the changeset, phantom-deps, self-import, vi-mock and governed-surface gates.",
"deviations": [
"ROUND 2 EXISTS BECAUSE MY ROUND-1 GATE DERIVATION WAS WRONG IN A NAMED WAY, and this is the part worth carrying forward. I derived the local gate set package-scoped: the touched package's suites, its type-check, its own eslint ., and the check:* scripts whose subject matter the diff touched. I never asked whether a REPO-WIDE gate under scripts/__tests__/ reads my diff. one-authority-per-exported-name-6273 does exactly that — it scans every exported type name in the monorepo, so adding a single exported type anywhere can redden it, no matter how small or how local the diff looks. Round 2 ran all 97 files under scripts/ rather than only the one that failed, which is what round 1 owed. The cost was one push-fix round; the report that preceded it was accurate about what it ran and simply did not run enough.",
"The remedy chosen is rename, not re-point. The gate offers both, and re-pointing is normally preferable, but the two declarations are structurally different (components requires order and collapsed; the spec z.input has both optional), and the gate's own guidance is to rename when the shapes genuinely differ. KNOWN_COLLISIONS was not touched: it is shrink-only and adding a line is explicitly not a supported way to pass.",
"The components interface at packages/components/src/custom/grouping-editor.tsx was READ and deliberately not edited — it is the incumbent authority for that name, it is outside this card's declared file surface, and it is imported by the grouping popover.",
"The worktree had been removed at the end of round 1, so it was recreated from the pushed branch (git worktree add with the existing branch, no --no-track since that flag requires creating a new branch). HEAD verified at 5eb7a8138 and the tree clean before any round-2 edit.",
"Carried forward from round 1 and still true: origin/main advanced to 2956d7af8 mid-run and the fork point e75f4c986 was kept per the coordinator, after verifying both target lines are byte-identical and at the same line numbers on current origin/main; PM assumption 4 came out half-false (no local re-declaration, but the entry shape arrives by reference from the spec and DOES refuse null), so the run took ZONE 1 item 3's 'validation DOES reject it' branch and no accept-set card is owed; the pin imports ListViewSchema from @object-ui/types/zod because @object-ui/types exports only the TypeScript interface of that name."
],
"mcp_calls": "14 cumulative across both rounds — round 1: issue_read get, issue_read get_comments, create_pull_request, pull_request_read x2, search_issues x1 (the single targeted dedup query), issue_write create, update_pull_request, add_issue_comment, issue_read get_comments read-back. Round 2 added 4: update_pull_request, pull_request_read, this add_issue_comment, and its read-back. Repo-scoped REST is 403 in this session, so every GitHub read and write went through MCP.",
"open_questions": [],
"out_of_scope_findings": [
"filed as objectui#7347 (unassigned, unlabelled for PM triage) in round 1, unchanged by round 2: a grouping field name with surrounding whitespace passes author-time validation, is trimmed by collectGroupingFieldRefs on the way into $select, but is used untrimmed as the bucket key by both grid sites — so every row reads undefined and lands in one (empty) group. Measured, not inferred: the spec safeParse accepts a padded field name, the harvester yields the trimmed one, the grid buckets by the raw one. Same silent-wrong-answer class as objectui#7179, and unlike the null entry it SURVIVES validation, which makes it more reachable through the validated path. Not fixed here: this PR's guard admits such an entry unchanged, so behaviour is exactly as on main."
]
}
Round 2 reviewed — ACCEPT stands on the new head 410823207. Verified by content on the fetched branch: the delta from 5eb7a8138 is exactly one file, packages/plugin-grid/src/useGroupedData.ts (+13 / −4) — GroupingFieldEntry renamed to UsableGroupingField at its three sites, plus a comment recording why the two names stay distinct (the components interface requires order / collapsed; this alias is the spec's z.input, where both are optional). ObjectGrid.tsx is byte-identical between the two heads, so PR #7284's held regions stay untouched; KNOWN_COLLISIONS was not edited. The dev reproduced the gate red locally first (1 failed / 10 passed with CI's exact message), then ran the whole scripts/__tests__ family green (97 files / 2738 tests) — the derivation round 1 owed. CI on 410823207 started 13:03Z; flip + auto-merge follows the green read.
Landed — PR #7346 merged through the queue as e21308ef2 on main.
Verified by content on origin/main, not by PR head sha: packages/plugin-grid/src/useGroupedData.ts exports usableGroupingFields (line 259) and the UsableGroupingField alias (line 222); packages/plugin-grid/src/ObjectGrid.tsx imports and consumes it at the grouping memo (lines 43 and 2044); the pin packages/plugin-grid/src/__tests__/groupingNullEntry-7217.test.tsx is in the tree. Control: the pre-existing useGroupedData export is still present in the same file (1 hit), so a zero would have been a real zero.
State: pm:dispatched removed in the same stroke (labels now bug, priority:p3, domain:ui); the card closes via the PR's Fixes #7217. The whitespace-in-field-name defect the measurement surfaced stays open as #7347 for triage.
Activity
os-litant commentedon Sep 2, 2026
Claim: PM loop round 7 (
domain:uiexecution seat, objectui)Session:
session_01NRRumy89BYdW9ogbcdHThoBranch:
claude/issue-7217-grouping-null-entry-guardWorktree:
/home/user/objectui-issue-7217(dedicated per-task worktree offorigin/main)Domain:
domain:ui— fix lands inpackages/plugin-grid(ObjectGrid.tsxgroupValueFormattermemo and the grouped-data hook it feeds);packages/coreharvestercollectGroupingFieldRefsis the candidate route and is read, not reshaped.File surface:
packages/plugin-grid/src/ObjectGrid.tsx(the grouping memo region only, lines 2036–2092 on96802feec; ⛔ not the default-column synthesis at 2788–2810, which PR #7284 holds),packages/plugin-grid/src/hooks/**(grouped-data hook, if it also dereferences entries),packages/plugin-grid/src/__tests__/(new pin),.changeset/.packages/types/src/**is NOT in this surface: if measurement shows the zod mirror accepts a null entry, that is a Clause-② accept-set change and comes back as a separate card, not a rider.Container & model:
os-devsubagent, opus, size S.Clause-②: no (defensive guard + pin; no published validator's accept set moves).
Serial constraints cleared:
ObjectGrid.tsxis a hot file held by PR #7284 (dogfood session, draft since 03:20Z) — its hunk is thefieldsToShowsynthesis (2788–2810) and the@object-ui/coreimport line at 39; this card's hunk is the grouping memo at 2036–2092, disjoint by 700 lines. Region-level serialization applies: the dev must not touch the import line or the synthesis branch. Premise re-verified onorigin/main(96802feec): the memo readsgf.fieldfor every entry ofgrouping.fieldswith no null guard (ObjectGrid.tsx:2044-2045), whilecollectGroupingFieldRefs(packages/core/src/utils/grouping-fields.ts:71) guardsentry?.field— the asymmetry the card describes is still present. Comments on this card before this claim: none.Generated by Claude Code
os-litant commentedon Sep 2, 2026
Claim correction (file surface, one path): the grouped-data hook is
packages/plugin-grid/src/useGroupedData.ts(not under ahooks/directory). It is a second dereference site —useGroupedData.ts:251-260readsf.fieldoff each entry with no guard — so it is in scope for the same guard and the same pin. Everything else in the claim stands. Dispatched to anos-devsubagent (opus, S) at 08:58Z.Generated by Claude Code
os-litant commentedon Sep 2, 2026
os-dev-report
{ "issue": 7217, "status": "done", "branch": "claude/issue-7217-grouping-null-entry-guard", "pr": "https://github.com/objectstack-ai/objectui/pull/7346", "premise_still_valid": true, "summary": "The card's premise held on the fork point e75f4c986 and still holds on current origin/main: ObjectGrid.tsx:2044-2045 read gf.field off every grouping entry with no guard, and useGroupedData.ts:251-290 is a second dereference site of the same list. Proven RED before fixing, with the exact error 'TypeError: Cannot read properties of null (reading \\'field\\')' thrown out of render. Both sites now read one normalized entry list, usableGroupingFields, exported from useGroupedData.ts; an entry is usable when it is an object carrying a non-empty string field, which is deliberately the same admission rule collectGroupingFieldRefs uses for the projection, so the grid can never group by an entry the query never asked for. An unusable entry is dropped, not the grouping: the usable levels still group. No core edit was needed and none was made, and no @object-ui/core import changed, so PR #7284's two held regions (line 39 and the fieldsToShow synthesis at roughly 2788-2810) were never touched; the only ObjectGrid hunks are at line 43 and inside the 2036-2092 memo region. The reachability question the card made the deciding one was measured, not assumed: author-time validation DOES reject a null entry (GroupingConfigSchema types fields as an array of strict objects; safeParse fails with invalid_type at fields.0), and objectui's own ListViewSchema inherits that refusal by reference through packages/types/src/zod/objectql.zod.ts:427, since grouping is absent from LIST_VIEW_LOCAL_OVERRIDES at objectql.zod.ts:310-325. So this is a defensive guard, not a validation gap, and no accept set moves: Clause-② stays no and packages/types/src/** is untouched. The crash is nevertheless live because nothing on the render path runs that validator: there is no safeParse or validateSchema call in packages/plugin-grid/src/** outside tests, and core's validateSchema at packages/core/src/validation/schema-validator.ts:458 is structural with zero occurrences of grouping anywhere under packages/core/src/validation/. Both halves of that measurement are pinned in the new test file rather than left as prose. The toolbar popover was cleared as a writer: GroupingEditor.writeFields only builds object literals and removes with .filter(), and useGroupReorder.ts reorders rendered group keys, never fields[] entries.", "files_changed": [ "packages/plugin-grid/src/ObjectGrid.tsx (memo region only: line 43 import + lines 2037-2051)", "packages/plugin-grid/src/useGroupedData.ts (usableGroupingFields added and the hook routed through it)", "packages/plugin-grid/src/__tests__/groupingNullEntry-7217.test.tsx (new pin, 6 tests)", ".changeset/7217-grouping-null-entry-guard.md (@object-ui/plugin-grid patch)" ], "tests": "All on the final commit 5eb7a8138, every heavy run through os-verify-lock.sh slot dev-7217, verdicts quoted from the printed VERDICT line rather than a bare exit code. RED FIRST on the unmodified tree: 4 failed | 2 passed (6), each failure 'TypeError: Cannot read properties of null (reading \\'field\\')' (the undefined variant reports the same for undefined); the 2 that passed are the schema measurements, which are about the validator and not the guard. GREEN after: Test Files 1 passed (1) / Tests 6 passed (6). ABLATION per site, each leg proving the mutation landed on disk by counting injected and removed text, restoring with git checkout HEAD -- and an ABSOLUTE path, and proving the restore by blob hash plus an empty git diff HEAD, under trap restore EXIT INT TERM: (A) memo guard removed, hook guard left in place, RED exit 1, Tests 4 failed | 2 passed (6), restored to blob b8053e5e91d98da07cd30525417411b1c57c0d93; (B) hook guard removed, memo guard left in place, RED exit 1, Tests 4 failed | 2 passed (6), restored to blob 98d98f05bdcf27510ff6d9c9f70229a13630b2f5. No build leg applies to the ablation: the pin imports ../ObjectGrid relatively and the root vitest config aliases @object-ui/* to each package's src, so both mutation targets are read from source with no dist in the path. SUITES: plugin-grid Test Files 110 passed (110) / Tests 999 passed (999); plugin-list (the downstream grouping composer, run as insurance for the memoization change) Test Files 62 passed (62) / Tests 787 passed (787). TYPE-CHECK: pnpm --filter @object-ui/plugin-grid run type-check (tsc --noEmit && tsc -p tsconfig.test.json) exit 0, after first building the dependency closure (pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-grid^...' build, exit 0) — before that build it reported TS2307 on every workspace import, which was a NOT MEASURED state and not a red gate. Proven to cover the new file: tsc -p tsconfig.test.json --listFiles | grep -c groupingNullEntry-7217 = 1. LINT: the package's own CI-identical eslint . over 148 files, E=0 W=777, exit 0; delta against the fork-point blobs (checked out into the same paths, proven landed, restored by hash) is ObjectGrid.tsx E=0 W=213 unchanged, useGroupedData.ts E=0 W=26 unchanged, and +1 warning on the new test file (@typescript-eslint/no-explicit-any on 'const schema: any', the same idiom every sibling grid test uses and deliberate, since the fixture is intentionally off-spec). GATES: check-changeset-presence '3 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)'; check-changeset-no-major 'No changeset declares a major bump'; check-control-bytes 'OK (scanned 6037 tracked text file(s); skipped 85 binary)' plus a direct control-byte grep over the four changed files, no match; check-phantom-dependencies exit 0; check-package-self-import 'No package names itself inside its own src/'; check-vi-mock-specifiers OK; check-vi-mock-inherit OK. GOVERNED: check-governed-merges.mjs --test on the final four paths, 'NOT governed — ordinary queue landing applies to a PR with exactly this file list', 0 of 4 paths hit the register. Scope of the local run, declared: repo-wide turbo run lint and the full vitest run are CI's; what ran locally is the whole touched package's own eslint . plus both affected packages' full suites, and no untouched package's verdict can have moved because eslint.config.js configures no type-aware linting (no projectService, no parserOptions.project, no project:).", "deviations": [ "origin/main advanced from e75f4c986 to 2956d7af8 mid-run (PR #7284 and PR #7339 landed, both touching my file surface). Per the coordinator's instruction I stayed on the fork point rather than rebasing, after verifying both target lines are byte-identical AND at the same line numbers on current origin/main (ObjectGrid.tsx:2044, useGroupedData.ts:251). The merge queue rebuilds on current main.", "PM assumption 4 came out half-false and is reported rather than worked around. 'No zod mirror declares the grouping entry shape' is true of packages/types/src/zod/ literally — git grep hits only comments at objectql.zod.ts:382,410 — but the shape arrives THERE BY REFERENCE from @objectstack/spec and does refuse a null entry. So the run took ZONE 1 item 3's 'validation DOES reject it' branch: the guard is still the fix, no schema-refusal card is owed, and the file:line evidence is in the PR body's measurement table.", "Ran the plugin-list suite as well, which the order did not ask for. useGroupedData's fields binding changed from a raw reference to a useMemo, and plugin-list is the package that composes grouping at runtime through the toolbar popover, so it was the one place the identity change could have shown up. Green, 62 files / 787 tests.", "The pin imports ListViewSchema from '@object-ui/types/zod', not '@object-ui/types' — the latter exports only the TypeScript interface of that name, so the first attempt failed with 'Cannot read properties of undefined (reading safeParse)'. Both specifiers stay inside the declared __tests__ surface.", "The run was interrupted by a usage limit after the card and claim were read and resumed on the coordinator's message. No work was lost or repeated.", "The PR body was edited once after creation, to repair a body-sanitizer bite (an angle-bracket fragment in the ablation paragraph was eaten) and to name the filed finding. Both reads-back confirm the body is intact; the PATCH dropped the platform's session-URL footer to the bare form, and the session URL survives in the body prose as the durable copy." ], "mcp_calls": "10 — issue_read get, issue_read get_comments, create_pull_request, pull_request_read x2, search_issues x1 (the single targeted dedup query), issue_write create, update_pull_request, add_issue_comment, and the read-back of this comment. Repo-scoped REST is 403 in this session, as the dispatch stated, so every GitHub read and write went through MCP; the card body and its comments came through issue_read rather than a bulk channel.", "open_questions": [], "out_of_scope_findings": [ "filed as objectui#7347 (unassigned, unlabelled for PM triage): a grouping field name with surrounding whitespace passes author-time validation, is trimmed by collectGroupingFieldRefs on the way into $select, but is used untrimmed as the bucket key by both grid sites — so every row reads undefined and lands in one (empty) group. Measured, not inferred: the spec safeParse accepts a padded field name, the harvester yields the trimmed one, the grid buckets by the raw one. Same silent-wrong-answer class as objectui#7179, and unlike the null entry it SURVIVES validation, which makes it more reachable through the validated path. Not fixed here: this PR's guard admits such an entry unchanged (its field is a non-empty string), so behaviour is exactly as on main; normalizing the value as well as the admission set would be a behaviour change beyond this card, on a hot file." ] }Generated by Claude Code
os-litant commentedon Sep 2, 2026
PM review: ACCEPT — PR #7346 (head
5eb7a8138, forke75f4c986). Landing once CI on the head is green (Type Check, Test shards 1–4 and Lint were still running at review time; every completed check issuccess, including Bundle Analysis, Doc Snippet Type Check, README Export Check). The dev's worktree is gone, so this is a content read of the fetched branch, the report, and CI.Verified:
ObjectGrid.tsxhas two hunks: the local./useGroupedDataimport at line 43 (NOT the@object-ui/coreimport at line 39 that PR fix(core,app-shell,plugin-grid): lead the synthesized default list view with the object's name field #7284 holds) and thegroupValueFormattermemo at 2034–2051; thefieldsToShowsynthesis at ~2788–2810 is untouched. The hot-file constraint from the claim is met by content.usableGroupingFields(exported fromuseGroupedData.ts) admits an entry iff it is an object with a non-empty stringfield— the same admission rulecollectGroupingFieldRefsuses for the projection — and the hook memoizes it on the rawfieldsarray (not onconfig, whose literal hosts rebuild every render). Unusable entries are dropped; the usable levels still group.GroupingConfigSchemarefuses a null entry and objectui'sListViewSchemainherits that by reference (objectql.zod.ts:427,groupingabsent fromLIST_VIEW_LOCAL_OVERRIDES), so no accept set moves andpackages/typesis untouched — Clause-② stays no. The crash is live anyway because no render-path code runs that validator (nosafeParseinplugin-grid/srcoutside tests; core'svalidateSchemais structural with zerogroupingoccurrences). Both halves are pinned as tests, not prose.groupingNullEntry-7217.test.tsx(6 cases): null / undefined sole entry renders; a null before / after a usable entry still groups; the two validator measurements. Red-first 4 failed / 2 passed with the exactTypeError: Cannot read properties of null (reading 'field'); ablation per site (memo guard removed → 4 failed; hook guard removed → 4 failed), restores proven by blob hash.@object-ui/plugin-grid: patch, presence / no-major / control-bytes / phantom-deps / vi-mock gates green.check-governed-merges.mjs --testover the 4 paths → NOT governed, exit 0.Out-of-scope finding the dev filed for triage: #7347 (a grouping field name with surrounding whitespace passes validation, is trimmed into
$selectby the harvester, but is used untrimmed as the bucket key by both grid sites — every row lands in one empty group; the #7179 class, and it survives validation).Generated by Claude Code
os-litant commentedon Sep 2, 2026
os-dev-report
Round 2 — supersedes the round-1 report above. Same PR, same branch; head moved from 5eb7a81 to 4108232.
{ "issue": 7217, "status": "done", "branch": "claude/issue-7217-grouping-null-entry-guard", "pr": "https://github.com/objectstack-ai/objectui/pull/7346", "premise_still_valid": true, "summary": "Round 2 fixes one red CI gate and changes nothing else. Test (shard 4/4) failed on 5eb7a8138 in scripts/__tests__/one-authority-per-exported-name-6273.test.ts: round 1's type alias published GroupingFieldEntry, a name @object-ui/components already declares at custom/grouping-editor.tsx:14. The two are genuinely different shapes, not one thing spelled twice — the components interface is the grouping editor's fully-populated value with order and collapsed REQUIRED, while the alias is z.input of the spec's GroupingConfig, where both carry defaults and are optional — so the remedy taken was the gate's rename branch, not re-pointing one at the other, which would have made one of the two lie. Renamed to UsableGroupingField, verified free across packages, apps and scripts before choosing; the components interface is untouched and keeps the name it already owned; nothing outside useGroupedData.ts referenced the alias. KNOWN_COLLISIONS was NOT touched, as it is shrink-only. A comment at the declaration now records why the two names differ, so the next reader does not unify them. The change is type-level only with no behaviour change, and every round-1 claim about the grid fix stands unchanged: the guard, the pin, the ablation and the reachability measurement are all as reported, and ObjectGrid.tsx was not touched again, so PR #7284's held regions remain untouched.", "files_changed": [ "packages/plugin-grid/src/useGroupedData.ts (round 2: GroupingFieldEntry renamed to UsableGroupingField, 3 sites, plus the comment explaining the distinction)", "packages/plugin-grid/src/ObjectGrid.tsx (round 1, unchanged in round 2)", "packages/plugin-grid/src/__tests__/groupingNullEntry-7217.test.tsx (round 1, unchanged in round 2)", ".changeset/7217-grouping-null-entry-guard.md (round 1, unchanged in round 2)" ], "tests": "Round 2, all on the final commit 410823207, every heavy run through os-verify-lock.sh slot dev-7217, verdicts read from the printed VERDICT line. THE FAILING GATE REPRODUCED RED LOCALLY FIRST, before any edit: pnpm exec vitest run scripts/__tests__/one-authority-per-exported-name-6273.test.ts gave Tests 1 failed | 10 passed (11), VERDICT command-exit 1, with the identical message CI reported ('GroupingFieldEntry — a NEW colliding name', naming grouping-editor.tsx:14 and useGroupedData.ts:213). GREEN after the rename, gate plus pin together: Test Files 2 passed (2) / Tests 17 passed (17), VERDICT command-exit 0. The rename was proven on disk before measuring: UsableGroupingField occurrences 3, and no 'export type GroupingFieldEntry' declaration left anywhere in plugin-grid (the single remaining textual mention is prose inside the explanatory comment, verified by reading the line). WHOLE scripts/ META-GATE FAMILY, which is the derivation round 1 should have made: Test Files 97 passed (97) / Tests 2738 passed (2738), VERDICT command-exit 0. plugin-grid suite re-run: Test Files 110 passed (110) / Tests 999 passed (999), VERDICT command-exit 0. Dependency closure build plus type-check (tsc --noEmit && tsc -p tsconfig.test.json) in one locked run: VERDICT command-exit 0. Lint on the one changed file: E=0 W=26, unchanged from the fork-point baseline, VERDICT command-exit 0. check-control-bytes: 'OK (scanned 6037 tracked text file(s); skipped 85 binary)'. Round-1 evidence still stands as reported and was not re-derived: RED-first 4 failed | 2 passed (6) with 'TypeError: Cannot read properties of null (reading field)', the per-site ablation with both legs red and both restores proven by blob hash under a trap, plugin-list 62 files / 787 tests, and the changeset, phantom-deps, self-import, vi-mock and governed-surface gates.", "deviations": [ "ROUND 2 EXISTS BECAUSE MY ROUND-1 GATE DERIVATION WAS WRONG IN A NAMED WAY, and this is the part worth carrying forward. I derived the local gate set package-scoped: the touched package's suites, its type-check, its own eslint ., and the check:* scripts whose subject matter the diff touched. I never asked whether a REPO-WIDE gate under scripts/__tests__/ reads my diff. one-authority-per-exported-name-6273 does exactly that — it scans every exported type name in the monorepo, so adding a single exported type anywhere can redden it, no matter how small or how local the diff looks. Round 2 ran all 97 files under scripts/ rather than only the one that failed, which is what round 1 owed. The cost was one push-fix round; the report that preceded it was accurate about what it ran and simply did not run enough.", "The remedy chosen is rename, not re-point. The gate offers both, and re-pointing is normally preferable, but the two declarations are structurally different (components requires order and collapsed; the spec z.input has both optional), and the gate's own guidance is to rename when the shapes genuinely differ. KNOWN_COLLISIONS was not touched: it is shrink-only and adding a line is explicitly not a supported way to pass.", "The components interface at packages/components/src/custom/grouping-editor.tsx was READ and deliberately not edited — it is the incumbent authority for that name, it is outside this card's declared file surface, and it is imported by the grouping popover.", "The worktree had been removed at the end of round 1, so it was recreated from the pushed branch (git worktree add with the existing branch, no --no-track since that flag requires creating a new branch). HEAD verified at 5eb7a8138 and the tree clean before any round-2 edit.", "Carried forward from round 1 and still true: origin/main advanced to 2956d7af8 mid-run and the fork point e75f4c986 was kept per the coordinator, after verifying both target lines are byte-identical and at the same line numbers on current origin/main; PM assumption 4 came out half-false (no local re-declaration, but the entry shape arrives by reference from the spec and DOES refuse null), so the run took ZONE 1 item 3's 'validation DOES reject it' branch and no accept-set card is owed; the pin imports ListViewSchema from @object-ui/types/zod because @object-ui/types exports only the TypeScript interface of that name." ], "mcp_calls": "14 cumulative across both rounds — round 1: issue_read get, issue_read get_comments, create_pull_request, pull_request_read x2, search_issues x1 (the single targeted dedup query), issue_write create, update_pull_request, add_issue_comment, issue_read get_comments read-back. Round 2 added 4: update_pull_request, pull_request_read, this add_issue_comment, and its read-back. Repo-scoped REST is 403 in this session, so every GitHub read and write went through MCP.", "open_questions": [], "out_of_scope_findings": [ "filed as objectui#7347 (unassigned, unlabelled for PM triage) in round 1, unchanged by round 2: a grouping field name with surrounding whitespace passes author-time validation, is trimmed by collectGroupingFieldRefs on the way into $select, but is used untrimmed as the bucket key by both grid sites — so every row reads undefined and lands in one (empty) group. Measured, not inferred: the spec safeParse accepts a padded field name, the harvester yields the trimmed one, the grid buckets by the raw one. Same silent-wrong-answer class as objectui#7179, and unlike the null entry it SURVIVES validation, which makes it more reachable through the validated path. Not fixed here: this PR's guard admits such an entry unchanged, so behaviour is exactly as on main." ] }Generated by Claude Code
os-litant commentedon Sep 2, 2026
Round 2 reviewed — ACCEPT stands on the new head
410823207. Verified by content on the fetched branch: the delta from5eb7a8138is exactly one file,packages/plugin-grid/src/useGroupedData.ts(+13 / −4) —GroupingFieldEntryrenamed toUsableGroupingFieldat its three sites, plus a comment recording why the two names stay distinct (the components interface requiresorder/collapsed; this alias is the spec'sz.input, where both are optional).ObjectGrid.tsxis byte-identical between the two heads, so PR #7284's held regions stay untouched;KNOWN_COLLISIONSwas not edited. The dev reproduced the gate red locally first (1 failed / 10 passed with CI's exact message), then ran the wholescripts/__tests__family green (97 files / 2738 tests) — the derivation round 1 owed. CI on410823207started 13:03Z; flip + auto-merge follows the green read.Generated by Claude Code
os-litant commentedon Sep 2, 2026
Landed — PR #7346 merged through the queue as
e21308ef2onmain.Verified by content on
origin/main, not by PR head sha:packages/plugin-grid/src/useGroupedData.tsexportsusableGroupingFields(line 259) and theUsableGroupingFieldalias (line 222);packages/plugin-grid/src/ObjectGrid.tsximports and consumes it at the grouping memo (lines 43 and 2044); the pinpackages/plugin-grid/src/__tests__/groupingNullEntry-7217.test.tsxis in the tree. Control: the pre-existinguseGroupedDataexport is still present in the same file (1 hit), so a zero would have been a real zero.State:
pm:dispatchedremoved in the same stroke (labels nowbug,priority:p3,domain:ui); the card closes via the PR'sFixes #7217. The whitespace-in-field-name defect the measurement surfaced stays open as #7347 for triage.Session:
session_01NRRumy89BYdW9ogbcdHTho(domain:uiexecution seat).Generated by Claude Code