From 01949095630b4071314b969383362e270d92424c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 14:33:15 +0000 Subject: [PATCH 1/5] fix(spec)!: refuse a padded `groupByField` on kanban, gantt and timeline WIP checkpoint before the verification run. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude --- packages/spec/src/ui/view.zod.ts | 120 ++++++++++++++++++++++++++++--- 1 file changed, 112 insertions(+), 8 deletions(-) diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index ef16a872c88..6ff7ba320a8 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -892,13 +892,17 @@ export const RowHeightSchema = lazySchema(() => z.enum([ const GROUPING_FIELD_RULING = 'ruled 2026-09-10'; /** - * A name with no leading and no trailing whitespace. + * A field-reference name with no leading and no trailing whitespace. + * + * Shared by the two axes that carry this rule: `grouping.fields[].field` + * (#17360) and the three `groupByField` keys (#17499). One spelling for one + * rule — a second regex saying the same thing is a second thing to drift. * * 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 + * tool NAMES: these keys are 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 + * machine-name grammar is the wrong vocabulary for them. The ruling asks * for a non-padded pattern, and that is exactly what this is — nothing wider, * nothing narrower. * @@ -906,7 +910,7 @@ const GROUPING_FIELD_RULING = 'ruled 2026-09-10'; * 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)?$/; +const NON_PADDED_FIELD_NAME_PATTERN = /^(?:\S|\S[\s\S]*\S)?$/; /** * Validate one `grouping.fields[].field` name against the ruling. Returns the @@ -918,7 +922,7 @@ const GROUPING_FIELD_NON_PADDED_PATTERN = /^(?:\S|\S[\s\S]*\S)?$/; * 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; + if (NON_PADDED_FIELD_NAME_PATTERN.test(raw)) return undefined; const trimmed = raw.trim(); const remedy = trimmed === '' @@ -934,6 +938,87 @@ function checkGroupingFieldName(raw: string): string | undefined { + `statement about the data. ${remedy} (${GROUPING_FIELD_RULING}.)`; } +/* + * --------------------------------------------------------------------------- + * `groupByField` — the same non-padded name rule, three more schemas (#17499) + * --------------------------------------------------------------------------- + * + * `KanbanConfigSchema`, `GanttConfigSchema` and `TimelineConfigSchema` each + * declare a `groupByField`, and all three were bare `z.string()`. This is the + * sibling axis #17360 named and scoped OUT by name ("symmetric and is + * explicitly not this card"), so the shape is the one that card already landed + * — a refusal, not a `.trim()` — and the only thing re-derived here is what + * the padded spelling DOES per view type, because that is the sentence an + * author reads. + * + * Three differences from the precedent, all deliberate: + * + * 1. `KanbanConfigSchema.groupByField` is REQUIRED. A padded spelling there + * cannot be dropped by omitting the key, which is why the whole axis is + * worth its own card rather than a footnote on #17360. + * 2. The refusal is addressed per view type (`kanban.groupByField`, + * `gantt.groupByField`, `timeline.groupByField`) rather than to one path, + * because these are three keys on three schemas, not one key reached + * through an array index. + * 3. It carries no ruling citation. #17360's ruling C (objectui#7347) is + * about `grouping.fields[].field` and explicitly not about this axis, so + * quoting its date here would attribute a decision that was never made. + * + * What the name is used for, measured in the consumer (objectui `dda8f3815`): + * the kanban board resolves its lane as + * `laneField = groupByField || groupField || detectStatusField(objectDef)` + * and buckets cards by `card[laneField]`; `ObjectGantt`'s `groupByAccessor` + * splits the name on `.` and walks the backing record + * (`resolvePath(task.data, field)`); the timeline groups its rows the same + * way. Every one of those is a lookup BY THAT NAME on data the server answers + * under the unpadded name, so a padded spelling reads `undefined` on every row. + */ + +/** Per-view-type tail of the refusal: what the padded spelling actually does. */ +const GROUP_BY_FIELD_CONSEQUENCE = { + kanban: 'every card falls into one `Uncategorized` lane instead of the column it belongs to', + gantt: 'every leaf task falls into one ungrouped bucket instead of the summary row it belongs to', + timeline: 'every row falls into one ungrouped band instead of the band it belongs to', +} as const; + +/** The three schemas that declare a `groupByField`. */ +type GroupByFieldView = keyof typeof GROUP_BY_FIELD_CONSEQUENCE; + +/** + * Validate one `groupByField` name. Returns the author-facing refusal, or + * `undefined` when the value conforms. + * + * ⛔ Not a `.trim()`, for the reason {@link checkGroupingFieldName} carries: 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 — and on the kanban key, which is required, the + * author has no way to withdraw the value instead. + */ +function checkGroupByFieldName(raw: string, view: GroupByFieldView): string | undefined { + if (NON_PADDED_FIELD_NAME_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 `\`${view}.groupByField\` names the field exactly as it is stored, with no leading or ` + + `trailing whitespace — received ${JSON.stringify(raw)}. The renderer reads that name off ` + + 'every row verbatim while the server answers under the unpadded name, so every per-row ' + + `lookup misses and ${GROUP_BY_FIELD_CONSEQUENCE[view]} — a wrong answer that reads as a ` + + `true statement about the data. ${remedy}`; +} + +/** + * The `superRefine` body for one view type's `groupByField`, so each of the + * three declaration sites keeps its own inline `z.string()….describe()` shape. + */ +const groupByFieldCheck = (view: GroupByFieldView) => (raw: string, ctx: z.RefinementCtx): void => { + const refusal = checkGroupByFieldName(raw, view); + if (refusal) ctx.addIssue({ code: 'custom', message: refusal }); +}; + /** * Grouping Field Configuration * Defines a single grouping level for record grouping. @@ -1069,7 +1154,13 @@ export const TimelineConfigSchema = lazySchema(() => strictObject({ startDateField: z.string().describe('Field for timeline item start date'), endDateField: z.string().optional().describe('Field for timeline item end date'), titleField: z.string().describe('Field to display as timeline item title'), - groupByField: z.string().optional().describe('Field to group timeline rows'), + groupByField: z.string() + .superRefine(groupByFieldCheck('timeline')) + .optional() + .describe( + 'Field to group timeline rows. NO leading or trailing whitespace: the renderer reads this ' + + 'name off every row verbatim, so a padded spelling drops every row into one ungrouped band.', + ), colorField: z.string().optional().describe('Field to derive each item color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else the timeline default marker color'), scale: z.enum(['hour', 'day', 'week', 'month', 'quarter', 'year']).default('week').describe('Default timeline scale'), }).describe('Timeline view configuration')); @@ -1347,7 +1438,13 @@ export const KanbanConfigSchema = lazySchema(() => strictObject({ surface: 'this kanban configuration', history: VIEW_HISTORY, }, { - groupByField: z.string().describe('Field to group columns by (usually status/select)'), + groupByField: z.string() + .superRefine(groupByFieldCheck('kanban')) + .describe( + 'Field to group columns by (usually status/select). NO leading or trailing whitespace: the ' + + 'board reads this name off every card verbatim, so a padded spelling collapses the whole ' + + 'board into one `Uncategorized` lane.', + ), summarizeField: z.string().optional().describe('Field to sum at top of column (e.g. amount)'), /** * [#16894] The one item-titled view config of the family that omitted this @@ -1517,7 +1614,14 @@ export const GanttConfigSchema = lazySchema(() => strictObject({ baselineStartField: z.string().optional().describe('Baseline (planned) start field'), baselineEndField: z.string().optional().describe('Baseline (planned) end field'), // Dynamic grouping: bucket leaf tasks under one synthesized summary per value. - groupByField: z.string().optional().describe('Field to group leaf tasks by (synthesized summary rows)'), + groupByField: z.string() + .superRefine(groupByFieldCheck('gantt')) + .optional() + .describe( + 'Field to group leaf tasks by (synthesized summary rows). NO leading or trailing whitespace: ' + + 'the group accessor reads this name off every task verbatim, so a padded spelling drops ' + + 'every task into one ungrouped bucket.', + ), // Resource / workload view. resourceView: z.boolean().optional().describe('Render a per-resource workload histogram instead of the timeline'), assigneeField: z.string().optional().describe('Resource field to bucket load by (resource view)'), From c9992c19ae6f7251417e4f028c721a863ef0f17a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 14:38:30 +0000 Subject: [PATCH 2/5] test(spec): pin the padded `groupByField` refusal on all three schemas LIT (every in-tree spelling still parses, `owner.name` included), DARK (every sibling string key on the same schema still accepts a padded value), the no-trim discriminator and the required-key double door. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude --- packages/spec/src/ui/view.test.ts | 147 ++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index 1cd0eb99a74..162772c6506 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -2422,6 +2422,153 @@ describe('TimelineConfigSchema', () => { }); }); +// ============================================================================ +// [#17499] `groupByField` refuses a padded field name — kanban / gantt / timeline +// ============================================================================ +// +// The sibling axis of #17360 / PR #17498 (`grouping.fields[].field`, landed as +// `f8e5790593`), which scoped this one out by name. All three keys were bare +// `z.string()`, so `' stage'` was valid authored metadata handed to a consumer +// that looks the name up on every row: objectui (`dda8f3815`) resolves the +// kanban lane as `groupByField || groupField || detectStatusField(objectDef)` +// and buckets cards by `card[laneField]`; `ObjectGantt`'s `groupByAccessor` +// splits the name on `.` and walks the backing record. The server answers +// under the unpadded name, so the padded spelling reads `undefined` on every +// row and the board shows one `Uncategorized` lane / the gantt and timeline +// one ungrouped bucket holding every record — a wrong answer that reads as a +// true statement about the data. +// +// `KanbanConfigSchema.groupByField` is the site that makes this its own card: +// it is REQUIRED, so the padded value cannot be withdrawn by omitting the key. +describe('groupByField — a padded field name is refused (#17499)', () => { + /** + * Per schema: the minimal valid block MINUS `groupByField`, and the sibling + * `z.string()` keys on that same schema the DARK control probes. + */ + const SCHEMAS: Array<[string, z.ZodTypeAny, Record, string[]]> = [ + ['kanban', KanbanConfigSchema as unknown as z.ZodTypeAny, + { columns: ['name'] }, ['summarizeField', 'titleField']], + ['gantt', GanttConfigSchema as unknown as z.ZodTypeAny, + { startDateField: 'starts_at', endDateField: 'ends_at', titleField: 'name' }, + ['startDateField', 'endDateField', 'titleField', 'progressField']], + ['timeline', TimelineConfigSchema as unknown as z.ZodTypeAny, + { startDateField: 'starts_at', titleField: 'name' }, + ['startDateField', 'titleField', 'endDateField', 'colorField']], + ]; + + const PADDED: Array<[string, string]> = [ + ['leading', ' stage'], + ['trailing', 'stage '], + ['both', ' stage '], + ['a tab', '\tstage'], + ['a newline', 'stage\n'], + ['whitespace only', ' '], + ]; + + // Every DISTINCT `groupByField` spelling this repo carries, harvested from + // every `.ts` / `.tsx` / `.mdx` / `.json` / `.mjs` outside `node_modules` + // (14 distinct literals; `'warning'` / `'error'` are severity-map VALUES in + // `packages/lint` and `''` is prose inside a + // completeness hint, so neither is an authored name and neither is listed). + // `owner.name` is the load-bearing member: these keys hold a field + // REFERENCE, and a dotted relationship path is an in-tree spelling of one — + // which is why this is NOT the snake_case machine-name grammar + // `/^[a-z_][a-z0-9_]*$/` (`owner.name` measured `false` against it, while + // `packages/lint`'s `validate-list-view-field-refs.test.ts` carries + // `kanban: { groupByField: 'owner.name' }` in a case asserting no findings). + const IN_TREE_GROUP_BY_FIELD_SPELLINGS = [ + 'status', 'stage', 'team', 'workshop', 'due_date', 'owner', 'owner.name', + 'business_unit', 'A9_no_such_field', 'statuss', 'zzzzzzzzzzzzzzzz', + ]; + + describe.each(SCHEMAS)('%s.groupByField', (view, schema, rest, siblings) => { + it.each(PADDED)('refuses %s whitespace', (_label, spelling) => { + expect(schema.safeParse({ ...rest, groupByField: spelling }).success).toBe(false); + }); + + it('addresses the refusal to `groupByField` BY NAME and quotes the spelling', () => { + const result = schema.safeParse({ ...rest, groupByField: ' stage' }); + expect(result.success).toBe(false); + + const issue = result.error!.issues.find((i) => i.path.join('.') === 'groupByField'); + expect(issue).toBeDefined(); + // The whitespace an author cannot see in an editor is visible here... + expect(issue!.message).toContain('" stage"'); + // ...next to the name to write instead, and the key that is wrong. + expect(issue!.message).toContain('Write "stage".'); + expect(issue!.message).toContain(`\`${view}.groupByField\``); + }); + + // ⛔ NOT a `.trim()`. A trimming schema would make `' stage'` and `'stage'` + // silently equivalent — the consumer-tolerance direction AGENTS.md #0.1 + // refuses, and on the REQUIRED kanban key the author cannot withdraw the + // value instead. This arm is what tells the two designs apart: it pins + // that an accepted name arrives byte-identical, so a schema that + // normalised on the way through would fail here even though it would also + // stop the silent miss. + it('does not trim — an accepted name arrives byte-identical', () => { + const parsed = schema.parse({ ...rest, groupByField: 'stage' }) as { groupByField?: string }; + expect(parsed.groupByField).toBe('stage'); + }); + + // LIT — the narrowing must not over-reach: every in-tree spelling is still + // accepted, on all three schemas. + it.each(IN_TREE_GROUP_BY_FIELD_SPELLINGS)('still accepts the in-tree spelling %s', (spelling) => { + expect(schema.safeParse({ ...rest, groupByField: spelling }).success).toBe(true); + }); + + // DARK — must read 0. The narrowing lands on `groupByField` and on nothing + // else: every sibling `z.string()` key on the SAME schema still accepts a + // padded value. A leak into a neighbour shows up here as a refusal. + it('leaves every sibling string key on the same schema untouched', () => { + expect(siblings.length).toBeGreaterThan(0); // non-vacuous: the table is populated + + for (const key of siblings) { + const probe = { ...rest, groupByField: 'stage', [key]: ' padded_sibling ' }; + expect(schema.safeParse(probe).success).toBe(true); + } + }); + }); + + // The kanban key is REQUIRED — the difference from the precedent that earns + // this card. Omitting it is refused for absence (as before); supplying it + // padded is refused for the padding (new). Both doors, one call shape. + it('kanban.groupByField is required AND non-padded — both doors refuse', () => { + expect(KanbanConfigSchema.safeParse({ columns: ['name'] }).success).toBe(false); + expect(KanbanConfigSchema.safeParse({ columns: ['name'], groupByField: ' stage' }).success).toBe(false); + expect(KanbanConfigSchema.safeParse({ columns: ['name'], groupByField: 'stage' }).success).toBe(true); + }); + + // The refusal survives nesting: a padded name inside a whole list view is + // addressed to the block's own key, not to the view. + it('refuses a padded name through ListViewSchema, addressed to `kanban.groupByField`', () => { + const result = ListViewSchema.safeParse({ + type: 'kanban', + columns: ['name'], + kanban: { groupByField: ' stage', columns: ['name'] }, + }); + + expect(result.success).toBe(false); + expect(result.error!.issues.map((i) => i.path.join('.'))).toContain('kanban.groupByField'); + }); + + // The empty string is deliberately NOT narrowed here — the precedent decided + // that for the sibling axis and nothing about these three keys changes it. + // Widening the pattern to catch `''` would be a second, undeclared narrowing + // riding on this card. + it('still accepts the empty string — this card narrows padding only', () => { + expect(KanbanConfigSchema.safeParse({ columns: ['name'], groupByField: '' }).success).toBe(true); + }); + + // The whole block is a reading only if this schema is genuinely NARROWER + // than the one it replaces: every accepting arm above would pass just as + // well against the old bare `z.string()`. + it('is a narrowing — the discriminator the old schema would fail', () => { + expect(z.string().safeParse(' stage').success).toBe(true); + expect(KanbanConfigSchema.safeParse({ columns: ['name'], groupByField: ' stage' }).success).toBe(false); + }); +}); + describe('ViewSharingSchema', () => { it('should default to collaborative', () => { const sharing = {}; From be28b3c44dc17eff517caa381a25534b56f722d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 14:48:46 +0000 Subject: [PATCH 3/5] docs(spec): regenerate ui/view + ui/component references for the non-padded `groupByField` Generated projection of the three `describe()` changes. `gen:schema` + `gen:docs`; no hand edit. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude --- content/docs/references/ui/component.mdx | 2 +- content/docs/references/ui/view.mdx | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index e352c0cb3c5..eb73234202d 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -476,7 +476,7 @@ Sort field and direction pair | **typeField** | `string` | optional | Field whose value maps to task/summary/milestone | | **baselineStartField** | `string` | optional | Baseline (planned) start field | | **baselineEndField** | `string` | optional | Baseline (planned) end field | -| **groupByField** | `string` | optional | Field to group leaf tasks by (synthesized summary rows) | +| **groupByField** | `string` | optional | Field to group leaf tasks by (synthesized summary rows). NO leading or trailing whitespace: the group accessor reads this name off every task verbatim, so a padded spelling drops every task into one ungrouped bucket. | | **resourceView** | `boolean` | optional | Render a per-resource workload histogram instead of the timeline | | **assigneeField** | `string` | optional | Resource field to bucket load by (resource view) | | **effortField** | `string` | optional | Per-task load units (resource view; default 1) | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 58dd1b7ccf0..256d05a9359 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -565,7 +565,7 @@ Gallery/card view configuration | **typeField** | `string` | optional | Field whose value maps to task/summary/milestone | | **baselineStartField** | `string` | optional | Baseline (planned) start field | | **baselineEndField** | `string` | optional | Baseline (planned) end field | -| **groupByField** | `string` | optional | Field to group leaf tasks by (synthesized summary rows) | +| **groupByField** | `string` | optional | Field to group leaf tasks by (synthesized summary rows). NO leading or trailing whitespace: the group accessor reads this name off every task verbatim, so a padded spelling drops every task into one ungrouped bucket. | | **resourceView** | `boolean` | optional | Render a per-resource workload histogram instead of the timeline | | **assigneeField** | `string` | optional | Resource field to bucket load by (resource view) | | **effortField** | `string` | optional | Per-task load units (resource view; default 1) | @@ -696,7 +696,7 @@ HTTP methods a view data source may request — the subset of `HttpMethod` witho | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **groupByField** | `string` | ✅ | Field to group columns by (usually status/select) | +| **groupByField** | `string` | ✅ | Field to group columns by (usually status/select). NO leading or trailing whitespace: the board reads this name off every card verbatim, so a padded spelling collapses the whole board into one `Uncategorized` lane. | | **summarizeField** | `string` | optional | Field to sum at top of column (e.g. amount) | | **titleField** | `string` | optional | Field displayed as the card title. Omit to fall back to the record display name (ADR-0079 resolver chain) | | **columns** | `string[]` | ✅ | Fields to show on cards | @@ -933,7 +933,7 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **groupByField** | `string` | ✅ | Field to group columns by (usually status/select) | +| **groupByField** | `string` | ✅ | Field to group columns by (usually status/select). NO leading or trailing whitespace: the board reads this name off every card verbatim, so a padded spelling collapses the whole board into one `Uncategorized` lane. | | **summarizeField** | `string` | optional | Field to sum at top of column (e.g. amount) | | **titleField** | `string` | optional | Field displayed as the card title. Omit to fall back to the record display name (ADR-0079 resolver chain) | | **columns** | `string[]` | ✅ | Fields to show on cards | @@ -962,7 +962,7 @@ View filter rule | **typeField** | `string` | optional | Field whose value maps to task/summary/milestone | | **baselineStartField** | `string` | optional | Baseline (planned) start field | | **baselineEndField** | `string` | optional | Baseline (planned) end field | -| **groupByField** | `string` | optional | Field to group leaf tasks by (synthesized summary rows) | +| **groupByField** | `string` | optional | Field to group leaf tasks by (synthesized summary rows). NO leading or trailing whitespace: the group accessor reads this name off every task verbatim, so a padded spelling drops every task into one ungrouped bucket. | | **resourceView** | `boolean` | optional | Render a per-resource workload histogram instead of the timeline | | **assigneeField** | `string` | optional | Resource field to bucket load by (resource view) | | **effortField** | `string` | optional | Per-task load units (resource view; default 1) | @@ -999,7 +999,7 @@ View filter rule | **startDateField** | `string` | ✅ | Field for timeline item start date | | **endDateField** | `string` | optional | Field for timeline item end date | | **titleField** | `string` | ✅ | Field to display as timeline item title | -| **groupByField** | `string` | optional | Field to group timeline rows | +| **groupByField** | `string` | optional | Field to group timeline rows. NO leading or trailing whitespace: the renderer reads this name off every row verbatim, so a padded spelling drops every row into one ungrouped band. | | **colorField** | `string` | optional | Field to derive each item color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else the timeline default marker color | | **scale** | `Enum<'hour' \| 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional (default: `"week"`) | Default timeline scale | @@ -1331,7 +1331,7 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **groupByField** | `string` | ✅ | Field to group columns by (usually status/select) | +| **groupByField** | `string` | ✅ | Field to group columns by (usually status/select). NO leading or trailing whitespace: the board reads this name off every card verbatim, so a padded spelling collapses the whole board into one `Uncategorized` lane. | | **summarizeField** | `string` | optional | Field to sum at top of column (e.g. amount) | | **titleField** | `string` | optional | Field displayed as the card title. Omit to fall back to the record display name (ADR-0079 resolver chain) | | **columns** | `string[]` | ✅ | Fields to show on cards | @@ -1360,7 +1360,7 @@ View filter rule | **typeField** | `string` | optional | Field whose value maps to task/summary/milestone | | **baselineStartField** | `string` | optional | Baseline (planned) start field | | **baselineEndField** | `string` | optional | Baseline (planned) end field | -| **groupByField** | `string` | optional | Field to group leaf tasks by (synthesized summary rows) | +| **groupByField** | `string` | optional | Field to group leaf tasks by (synthesized summary rows). NO leading or trailing whitespace: the group accessor reads this name off every task verbatim, so a padded spelling drops every task into one ungrouped bucket. | | **resourceView** | `boolean` | optional | Render a per-resource workload histogram instead of the timeline | | **assigneeField** | `string` | optional | Resource field to bucket load by (resource view) | | **effortField** | `string` | optional | Per-task load units (resource view; default 1) | @@ -1397,7 +1397,7 @@ View filter rule | **startDateField** | `string` | ✅ | Field for timeline item start date | | **endDateField** | `string` | optional | Field for timeline item end date | | **titleField** | `string` | ✅ | Field to display as timeline item title | -| **groupByField** | `string` | optional | Field to group timeline rows | +| **groupByField** | `string` | optional | Field to group timeline rows. NO leading or trailing whitespace: the renderer reads this name off every row verbatim, so a padded spelling drops every row into one ungrouped band. | | **colorField** | `string` | optional | Field to derive each item color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else the timeline default marker color | | **scale** | `Enum<'hour' \| 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional (default: `"week"`) | Default timeline scale | @@ -1649,7 +1649,7 @@ Timeline view configuration | **startDateField** | `string` | ✅ | Field for timeline item start date | | **endDateField** | `string` | optional | Field for timeline item end date | | **titleField** | `string` | ✅ | Field to display as timeline item title | -| **groupByField** | `string` | optional | Field to group timeline rows | +| **groupByField** | `string` | optional | Field to group timeline rows. NO leading or trailing whitespace: the renderer reads this name off every row verbatim, so a padded spelling drops every row into one ungrouped band. | | **colorField** | `string` | optional | Field to derive each item color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else the timeline default marker color | | **scale** | `Enum<'hour' \| 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional (default: `"week"`) | Default timeline scale | From c10cf097b5553d5debcd0588322823d89c919511 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 14:51:07 +0000 Subject: [PATCH 4/5] chore(changeset): declare the `groupByField` narrowing Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude --- .changeset/17499-groupbyfield-non-padded.md | 49 +++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 .changeset/17499-groupbyfield-non-padded.md diff --git a/.changeset/17499-groupbyfield-non-padded.md b/.changeset/17499-groupbyfield-non-padded.md new file mode 100644 index 00000000000..c5d50710a93 --- /dev/null +++ b/.changeset/17499-groupbyfield-non-padded.md @@ -0,0 +1,49 @@ +--- +"@objectstack/spec": minor +--- + +fix(spec)!: `groupByField` refuses a padded field name on kanban, gantt and timeline instead of handing the renderer a lookup that always misses (#17499) + +**BREAKING** — an accept-set narrowing on three published authoring keys. `KanbanConfigSchema.groupByField` (**required**), `GanttConfigSchema.groupByField` and `TimelineConfigSchema.groupByField` were bare `z.string()`, so `' stage'` was valid authored metadata; all three are now refused at parse. Shipped as `minor` under the repo's launch-window convention for accept-set narrowings, the same as the sibling axis in #17360. Stored metadata carrying a padded `groupByField` now fails validation and must be re-authored — the hand-migration prescription is registered under protocol major 18 as `ui-list-view-groupbyfield-padded-refused`. + +## What was wrong + +The padded name never failed anywhere. It failed to *group*. + +These three keys name a field the consumer looks up on **every row, by that name**. Measured in objectui at `dda8f3815`: the kanban board resolves its lane as `laneField = groupByField || groupField || detectStatusField(objectDef)` and buckets cards by `card[laneField]`; `ObjectGantt`'s `groupByAccessor` splits the name on `.` and walks the backing record (`resolvePath(task.data, field)`); the timeline groups its rows the same way. The server answers under the unpadded name, so a padded spelling reads `undefined` on every row and the board collapses into one `Uncategorized` lane — the gantt and the timeline into one ungrouped bucket — holding every record. + +That is a silent wrong answer that reads as a true statement about the data: a user looking at one giant lane cannot tell it apart from a dataset where the field genuinely is empty. Nothing weaker than a parse refusal is honest about it. + +`packages/lint`'s `validate-list-view-field-refs` already calls this consequence out for `kanban.groupByField` (*"collapses every card into the uncolumned bucket"*), and grades that position `error` — but that rule only runs where an app is validated against its object definitions. The producer accepted the value regardless, which is the hole this closes. + +## What it does now + +Each of the three carries the **non-padded** pattern — no leading and no trailing whitespace — and the refusal is addressed to the offending key (`kanban.groupByField`, `gantt.groupByField`, `timeline.groupByField`), names the offending spelling verbatim so the whitespace an author cannot see in an editor is visible in the message, and carries the name to write instead. + +⛔ **Not a `.trim()`.** A trimming schema makes `' stage'` and `'stage'` 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. On the **required** kanban key this is sharper than on the sibling axis — an author cannot withdraw the value by omitting the key, so a normalising producer would be the author's only feedback channel and it would say nothing. + +## FROM → TO + +| you wrote | write instead | +|:--|:--| +| `kanban: { groupByField: ' stage' }` | `kanban: { groupByField: 'stage' }` | +| `gantt: { groupByField: 'owner ' }` | `gantt: { groupByField: 'owner' }` | +| `timeline: { groupByField: 'team\n' }` | `timeline: { groupByField: 'team' }` | + +The remedy is always the same: write the field name exactly as the object declares it and the server answers under. If a board has been silently showing one `Uncategorized` lane, re-authoring the name is also the fix for that. + +## Scope — what is deliberately NOT narrowed + +- **The empty string is unchanged.** It still parses, exactly as before, on all three keys. This narrowing exists for the **silent** case; widening the pattern to catch `''` would be a second, undeclared narrowing riding on this one. +- **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 these keys deliberately do not take it: a `groupByField` holds a field **reference**, and a dotted relationship path (`owner.name`) is an in-tree spelling of one — `packages/lint`'s `validate-list-view-field-refs.test.ts` carries `kanban: { groupByField: 'owner.name' }` in a case asserting no findings. +- **The sibling axis `grouping.fields[].field`** already landed this rule in #17360 / PR #17498; this change reuses that pattern rather than declaring a second one. + +## Who is affected, measured + +Every `groupByField` spelling in this repo parses unchanged. Harvested across every `.ts` / `.tsx` / `.mdx` / `.json` / `.mjs` outside `node_modules`: **14 distinct literals, zero of them padded** (`'warning'` / `'error'` are severity-map values in `packages/lint` and `''` is prose inside a completeness hint, so neither is an authored name). Nothing in the tree reddens, and no fixture had to be rewritten to keep it green. + +Outside the repo, only metadata that was already grouping wrongly is affected: a padded `groupByField` has never produced a correct board, gantt or timeline on any renderer. + +Clause-②: no (narrowing) — no key is added, removed or renamed, no exported symbol moves (`check:api-surface` clean with no regeneration), and no registry row is added. The accept set narrows back to what the key's description already claimed. + + From ca0dd6ce0ef3ba7cc351629c557a13cb8a24d5c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 15:21:16 +0000 Subject: [PATCH 5/5] feat(spec): register the ADR-0087 semantic entry for the padded `groupByField` refusal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ui-list-view-groupbyfield-padded-refused` under protocol major 18 — the disposition the changeset's marker already claimed. One new file under `entries/semantic/` plus its regeneration lap; nothing hand-edited inside `registry.ts`'s generated markers. `spec-changes.json` and `docs/protocol-upgrade-guide.md` were regenerated and are byte-identical: both project majors up to `PROTOCOL_VERSION` (17.0.0), and this entry registers under 18. The landed sibling `f8e5790593` is the control — it touched neither file either. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude --- ...i-list-view-groupbyfield-padded-refused.ts | 60 +++++++++++++++++++ packages/spec/src/migrations/registry.ts | 56 +++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 packages/spec/src/migrations/entries/semantic/18.ui-list-view-groupbyfield-padded-refused.ts diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-list-view-groupbyfield-padded-refused.ts b/packages/spec/src/migrations/entries/semantic/18.ui-list-view-groupbyfield-padded-refused.ts new file mode 100644 index 00000000000..71685074bf7 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-list-view-groupbyfield-padded-refused.ts @@ -0,0 +1,60 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// The sibling axis of `ui-list-view-grouping-field-padded-refused` (#17360), +// which scoped this one out by name. Same defect, same refusal, one difference +// that changes the author's options: `kanban.groupByField` is REQUIRED, so a +// padded value there cannot be withdrawn by omitting the key. +export const entry: SemanticMigration = { + id: 'ui-list-view-groupbyfield-padded-refused', + surface: 'list-view group-by field names — `kanban.groupByField` (`KanbanConfigSchema`, ' + + 'REQUIRED), `gantt.groupByField` and `timeline.groupByField` (`GanttConfigSchema` / ' + + '`TimelineConfigSchema`, both optional) — 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: `\' stage\'` becomes `\'stage\'`. The refusal names ' + + 'the offending spelling verbatim, so the whitespace an author cannot see in an editor is ' + + 'visible in the message, next to the name to write instead.', + reason: + '#17499. All three keys were a bare `z.string()`, so a padded group-by name was valid ' + + 'authored metadata all the way to the renderers. The name is a LOOKUP KEY on every row, ' + + 'measured in objectui at `dda8f3815`: the kanban board resolves its lane as ' + + '`laneField = groupByField || groupField || detectStatusField(objectDef)` and buckets cards ' + + 'by `card[laneField]`; `ObjectGantt`\'s `groupByAccessor` splits the name on `.` and walks ' + + 'the backing record (`resolvePath(task.data, field)`); the timeline groups its rows the same ' + + 'way. The server answers under the unpadded name, so every per-row lookup reads `undefined` ' + + 'and the board collapses into one `Uncategorized` lane — the gantt and the timeline into one ' + + 'ungrouped bucket — holding every record. That is a silent wrong answer that reads as a true ' + + 'statement about the data: one giant bucket is indistinguishable from a dataset where the ' + + 'field genuinely is empty, which is why nothing weaker than a parse refusal is honest here. ' + + '`packages/lint`\'s `validate-list-view-field-refs` already grades this position `error` for ' + + 'the same consequence, but it only runs where an app is validated against its object ' + + 'definitions; the producer accepted the value regardless. ⛔ NOT a `.trim()`: a trimming ' + + 'schema makes `\' stage\'` and `\'stage\'` silently equivalent, the consumer-tolerance ' + + 'direction AGENTS.md #0.1 refuses — and on the REQUIRED kanban key the author cannot ' + + 'withdraw the value by omitting the key, so a normalising producer would be their only ' + + 'feedback channel and it would say nothing. 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 `groupByField` is authored as a field ' + + 'REFERENCE and a dotted relationship path (`owner.name`) is an in-tree spelling of one. ' + + 'Ships at once, no deprecation window (2026-08-27 maintainer ruling 「短期不考虑渐进」).', + acceptanceCriteria: + 'Measured against the shipped schemas, not restated from the card. Every stored view whose ' + + '`kanban.groupByField`, `gantt.groupByField` or `timeline.groupByField` carries leading or ' + + 'trailing whitespace is refused on its next authoring-path save, with a `custom` issue at ' + + 'that key\'s own path (`groupByField`, or `kanban.groupByField` when the view is parsed ' + + 'whole) naming the offending spelling verbatim and the trimmed name to write instead; a ' + + 'value that is nothing but whitespace is refused with the remedy "Name the field to group ' + + 'by" rather than a trimmed name, since there is none. Refused: leading, trailing and both; ' + + 'a tab, a newline and a non-breaking space in those positions; whitespace-only. NOT refused, ' + + 'on purpose: whitespace INSIDE the name (`\'Group by field\'` parses), and the EMPTY string ' + + '(unchanged on all three keys, this narrowing covers the silent case only). Nothing is ' + + 'normalised on the way through — an accepted name arrives byte-identical, `\'owner.name\'` ' + + 'included — so a consumer proves the migration by re-saving each view and seeing either a ' + + 'refusal naming the field or a value it can compare byte-for-byte with what it wrote. Every ' + + '`groupByField` spelling in the repo at the time of the change parses unchanged: 14 distinct ' + + 'literals harvested across every `.ts` / `.tsx` / `.mdx` / `.json` / `.mjs` outside ' + + '`node_modules`, zero of them padded, so no fixture had to be rewritten to keep the tree ' + + 'green.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 3d54818d5d9..95b45dab9d0 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -11683,6 +11683,62 @@ 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.', }, + // The sibling axis of `ui-list-view-grouping-field-padded-refused` (#17360), + // which scoped this one out by name. Same defect, same refusal, one difference + // that changes the author's options: `kanban.groupByField` is REQUIRED, so a + // padded value there cannot be withdrawn by omitting the key. + { + id: 'ui-list-view-groupbyfield-padded-refused', + surface: 'list-view group-by field names — `kanban.groupByField` (`KanbanConfigSchema`, ' + + 'REQUIRED), `gantt.groupByField` and `timeline.groupByField` (`GanttConfigSchema` / ' + + '`TimelineConfigSchema`, both optional) — 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: `\' stage\'` becomes `\'stage\'`. The refusal names ' + + 'the offending spelling verbatim, so the whitespace an author cannot see in an editor is ' + + 'visible in the message, next to the name to write instead.', + reason: + '#17499. All three keys were a bare `z.string()`, so a padded group-by name was valid ' + + 'authored metadata all the way to the renderers. The name is a LOOKUP KEY on every row, ' + + 'measured in objectui at `dda8f3815`: the kanban board resolves its lane as ' + + '`laneField = groupByField || groupField || detectStatusField(objectDef)` and buckets cards ' + + 'by `card[laneField]`; `ObjectGantt`\'s `groupByAccessor` splits the name on `.` and walks ' + + 'the backing record (`resolvePath(task.data, field)`); the timeline groups its rows the same ' + + 'way. The server answers under the unpadded name, so every per-row lookup reads `undefined` ' + + 'and the board collapses into one `Uncategorized` lane — the gantt and the timeline into one ' + + 'ungrouped bucket — holding every record. That is a silent wrong answer that reads as a true ' + + 'statement about the data: one giant bucket is indistinguishable from a dataset where the ' + + 'field genuinely is empty, which is why nothing weaker than a parse refusal is honest here. ' + + '`packages/lint`\'s `validate-list-view-field-refs` already grades this position `error` for ' + + 'the same consequence, but it only runs where an app is validated against its object ' + + 'definitions; the producer accepted the value regardless. ⛔ NOT a `.trim()`: a trimming ' + + 'schema makes `\' stage\'` and `\'stage\'` silently equivalent, the consumer-tolerance ' + + 'direction AGENTS.md #0.1 refuses — and on the REQUIRED kanban key the author cannot ' + + 'withdraw the value by omitting the key, so a normalising producer would be their only ' + + 'feedback channel and it would say nothing. 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 `groupByField` is authored as a field ' + + 'REFERENCE and a dotted relationship path (`owner.name`) is an in-tree spelling of one. ' + + 'Ships at once, no deprecation window (2026-08-27 maintainer ruling 「短期不考虑渐进」).', + acceptanceCriteria: + 'Measured against the shipped schemas, not restated from the card. Every stored view whose ' + + '`kanban.groupByField`, `gantt.groupByField` or `timeline.groupByField` carries leading or ' + + 'trailing whitespace is refused on its next authoring-path save, with a `custom` issue at ' + + 'that key\'s own path (`groupByField`, or `kanban.groupByField` when the view is parsed ' + + 'whole) naming the offending spelling verbatim and the trimmed name to write instead; a ' + + 'value that is nothing but whitespace is refused with the remedy "Name the field to group ' + + 'by" rather than a trimmed name, since there is none. Refused: leading, trailing and both; ' + + 'a tab, a newline and a non-breaking space in those positions; whitespace-only. NOT refused, ' + + 'on purpose: whitespace INSIDE the name (`\'Group by field\'` parses), and the EMPTY string ' + + '(unchanged on all three keys, this narrowing covers the silent case only). Nothing is ' + + 'normalised on the way through — an accepted name arrives byte-identical, `\'owner.name\'` ' + + 'included — so a consumer proves the migration by re-saving each view and seeing either a ' + + 'refusal naming the field or a value it can compare byte-for-byte with what it wrote. Every ' + + '`groupByField` spelling in the repo at the time of the change parses unchanged: 14 distinct ' + + 'literals harvested across every `.ts` / `.tsx` / `.mdx` / `.json` / `.mjs` outside ' + + '`node_modules`, zero of them padded, so no fixture had to be rewritten to keep the tree ' + + 'green.', + }, { id: 'ui-list-view-grouping-field-padded-refused', surface: 'list-view grouping level names — `grouping.fields[].field` '