From e3dc14b7596cc8fcb00b17dadfb9612f94c568c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 16:37:38 +0000 Subject: [PATCH 1/3] fix(spec)!: refuse a padded `grouping.fields[].field` name at the producer WIP checkpoint before the heavy verification run. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude --- packages/spec/src/ui/view.test.ts | 106 ++++++++++++++++++++++++++++++ packages/spec/src/ui/view.zod.ts | 66 ++++++++++++++++++- 2 files changed, 171 insertions(+), 1 deletion(-) diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index 6d4ed92d7e..a7ac494dfe 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -2036,6 +2036,112 @@ describe('GroupingFieldSchema', () => { expect(() => GroupingFieldSchema.parse(field)).not.toThrow(); }); + + // ========================================================================== + // [#17360] A padded `field` is REFUSED, not trimmed + // (objectui#7347 ruling C, decision batch #110 item 5 — refuse at the producer) + // ========================================================================== + // + // The defect: `field` was a bare `z.string()`, so `' business_unit '` was + // valid authored metadata. objectui measured that its projection harvester + // TRIMS the name for `$select` while three renderers bucket rows by the RAW + // name (plugin-grid `usableGroupingFields`, plugin-list + // `ObjectGallery.groupedItems`, plugin-kanban `effectiveSwimlaneField`), so + // the server answers under `business_unit`, every per-row lookup reads + // `undefined`, and the view shows ONE `(empty)` group / `Uncategorized` lane + // holding every record — a wrong answer that reads as a true statement. + + it('refuses a padded grouping field name BY NAME at `grouping.fields[N].field`', () => { + const result = ListViewSchema.safeParse({ + columns: ['name', 'business_unit'], + grouping: { fields: [{ field: 'status' }, { field: ' business_unit ' }] }, + }); + + expect(result.success).toBe(false); + const issues = result.error!.issues; + + // BY NAME: the issue is addressed to the offending element's own `field` + // key, not to the view or to the array — index 1, the padded one. + expect(issues.map((i) => i.path.join('.'))).toContain('grouping.fields.1.field'); + + // ...and the refusal names the offending spelling, so the author can see + // the whitespace they cannot see in their editor. + const issue = issues.find((i) => i.path.join('.') === 'grouping.fields.1.field')!; + expect(issue.message).toContain('" business_unit "'); + expect(issue.message).toContain('grouping.fields[].field'); + }); + + it.each([ + ['leading', ' business_unit'], + ['trailing', 'business_unit '], + ['both', ' business_unit '], + ['a tab', '\tbusiness_unit'], + ['a newline', 'business_unit\n'], + ['whitespace only', ' '], + ])('refuses %s whitespace', (_label, spelling) => { + expect(GroupingFieldSchema.safeParse({ field: spelling }).success).toBe(false); + }); + + // ⛔ NOT a `.trim()`. A trimming schema would make `' a '` and `'a'` + // silently equivalent — the consumer-tolerance direction AGENTS.md #0.1 + // refuses. This arm is what tells the two designs apart: a trimming schema + // passes the refusal pins above only by NOT refusing, so it would fail there + // first; this arm additionally pins that nothing normalises the value on the + // way through for the names that ARE accepted. + it('does not trim — an accepted name arrives byte-identical', () => { + expect(GroupingFieldSchema.parse({ field: 'business_unit' }).field).toBe('business_unit'); + expect(GroupingFieldSchema.safeParse({ field: ' business_unit ' }).success).toBe(false); + }); + + // ========================================================================== + // [#17360] The narrowing reddens NO existing grouping fixture in the repo + // ========================================================================== + // + // Every DISTINCT `grouping.fields[].field` spelling harvested from the repo + // with the TypeScript parser (50 literal occurrences under a `grouping:` key + // across 19 files; cross-checked against a deliberately over-approximating + // second pass over 906 shape-exact `{ field, order?, collapsed? }` literals + // in `packages/**`). Exactly one harvested spelling is refused by this + // narrowing — `' '` at `view-grouping-query.test.ts:507` — and that one is a + // NEGATIVE fixture handed straight to `compileListViewGroupQuery` with no + // Zod parse anywhere on its path, pinning the consumer's own + // `grouping_field_blank` refusal. It is therefore not a fixture that has to + // parse, and the producer now refuses it one layer earlier for the same + // reason. So: zero reddened fixtures, and it is recorded here rather than + // used as a reason to widen the pattern to fit. + // + // `owner.name` is the load-bearing member: a grouping level is authored as a + // field REFERENCE and a dotted relationship path is an in-tree spelling of + // one, which is why this key does NOT take the snake_case machine-name + // grammar (`/^[a-z_][a-z0-9_]*$/`) the rest of `packages/spec` spells inline + // for object/field/tool NAMES. + const IN_TREE_GROUPING_FIELD_SPELLINGS = [ + 'A7_no_such_field', 'a', 'actor_id', 'b', 'business_unit', 'category', + 'count', 'count_notes', 'department', 'kind', 'namespace', 'object_name', + 'organization_id', 'owner.name', 'priority', 'provider_id', 'status', + 'sum_amount', 'topic', 'user_id', + ] as const; + + it.each(IN_TREE_GROUPING_FIELD_SPELLINGS)('still parses the in-tree spelling %s', (spelling) => { + expect(GroupingFieldSchema.safeParse({ field: spelling }).success).toBe(true); + }); + + it('the in-tree enumeration is non-vacuous — lit and dark controls', () => { + // LIT: the two spellings the harvest is anchored on are actually in the + // list, so the `it.each` above cannot be passing over an empty table. + expect(IN_TREE_GROUPING_FIELD_SPELLINGS).toContain('business_unit'); + expect(IN_TREE_GROUPING_FIELD_SPELLINGS).toContain('owner.name'); + expect(IN_TREE_GROUPING_FIELD_SPELLINGS.length).toBeGreaterThan(15); + + // DARK: a spelling the repo does not carry is absent — the list is a + // harvest, not a wish-list that would pass no matter what was measured. + expect(IN_TREE_GROUPING_FIELD_SPELLINGS).not.toContain('zz_no_such_grouping_field'); + + // And the discriminator: the accepting arm above would pass just as well + // against the OLD bare `z.string()`, so pin that this schema is genuinely + // narrower than the one it replaces. + expect(GroupingFieldSchema.safeParse({ field: ' owner.name' }).success).toBe(false); + }); }); describe('GalleryConfigSchema', () => { diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 0927443b7b..848f63fa33 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -839,6 +839,60 @@ export const RowHeightSchema = lazySchema(() => z.enum([ 'extra_tall', // Maximum padding, rich content preview ]).describe('Row height / density setting for list view')); +/* + * --------------------------------------------------------------------------- + * `grouping.fields[].field` — the non-padded name rule (#17360) + * --------------------------------------------------------------------------- + */ + +// The ruling is objectui#7347 ruling C (decision batch #110 item 5) — internal +// readers get the ids here; the author-facing sentence carries the date only, +// which is what a refused author can act on. +const GROUPING_FIELD_RULING = 'ruled 2026-09-10'; + +/** + * A name with no leading and no trailing whitespace. + * + * Deliberately **not** the snake_case machine-name grammar + * (`/^[a-z_][a-z0-9_]*$/`) this package spells inline for object, field and + * tool NAMES: a grouping level is authored as a field REFERENCE, and a dotted + * relationship path (`owner.name`) is an in-tree spelling of one, so the + * machine-name grammar is the wrong vocabulary for this key. The ruling asks + * for a non-padded pattern, and that is exactly what this is — nothing wider, + * nothing narrower. + * + * The empty string still matches, on purpose: a blank grouping name is already + * refused LOUDLY one layer down (`compileListViewGroupQuery`'s + * `grouping_field_blank`), and this narrowing exists for the SILENT case only. + */ +const GROUPING_FIELD_NON_PADDED_PATTERN = /^(?:\S|\S[\s\S]*\S)?$/; + +/** + * Validate one `grouping.fields[].field` name against the ruling. Returns the + * author-facing refusal, or `undefined` when the value conforms. + * + * ⛔ Not a `.trim()`. A trimming schema would make `' a '` and `'a'` silently + * equivalent, which is the consumer-tolerance direction AGENTS.md #0.1 refuses: + * the padded spelling is a mistake the author should be told about, not a + * dialect the producer should quietly accept and normalise away. + */ +function checkGroupingFieldName(raw: string): string | undefined { + if (GROUPING_FIELD_NON_PADDED_PATTERN.test(raw)) return undefined; + + const trimmed = raw.trim(); + const remedy = trimmed === '' + ? 'Name the field to group by — this value is nothing but whitespace.' + : `Write ${JSON.stringify(trimmed)}.`; + + return '`grouping.fields[].field` names the field exactly as it is stored, with no leading or ' + + `trailing whitespace — received ${JSON.stringify(raw)}. The group header query is compiled ` + + 'from this string verbatim (`compileListViewGroupQuery` sends it as a `groupBy` column) and ' + + 'the server answers under the unpadded name, so a padded spelling makes every renderer\'s ' + + 'per-row lookup miss: the grid and the gallery show a single `(empty)` group and the kanban ' + + 'a single `Uncategorized` lane holding every record — a wrong answer that reads as a true ' + + `statement about the data. ${remedy} (${GROUPING_FIELD_RULING}.)`; +} + /** * Grouping Field Configuration * Defines a single grouping level for record grouping. @@ -854,7 +908,17 @@ export const GroupingFieldSchema = lazySchema(() => strictObject({ surface: 'this grouping field', history: VIEW_HISTORY, }, { - field: z.string().describe('Field name to group by — one `groupBy` column of the group header query; the header row carries its raw stored value (null for the empty group)'), + field: z.string() + .superRefine((raw, ctx) => { + const refusal = checkGroupingFieldName(raw); + if (refusal) ctx.addIssue({ code: 'custom', message: refusal }); + }) + .describe( + 'Field name to group by — one `groupBy` column of the group header query; the header row ' + + 'carries its raw stored value (null for the empty group). NO leading or trailing ' + + 'whitespace: the name is compiled into the aggregate query verbatim, so a padded spelling ' + + 'buckets every row into one empty group instead of grouping them.', + ), order: z.enum(['asc', 'desc']).default('asc').describe('Group sort order — applied by the consumer over the header rows (the aggregate query carries no orderBy)'), collapsed: z.boolean().default(false).describe('Collapse groups by default (presentation only)'), })); From bc0c0712b2b62f87c50eec7c254aebb5dd9990b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 16:40:25 +0000 Subject: [PATCH 2/3] fix(spec)!: refuse a padded `grouping.fields[].field` name at the producer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GroupingFieldSchema.field` was a bare `z.string()`, so `' business_unit '` was valid authored metadata. objectui's projection harvester trims the name for `$select` while three renderers bucket rows by the raw name, so the server answers under `business_unit`, every per-row lookup reads `undefined`, and the view collapses into one `(empty)` group / `Uncategorized` lane holding every record — a silent wrong answer that reads as a true statement about the data. `field` now carries a non-padded pattern: the refusal lands at `grouping.fields[N].field` and names the offending spelling verbatim. Not a `.trim()` — a trimming schema makes `' a '` and `'a'` silently equivalent, the consumer-tolerance direction AGENTS.md #0.1 refuses. Deliberately not the snake_case machine-name grammar `/^[a-z_][a-z0-9_]*$/`: a grouping level is authored as a field reference and a dotted relationship path (`owner.name`) is an in-tree spelling of one. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude --- .changeset/grouping-field-non-padded.md | 48 +++++++++++++++++ ...list-view-grouping-field-padded-refused.ts | 53 +++++++++++++++++++ packages/spec/src/migrations/registry.ts | 49 +++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 .changeset/grouping-field-non-padded.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.ui-list-view-grouping-field-padded-refused.ts diff --git a/.changeset/grouping-field-non-padded.md b/.changeset/grouping-field-non-padded.md new file mode 100644 index 0000000000..e23b459755 --- /dev/null +++ b/.changeset/grouping-field-non-padded.md @@ -0,0 +1,48 @@ +--- +"@objectstack/spec": minor +--- + +fix(spec)!: `grouping.fields[].field` refuses a padded field name instead of handing three renderers a lookup that always misses (#17360, ruling C on objectui#7347) + + + +**BREAKING** — an accept-set narrowing on a published authoring surface. `GroupingFieldSchema.field` was a bare `z.string()`, so `' business_unit '` was valid authored metadata; it is now refused at parse. Shipped as `minor` under the repo's launch-window convention for accept-set narrowings. Stored metadata carrying a padded grouping name now fails validation and must be re-authored — the hand-migration prescription is registered under protocol major 18 as `ui-list-view-grouping-field-padded-refused`. + +## What was wrong + +The padded name never failed anywhere. It failed to *group*. + +Measured on objectui (M1–M11, with live controls): the projection harvester `collectGroupingFieldRefs` **trims** the name when it builds `$select`, while **three** renderers bucket rows by the **raw** name — plugin-grid `usableGroupingFields`, plugin-list `ObjectGallery.groupedItems`, plugin-kanban `effectiveSwimlaneField`. So the server answers under `business_unit`, every per-row lookup asks for `' business_unit '`, reads `undefined`, and the view collapses into one `(empty)` group (grid, gallery) or one `Uncategorized` lane (kanban) holding every record. + +That is a silent wrong answer that reads as a true statement about the data: a user looking at one giant `(empty)` group has no way to tell it apart from a dataset where the field genuinely is empty. Nothing weaker than a parse refusal is honest about it. + +## What it does now + +`grouping.fields[].field` carries a **non-padded** pattern — no leading and no trailing whitespace. The refusal lands at `grouping.fields[N].field` (the offending element's own key, not the view or the array) and names the offending spelling verbatim, so the whitespace an author cannot see in an editor is visible in the message, together with the trimmed name to write instead. + +⛔ **Not a `.trim()`.** A trimming schema makes `' a '` and `'a'` silently equivalent, which is the consumer-tolerance direction AGENTS.md #0.1 refuses: the padded spelling is a mistake the author should be told about, not a dialect the producer quietly normalises away. objectui's harvester trim stays as defence-in-depth; nothing is removed there. + +## FROM → TO + +| you wrote | write instead | +|:--|:--| +| `grouping: { fields: [{ field: ' business_unit ' }] }` | `grouping: { fields: [{ field: 'business_unit' }] }` | +| `grouping: { fields: [{ field: 'status\n' }] }` | `grouping: { fields: [{ field: 'status' }] }` | + +The remedy is always the same: write the field name exactly as the object declares it and the server answers under. If a view has been silently showing one `(empty)` group, re-authoring the name is also the fix for that. + +## Scope — what is deliberately NOT narrowed + +- **The blank name is unchanged.** It is already refused loudly one layer down by `compileListViewGroupQuery`'s `grouping_field_blank` (`400`, path `['grouping','fields',N,'field']`). This narrowing exists for the **silent** case; the empty string still parses here exactly as before. +- **This is not the snake_case machine-name grammar.** `packages/spec` spells `/^[a-z_][a-z0-9_]*$/` inline for object, field and tool **names**, and this key deliberately does not take it: a grouping level is authored as a field **reference**, and a dotted relationship path (`owner.name`) is an in-tree spelling of one. The ruling asked for a non-padded pattern and this is exactly that — nothing wider, nothing narrower. +- **The sibling `groupByField` axis** (kanban / gantt / timeline) is symmetric and is **not** touched by this change. + +## Who is affected, measured + +Every `grouping.fields[].field` spelling in this repo parses unchanged: 50 literal occurrences under a `grouping:` key across 19 files, harvested with the TypeScript parser and cross-checked against a deliberately over-approximating second pass over 906 shape-exact `{ field, order?, collapsed? }` literals in `packages/**`. The single harvested spelling this refuses is `' '` in `view-grouping-query.test.ts` — a **negative** fixture handed straight to `compileListViewGroupQuery` with no parse on its path, pinning that same `grouping_field_blank` refusal. Nothing in the tree reddens. + +Outside the repo, only metadata that was already grouping wrongly is affected: a padded name has never produced a correct grouped view on any renderer. + +## Consumer + +**objectui#7347 unblocks on the INSTALLABLE RELEASE of this package, not on merge.** Its side of the work — a pin bump plus a regression test that a padded name is refused before it reaches any renderer — needs a published `@objectstack/spec` to depend on, so it stays `pm:blocked` until this ships in a release a consumer can install. The gallery and kanban sites are covered by this one producer fix and get no cards of their own. diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-list-view-grouping-field-padded-refused.ts b/packages/spec/src/migrations/entries/semantic/18.ui-list-view-grouping-field-padded-refused.ts new file mode 100644 index 0000000000..576a6cbe2e --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-list-view-grouping-field-padded-refused.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'ui-list-view-grouping-field-padded-refused', + surface: 'list-view grouping level names — `grouping.fields[].field` ' + + '(`GroupingFieldSchema`, the rows inside `ListView.grouping.fields[]`) — ' + + 'values carrying leading or trailing whitespace', + replacement: 'the field name written with no leading and no trailing whitespace — the ' + + 'same spelling the object declares and the server answers under. A padded value is ' + + 'RE-AUTHORED, never trimmed on the author\'s behalf: `\' business_unit \'` becomes ' + + '`\'business_unit\'`. The refusal names the offending spelling verbatim, so the ' + + 'whitespace an author cannot see in an editor is visible in the message.', + reason: + '#17360, ruling C on objectui#7347 (maintainer 「其他同意」, decision batch #110 item 5): ' + + 'refuse at the producer. `field` was a bare `z.string()`, so a padded grouping level ' + + 'was valid authored metadata all the way to the renderers. Measured on objectui ' + + '(M1-M11 with live controls): the projection harvester `collectGroupingFieldRefs` ' + + 'TRIMS the name when it builds `$select`, while THREE renderers bucket rows by the ' + + 'RAW name — plugin-grid `usableGroupingFields`, plugin-list ' + + '`ObjectGallery.groupedItems`, plugin-kanban `effectiveSwimlaneField`. The server ' + + 'therefore answers under `business_unit` while every per-row lookup asks for ' + + '`\' business_unit \'`, reads `undefined`, and the view collapses into ONE `(empty)` ' + + 'group (grid, gallery) or ONE `Uncategorized` lane (kanban) holding every record — a ' + + 'silent wrong answer that reads as a true statement about the data, which is why ' + + 'nothing weaker than a parse refusal is honest here. ⛔ NOT a `.trim()`: a trimming ' + + 'schema makes `\' a \'` and `\'a\'` silently equivalent, the consumer-tolerance ' + + 'direction AGENTS.md #0.1 refuses. objectui\'s harvester trim stays as ' + + 'defence-in-depth; nothing is removed there. The narrowing is non-padded ONLY and ' + + 'deliberately not the snake_case machine-name grammar `/^[a-z_][a-z0-9_]*$/` this ' + + 'package spells inline for object/field/tool NAMES: a grouping level is authored as ' + + 'a field REFERENCE and a dotted relationship path (`owner.name`) is an in-tree ' + + 'spelling of one. The blank name is unchanged here — it is already refused loudly ' + + 'one layer down by `compileListViewGroupQuery`\'s `grouping_field_blank`, and this ' + + 'narrowing exists for the SILENT case. Ships at once, no deprecation window ' + + '(2026-08-27 maintainer ruling 「短期不考虑渐进」).', + acceptanceCriteria: + 'Every stored list view whose `grouping.fields[].field` carries leading or trailing ' + + 'whitespace is refused on its next authoring-path save, with a per-element issue at ' + + '`grouping.fields[N].field` naming the offending spelling and the trimmed name to ' + + 'write instead. Names with no padding parse byte-identically to before — nothing is ' + + 'normalised on the way through, and a dotted relationship path stays valid. Views ' + + 'with no `grouping` block are untouched. Every `grouping.fields[].field` spelling ' + + 'in this repo at the time of the change parses unchanged: 50 literal occurrences ' + + 'under a `grouping:` key across 19 files, harvested with the TypeScript parser and ' + + 'cross-checked against 906 shape-exact `{ field, order?, collapsed? }` literals in ' + + '`packages/**`. The single harvested spelling this refuses — `\' \'` at ' + + '`view-grouping-query.test.ts` — is a NEGATIVE fixture handed straight to ' + + '`compileListViewGroupQuery` with no parse on its path, pinning that same ' + + '`grouping_field_blank` refusal; the producer now refuses it one layer earlier for ' + + 'the same reason.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 5fdd885233..3ca473a736 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -10352,6 +10352,55 @@ const step18: MigrationStep = { + 'authoring-path save (zero such documents were measured to exist); on refusal the ' + 'author re-gates by record state or moves the gate to an app surface.', }, + { + id: 'ui-list-view-grouping-field-padded-refused', + surface: 'list-view grouping level names — `grouping.fields[].field` ' + + '(`GroupingFieldSchema`, the rows inside `ListView.grouping.fields[]`) — ' + + 'values carrying leading or trailing whitespace', + replacement: 'the field name written with no leading and no trailing whitespace — the ' + + 'same spelling the object declares and the server answers under. A padded value is ' + + 'RE-AUTHORED, never trimmed on the author\'s behalf: `\' business_unit \'` becomes ' + + '`\'business_unit\'`. The refusal names the offending spelling verbatim, so the ' + + 'whitespace an author cannot see in an editor is visible in the message.', + reason: + '#17360, ruling C on objectui#7347 (maintainer 「其他同意」, decision batch #110 item 5): ' + + 'refuse at the producer. `field` was a bare `z.string()`, so a padded grouping level ' + + 'was valid authored metadata all the way to the renderers. Measured on objectui ' + + '(M1-M11 with live controls): the projection harvester `collectGroupingFieldRefs` ' + + 'TRIMS the name when it builds `$select`, while THREE renderers bucket rows by the ' + + 'RAW name — plugin-grid `usableGroupingFields`, plugin-list ' + + '`ObjectGallery.groupedItems`, plugin-kanban `effectiveSwimlaneField`. The server ' + + 'therefore answers under `business_unit` while every per-row lookup asks for ' + + '`\' business_unit \'`, reads `undefined`, and the view collapses into ONE `(empty)` ' + + 'group (grid, gallery) or ONE `Uncategorized` lane (kanban) holding every record — a ' + + 'silent wrong answer that reads as a true statement about the data, which is why ' + + 'nothing weaker than a parse refusal is honest here. ⛔ NOT a `.trim()`: a trimming ' + + 'schema makes `\' a \'` and `\'a\'` silently equivalent, the consumer-tolerance ' + + 'direction AGENTS.md #0.1 refuses. objectui\'s harvester trim stays as ' + + 'defence-in-depth; nothing is removed there. The narrowing is non-padded ONLY and ' + + 'deliberately not the snake_case machine-name grammar `/^[a-z_][a-z0-9_]*$/` this ' + + 'package spells inline for object/field/tool NAMES: a grouping level is authored as ' + + 'a field REFERENCE and a dotted relationship path (`owner.name`) is an in-tree ' + + 'spelling of one. The blank name is unchanged here — it is already refused loudly ' + + 'one layer down by `compileListViewGroupQuery`\'s `grouping_field_blank`, and this ' + + 'narrowing exists for the SILENT case. Ships at once, no deprecation window ' + + '(2026-08-27 maintainer ruling 「短期不考虑渐进」).', + acceptanceCriteria: + 'Every stored list view whose `grouping.fields[].field` carries leading or trailing ' + + 'whitespace is refused on its next authoring-path save, with a per-element issue at ' + + '`grouping.fields[N].field` naming the offending spelling and the trimmed name to ' + + 'write instead. Names with no padding parse byte-identically to before — nothing is ' + + 'normalised on the way through, and a dotted relationship path stays valid. Views ' + + 'with no `grouping` block are untouched. Every `grouping.fields[].field` spelling ' + + 'in this repo at the time of the change parses unchanged: 50 literal occurrences ' + + 'under a `grouping:` key across 19 files, harvested with the TypeScript parser and ' + + 'cross-checked against 906 shape-exact `{ field, order?, collapsed? }` literals in ' + + '`packages/**`. The single harvested spelling this refuses — `\' \'` at ' + + '`view-grouping-query.test.ts` — is a NEGATIVE fixture handed straight to ' + + '`compileListViewGroupQuery` with no parse on its path, pinning that same ' + + '`grouping_field_blank` refusal; the producer now refuses it one layer earlier for ' + + 'the same reason.', + }, { id: 'ui-mcp-connect-agent-unknown-keys-refused', surface: 'page `mcp:connect-agent` component — `properties` (any key at all: the widget ' From db549a42c1cebf2a00eb3410e4d97427f4496d50 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 16:55:42 +0000 Subject: [PATCH 3/3] docs(spec): regenerate ui/view reference for the non-padded grouping name Generated projection of the `grouping.fields[].field` describe() change. `gen:schema` + `gen:docs`; no hand edit. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude --- content/docs/references/ui/view.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index da2b0cd7e6..6552cb1937 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -639,7 +639,7 @@ Record grouping configuration — SERVER-SIDE: the set of groups and every numbe | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **field** | `string` | ✅ | Field name to group by — one `groupBy` column of the group header query; the header row carries its raw stored value (null for the empty group) | +| **field** | `string` | ✅ | Field name to group by — one `groupBy` column of the group header query; the header row carries its raw stored value (null for the empty group). NO leading or trailing whitespace: the name is compiled into the aggregate query verbatim, so a padded spelling buckets every row into one empty group instead of grouping them. | | **order** | `Enum<'asc' \| 'desc'>` | optional (default: `"asc"`) | Group sort order — applied by the consumer over the header rows (the aggregate query carries no orderBy) | | **collapsed** | `boolean` | optional (default: `false`) | Collapse groups by default (presentation only) | @@ -652,7 +652,7 @@ Record grouping configuration — SERVER-SIDE: the set of groups and every numbe | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **field** | `string` | ✅ | Field name to group by — one `groupBy` column of the group header query; the header row carries its raw stored value (null for the empty group) | +| **field** | `string` | ✅ | Field name to group by — one `groupBy` column of the group header query; the header row carries its raw stored value (null for the empty group). NO leading or trailing whitespace: the name is compiled into the aggregate query verbatim, so a padded spelling buckets every row into one empty group instead of grouping them. | | **order** | `Enum<'asc' \| 'desc'>` | optional (default: `"asc"`) | Group sort order — applied by the consumer over the header rows (the aggregate query carries no orderBy) | | **collapsed** | `boolean` | optional (default: `false`) | Collapse groups by default (presentation only) |