Blocked-by: objectstack-ai/objectstack#17360
Surfaced while implementing objectui#7217 (the null-entry crash guard), by the measurement that defined that guard's admission rule. Filed unassigned, not fixed there — it is a different defect class from that card's loud crash, and it is deliberately left alone by objectui#7217's guard rather than ridden.
The claim
GroupingFieldSchema.field is a bare z.ZodString with no .trim() and no pattern, so grouping: { fields: [{ field: ' business_unit ' }] } is valid authored metadata. The two readers of that name then disagree:
collectGroupingFieldRefs (packages/core/src/utils/grouping-fields.ts:71-76) trims — it harvests business_unit, and that is what reaches $select.
ObjectGrid's groupValueFormatter memo and useGroupedData's buildSegmentKey bucket rows by the raw, untrimmed key, i.e. row[' business_unit '].
So the server is asked for business_unit and returns it under that name, while the grid looks up a key that no row carries. Every row reads undefined, buildSegmentLabel answers (empty) for all of them, and the view shows ONE group holding every record — no error, no warning, no empty state.
Measured, not inferred
Run in the objectui#7217 worktree against @objectstack/spec/ui and @object-ui/core's built dist:
spec accepts a padded field name: true {"fields":[{"field":" business_unit ","order":"asc","collapsed":false}]}
harvester (drives $select) yields: ["business_unit"]
grid would bucket rows by the RAW key: " business_unit "
Why this is worth a card of its own
It is the exact silent-wrong-answer failure objectui#7179 closed — one (empty) bucket holding every row, which reads as a true statement about the data rather than a bug in the view — but arriving by a different route, and it survives author-time validation. The null entry that objectui#7217 guards is refused by GroupingConfigSchema; a padded name is accepted by it. That makes this one strictly more reachable through the validated path, and quieter.
Why objectui#7217 did not fix it
That card's guard admits an entry when it is an object carrying a non-empty string field — the harvester's own admission rule, chosen so the grid can never group by an entry the projection ignored. A padded name satisfies it, so the entry is admitted and grouping proceeds on the raw key, exactly as it does on main today. Changing the value as well as the admission set would have been a behaviour change beyond that card's crash, on a hot file, so it was left for triage here.
Fix directions, not a ruling
- Normalize at the reader — trim in the grid's entry normalizer so both sides use the harvested name. Smallest change; makes the two readers agree by construction.
- Refuse at the producer — give
GroupingFieldSchema.field a .trim() or a non-padded pattern in @objectstack/spec, so a padded name never becomes valid metadata. This is the contract-first direction (AGENTS.md #0.1: fix the producer, do not teach the renderer to tolerate), but it moves a published validator's accept set and is a spec change, so it needs the maintainer.
- Both: refuse new metadata at the producer, normalize for metadata already stored.
Worth checking whether the same trim asymmetry exists for the sibling view kinds' groupByField (kanban / gantt / timeline) before choosing, since they union their grouping field into the projection through their own path.
Related: objectui#7179 (the projection fix whose harvester does the trimming), objectui#7217 (the null-entry crash guard this came out of).
Generated by Claude Code
Blocked-by: objectstack-ai/objectstack#17360
Surfaced while implementing objectui#7217 (the null-entry crash guard), by the measurement that defined that guard's admission rule. Filed unassigned, not fixed there — it is a different defect class from that card's loud crash, and it is deliberately left alone by objectui#7217's guard rather than ridden.
The claim
GroupingFieldSchema.fieldis a barez.ZodStringwith no.trim()and no pattern, sogrouping: { fields: [{ field: ' business_unit ' }] }is valid authored metadata. The two readers of that name then disagree:collectGroupingFieldRefs(packages/core/src/utils/grouping-fields.ts:71-76) trims — it harvestsbusiness_unit, and that is what reaches$select.ObjectGrid'sgroupValueFormattermemo anduseGroupedData'sbuildSegmentKeybucket rows by the raw, untrimmed key, i.e.row[' business_unit '].So the server is asked for
business_unitand returns it under that name, while the grid looks up a key that no row carries. Every row readsundefined,buildSegmentLabelanswers(empty)for all of them, and the view shows ONE group holding every record — no error, no warning, no empty state.Measured, not inferred
Run in the objectui#7217 worktree against
@objectstack/spec/uiand@object-ui/core's builtdist:Why this is worth a card of its own
It is the exact silent-wrong-answer failure objectui#7179 closed — one
(empty)bucket holding every row, which reads as a true statement about the data rather than a bug in the view — but arriving by a different route, and it survives author-time validation. The null entry that objectui#7217 guards is refused byGroupingConfigSchema; a padded name is accepted by it. That makes this one strictly more reachable through the validated path, and quieter.Why objectui#7217 did not fix it
That card's guard admits an entry when it is an object carrying a non-empty string
field— the harvester's own admission rule, chosen so the grid can never group by an entry the projection ignored. A padded name satisfies it, so the entry is admitted and grouping proceeds on the raw key, exactly as it does onmaintoday. Changing the value as well as the admission set would have been a behaviour change beyond that card's crash, on a hot file, so it was left for triage here.Fix directions, not a ruling
GroupingFieldSchema.fielda.trim()or a non-padded pattern in@objectstack/spec, so a padded name never becomes valid metadata. This is the contract-first direction (AGENTS.md #0.1: fix the producer, do not teach the renderer to tolerate), but it moves a published validator's accept set and is a spec change, so it needs the maintainer.Worth checking whether the same trim asymmetry exists for the sibling view kinds'
groupByField(kanban / gantt / timeline) before choosing, since they union their grouping field into the projection through their own path.Related: objectui#7179 (the projection fix whose harvester does the trimming), objectui#7217 (the null-entry crash guard this came out of).
Generated by Claude Code