Skip to content

Commit 12bb672

Browse files
os-billclaude
andauthored
fix(spec)!: refuse a padded groupByField on kanban, gantt and timeline instead of handing the renderer a lookup that always misses (#18695)
Fixes #17499 Clause-②: no ## What changed `KanbanConfigSchema.groupByField` (**required**), `GanttConfigSchema.groupByField` and `TimelineConfigSchema.groupByField` were bare `z.string()`, so `' stage'` was valid authored metadata. All three now carry the **non-padded** pattern this repo already chose for the same defect on the sibling axis — `grouping.fields[].field`, landed as `f8e5790593` (#17360 / PR #17498), which scoped this axis out by name. The refusal is addressed to the offending key (`kanban.groupByField`, `gantt.groupByField`, `timeline.groupByField`), quotes the offending spelling so the invisible whitespace is visible, and carries the name to write instead. ### Reusing the precedent, and the three places it does not fit - The regex is the precedent's, not a second one. Its module-private constant is renamed `GROUPING_FIELD_NON_PADDED_PATTERN` to `NON_PADDED_FIELD_NAME_PATTERN` and now serves both axes. No exported symbol moves (`check:api-surface` is clean with no regeneration). - **The kanban key is REQUIRED** — the precedent's is not. That is the whole reason this is its own card: a padded value there cannot be withdrawn by omitting the key, so a `.trim()` producer would be the author's only feedback channel and it would say nothing. The refusal is sharper here, not milder. - **Refuse, not `.trim()`** — the precedent's stated reason, re-checked and unchanged: a trimming schema makes `' stage'` and `'stage'` silently equivalent, the consumer-tolerance direction AGENTS.md #0.1 refuses. None of these three rows honestly wants the other answer, and the required key is the strongest case against it. - **No ruling citation in the message.** The precedent's message ends with its ruling date; ruling C on objectui#7347 is about `grouping.fields[].field` and explicitly not about this axis, so quoting it here would attribute a decision nobody made. The code comment says so. ## ADR-0087 disposition: `registered`, and the entry is in this PR The changeset carries an `adr-0087: registered ui-list-view-groupbyfield-padded-refused` marker (the HTML-comment form the gate reads), and `packages/spec/src/migrations/entries/semantic/18.ui-list-view-groupbyfield-padded-refused.ts` registers it under protocol major 18, with the regeneration lap committed beside it. Why `registered` and not a `not-required` category, measured against the gate's closed vocabulary: | category | verdict here | |:--|:--| | `unpublished` | refused — `@objectstack/spec` publishes to npm. | | `already-registered` | **false**. The precedent's entry scopes itself to `grouping.fields[].field` and says in its own text that the `groupByField` axis is not touched by it. | | `no-migration-prescription` | refused — the changeset body carries a FROM/TO block, and there genuinely is a prescription: stored views with a padded name are refused on their next authoring-path save and must be re-authored. | | `runtime-interface-only` | refused — this is a spec schema, not a runtime TS interface. | | `type-surface-only` | refused — the narrowing is a parse-time refusal, not a type-only change. | The same-defect precedent took `registered` with a new semantic entry, for the same stored-metadata reason. **The regeneration lap, and why two of its three files show no diff.** `pnpm --filter @objectstack/spec gen:migration-registry` inserted the entry into `registry.ts`'s generated region (56 lines, insertion-only, comment run carried through; nothing hand-edited inside the markers). `gen:spec-changes` and `gen:upgrade-guide` both ran and wrote byte-identical files: each projects majors up to `PROTOCOL_VERSION`, which is `17.0.0`, and this entry registers under **18**. The control is the landed sibling `f8e5790593` — its file list touched neither projection either. `check:migration-registry`, `check:spec-changes` and `check:upgrade-guide` are all green. ## Evidence **Red before green — ablation, on-disk proof, hash-verified restore.** The fix was committed first; the mutation removed the three `superRefine` calls from `packages/spec/src/ui/view.zod.ts` (the subject resolves through the same-package relative import `./view.zod`, so there is no `dist` leg to prove — the test never resolves through `exports`). ``` BEFORE HEAD blob 6ff7ba3 == on-disk hash; 3 occurrences MUTATED on-disk hash bf9424b852ac545affc8d69abc8548e1114817da; 0 occurrences; git diff --stat: 3 deletions ABLATED 24 failed | 40 passed (exit 1) RESTORE git checkout HEAD -- packages/spec/src/ui/view.zod.ts; on-disk hash back to 6ff7ba3…, git status clean GREEN 64 passed | 387 skipped (exit 0) ``` The direction was predicted before the run and observed as predicted: turning red, 24 of the 64 arms. The 40 that stayed green are the LIT and no-trim arms, which is what makes the 24 a reading rather than a restatement. **⭐ LIT — the narrowing does not over-reach.** Every distinct `groupByField` spelling this repo carries still parses, on all three schemas: harvested across every `.ts` / `.tsx` / `.mdx` / `.json` / `.mjs` outside `node_modules`, **14 distinct literals, zero of them padded**. `owner.name` is the load-bearing member and is pinned — these keys hold a field reference, not a machine name, which is why the snake_case grammar `/^[a-z_][a-z0-9_]*$/` is the wrong vocabulary (`packages/lint`'s `validate-list-view-field-refs.test.ts` carries `kanban: { groupByField: 'owner.name' }` in a case asserting no findings; that suite is green here, 137 tests). Three of the 14 — `'warning'`, `'error'`, a bracketed `select_or_status_field` placeholder — are a severity-map value and prose in a completeness hint, so they are not authored names and are not pinned. **⭐ DARK — three readings that must be 0, before and after.** | reading | before | after | |:--|:--|:--| | other bare `z.string()` keys in `view.zod.ts` reached by this change | 0 | 0 — the edit touches exactly the 3 `groupByField` declarations; `git diff` shows 3 changed property sites and no other schema key. A per-schema test probes every sibling string key (`summarizeField`, `titleField`, `startDateField`, `endDateField`, `progressField`, `colorField`) with a padded value and requires it to still parse. | | `authorable-surface/` rows moved | 0 | 0 — `check:authorable-surface` green with no regeneration; a value-level refusal adds no key. | | `api-surface/` entries moved | 0 | 0 — `check:api-surface` green with no regeneration; the published type surface is byte-unchanged. | **Which gate can actually go red on this — measured, not guessed.** `check:docs` reads these three `describe()` strings: it exited **1** naming `content/docs/references/ui/view.mdx` and `content/docs/references/ui/component.mdx` before `gen:schema && gen:docs`, and 0 after. `check:adr-0087-registration` reads the changeset and exited **1** before the ledger entry landed, naming exactly the missing id, and **0** after it — red before green on that gate too. The remaining 13 of the 15 spec artifact gates were green throughout — they structurally cannot see a value-level refine, and are recorded as DARK rather than claimed as coverage. **Commands run.** The schema and test blobs have not moved since the first lap (`view.zod.ts` is `6ff7ba320a8fa445fa34e421b39716359194bc1b` at both heads and on disk, `view.test.ts` is `162772c6…`), so the ablation above stands unrepeated; every other reading below was re-taken at `ca0dd6ce0e` after the ledger entry landed: | command | result | |:--|:--| | `pnpm --filter @objectstack/spec test` | 485 files / **13932 passed** | | `pnpm --filter @objectstack/spec typecheck` | exit 0 (test layer compiles; debt ledger held) | | `pnpm --filter @objectstack/spec check:generated` | **all 15 artifacts up to date** (both laps) | | `pnpm --filter @objectstack/lint exec vitest run src/validate-list-view-field-refs.test.ts src/runtime-gate.view-writes.test.ts` | 137 passed | | `pnpm exec eslint . --no-inline-config` | **6819 files, 0 errors, 0 warnings** — the whole population at `ca0dd6ce0e`, no narrowing claimed; both files this lap added or changed are provably in the run | | `pnpm check:nul-bytes` | 8804 files, no raw control bytes | | `pnpm check:empty-changeset`, `check-changeset-no-major.mjs` | exit 0 | | `check-adr-0087-registration.mjs` | **exit 0** — `1 declared-breaking changeset(s), each carrying an ADR-0087 disposition … registered ui-list-view-groupbyfield-padded-refused (new here)` | ## Acceptance notes - The empty string still parses on all three keys, deliberately: this card narrows padding only, and widening the pattern to catch `''` would be a second, undeclared narrowing riding on it. On the required kanban key a blank name is a blank board, refused one layer down by the field-reference rules, not by this pattern. - Noted, not filed — objectui's `packages/i18n` carries `groupByField: 'Group by field'` / `'分组字段'`. Those are i18n label entries keyed by the same name, not values of this spec key, so they are neither affected nor evidence about accepted values. Recorded because a reader harvesting the sibling repo will hit them. - Noted, not filed — `packages/spec/src/kernel/functional-completeness.ts` documents the kanban fallback chain as `groupBy = groupByField || groupField || ...`, and `groupField` is a live legacy alias declared in objectui, not in this repo. Nothing here is wrong; it is the context a future reader of this key needs. Carrier: whoever next touches the kanban completeness hint. Authored by the `domain:spec` execution seat, session `session_01JbZnqu8bt6YqfJsr9vaFb3`. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 51297e9 commit 12bb672

7 files changed

Lines changed: 434 additions & 18 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
fix(spec)!: `groupByField` refuses a padded field name on kanban, gantt and timeline instead of handing the renderer a lookup that always misses (#17499)
6+
7+
**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`.
8+
9+
## What was wrong
10+
11+
The padded name never failed anywhere. It failed to *group*.
12+
13+
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.
14+
15+
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.
16+
17+
`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.
18+
19+
## What it does now
20+
21+
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.
22+
23+
⛔ **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.
24+
25+
## FROM → TO
26+
27+
| you wrote | write instead |
28+
|:--|:--|
29+
| `kanban: { groupByField: ' stage' }` | `kanban: { groupByField: 'stage' }` |
30+
| `gantt: { groupByField: 'owner ' }` | `gantt: { groupByField: 'owner' }` |
31+
| `timeline: { groupByField: 'team\n' }` | `timeline: { groupByField: 'team' }` |
32+
33+
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.
34+
35+
## Scope — what is deliberately NOT narrowed
36+
37+
- **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.
38+
- **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.
39+
- **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.
40+
41+
## Who is affected, measured
42+
43+
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 `'<select_or_status_field>'` 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.
44+
45+
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.
46+
47+
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.
48+
49+
<!-- adr-0087: registered ui-list-view-groupbyfield-padded-refused -->

‎content/docs/references/ui/component.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -476,7 +476,7 @@ Sort field and direction pair
476476
| **typeField** | `string` | optional | Field whose value maps to task/summary/milestone |
477477
| **baselineStartField** | `string` | optional | Baseline (planned) start field |
478478
| **baselineEndField** | `string` | optional | Baseline (planned) end field |
479-
| **groupByField** | `string` | optional | Field to group leaf tasks by (synthesized summary rows) |
479+
| **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. |
480480
| **resourceView** | `boolean` | optional | Render a per-resource workload histogram instead of the timeline |
481481
| **assigneeField** | `string` | optional | Resource field to bucket load by (resource view) |
482482
| **effortField** | `string` | optional | Per-task load units (resource view; default 1) |

‎content/docs/references/ui/view.mdx‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -565,7 +565,7 @@ Gallery/card view configuration
565565
| **typeField** | `string` | optional | Field whose value maps to task/summary/milestone |
566566
| **baselineStartField** | `string` | optional | Baseline (planned) start field |
567567
| **baselineEndField** | `string` | optional | Baseline (planned) end field |
568-
| **groupByField** | `string` | optional | Field to group leaf tasks by (synthesized summary rows) |
568+
| **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. |
569569
| **resourceView** | `boolean` | optional | Render a per-resource workload histogram instead of the timeline |
570570
| **assigneeField** | `string` | optional | Resource field to bucket load by (resource view) |
571571
| **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
696696

697697
| Property | Type | Required | Description |
698698
| :--- | :--- | :--- | :--- |
699-
| **groupByField** | `string` | ✅ | Field to group columns by (usually status/select) |
699+
| **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. |
700700
| **summarizeField** | `string` | optional | Field to sum at top of column (e.g. amount) |
701701
| **titleField** | `string` | optional | Field displayed as the card title. Omit to fall back to the record display name (ADR-0079 resolver chain) |
702702
| **columns** | `string[]` | ✅ | Fields to show on cards |
@@ -933,7 +933,7 @@ View filter rule
933933

934934
| Property | Type | Required | Description |
935935
| :--- | :--- | :--- | :--- |
936-
| **groupByField** | `string` | ✅ | Field to group columns by (usually status/select) |
936+
| **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. |
937937
| **summarizeField** | `string` | optional | Field to sum at top of column (e.g. amount) |
938938
| **titleField** | `string` | optional | Field displayed as the card title. Omit to fall back to the record display name (ADR-0079 resolver chain) |
939939
| **columns** | `string[]` | ✅ | Fields to show on cards |
@@ -962,7 +962,7 @@ View filter rule
962962
| **typeField** | `string` | optional | Field whose value maps to task/summary/milestone |
963963
| **baselineStartField** | `string` | optional | Baseline (planned) start field |
964964
| **baselineEndField** | `string` | optional | Baseline (planned) end field |
965-
| **groupByField** | `string` | optional | Field to group leaf tasks by (synthesized summary rows) |
965+
| **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. |
966966
| **resourceView** | `boolean` | optional | Render a per-resource workload histogram instead of the timeline |
967967
| **assigneeField** | `string` | optional | Resource field to bucket load by (resource view) |
968968
| **effortField** | `string` | optional | Per-task load units (resource view; default 1) |
@@ -999,7 +999,7 @@ View filter rule
999999
| **startDateField** | `string` | ✅ | Field for timeline item start date |
10001000
| **endDateField** | `string` | optional | Field for timeline item end date |
10011001
| **titleField** | `string` | ✅ | Field to display as timeline item title |
1002-
| **groupByField** | `string` | optional | Field to group timeline rows |
1002+
| **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. |
10031003
| **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 |
10041004
| **scale** | `Enum<'hour' \| 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional (default: `"week"`) | Default timeline scale |
10051005

@@ -1331,7 +1331,7 @@ View filter rule
13311331

13321332
| Property | Type | Required | Description |
13331333
| :--- | :--- | :--- | :--- |
1334-
| **groupByField** | `string` | ✅ | Field to group columns by (usually status/select) |
1334+
| **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. |
13351335
| **summarizeField** | `string` | optional | Field to sum at top of column (e.g. amount) |
13361336
| **titleField** | `string` | optional | Field displayed as the card title. Omit to fall back to the record display name (ADR-0079 resolver chain) |
13371337
| **columns** | `string[]` | ✅ | Fields to show on cards |
@@ -1360,7 +1360,7 @@ View filter rule
13601360
| **typeField** | `string` | optional | Field whose value maps to task/summary/milestone |
13611361
| **baselineStartField** | `string` | optional | Baseline (planned) start field |
13621362
| **baselineEndField** | `string` | optional | Baseline (planned) end field |
1363-
| **groupByField** | `string` | optional | Field to group leaf tasks by (synthesized summary rows) |
1363+
| **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. |
13641364
| **resourceView** | `boolean` | optional | Render a per-resource workload histogram instead of the timeline |
13651365
| **assigneeField** | `string` | optional | Resource field to bucket load by (resource view) |
13661366
| **effortField** | `string` | optional | Per-task load units (resource view; default 1) |
@@ -1397,7 +1397,7 @@ View filter rule
13971397
| **startDateField** | `string` | ✅ | Field for timeline item start date |
13981398
| **endDateField** | `string` | optional | Field for timeline item end date |
13991399
| **titleField** | `string` | ✅ | Field to display as timeline item title |
1400-
| **groupByField** | `string` | optional | Field to group timeline rows |
1400+
| **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. |
14011401
| **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 |
14021402
| **scale** | `Enum<'hour' \| 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional (default: `"week"`) | Default timeline scale |
14031403

@@ -1649,7 +1649,7 @@ Timeline view configuration
16491649
| **startDateField** | `string` | ✅ | Field for timeline item start date |
16501650
| **endDateField** | `string` | optional | Field for timeline item end date |
16511651
| **titleField** | `string` | ✅ | Field to display as timeline item title |
1652-
| **groupByField** | `string` | optional | Field to group timeline rows |
1652+
| **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. |
16531653
| **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 |
16541654
| **scale** | `Enum<'hour' \| 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional (default: `"week"`) | Default timeline scale |
16551655

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
// The sibling axis of `ui-list-view-grouping-field-padded-refused` (#17360),
6+
// which scoped this one out by name. Same defect, same refusal, one difference
7+
// that changes the author's options: `kanban.groupByField` is REQUIRED, so a
8+
// padded value there cannot be withdrawn by omitting the key.
9+
export const entry: SemanticMigration = {
10+
id: 'ui-list-view-groupbyfield-padded-refused',
11+
surface: 'list-view group-by field names — `kanban.groupByField` (`KanbanConfigSchema`, '
12+
+ 'REQUIRED), `gantt.groupByField` and `timeline.groupByField` (`GanttConfigSchema` / '
13+
+ '`TimelineConfigSchema`, both optional) — values carrying leading or trailing whitespace',
14+
replacement: 'the field name written with no leading and no trailing whitespace — the same '
15+
+ 'spelling the object declares and the server answers under. A padded value is RE-AUTHORED, '
16+
+ 'never trimmed on the author\'s behalf: `\' stage\'` becomes `\'stage\'`. The refusal names '
17+
+ 'the offending spelling verbatim, so the whitespace an author cannot see in an editor is '
18+
+ 'visible in the message, next to the name to write instead.',
19+
reason:
20+
'#17499. All three keys were a bare `z.string()`, so a padded group-by name was valid '
21+
+ 'authored metadata all the way to the renderers. The name is a LOOKUP KEY on every row, '
22+
+ 'measured in objectui at `dda8f3815`: the kanban board resolves its lane as '
23+
+ '`laneField = groupByField || groupField || detectStatusField(objectDef)` and buckets cards '
24+
+ 'by `card[laneField]`; `ObjectGantt`\'s `groupByAccessor` splits the name on `.` and walks '
25+
+ 'the backing record (`resolvePath(task.data, field)`); the timeline groups its rows the same '
26+
+ 'way. The server answers under the unpadded name, so every per-row lookup reads `undefined` '
27+
+ 'and the board collapses into one `Uncategorized` lane — the gantt and the timeline into one '
28+
+ 'ungrouped bucket — holding every record. That is a silent wrong answer that reads as a true '
29+
+ 'statement about the data: one giant bucket is indistinguishable from a dataset where the '
30+
+ 'field genuinely is empty, which is why nothing weaker than a parse refusal is honest here. '
31+
+ '`packages/lint`\'s `validate-list-view-field-refs` already grades this position `error` for '
32+
+ 'the same consequence, but it only runs where an app is validated against its object '
33+
+ 'definitions; the producer accepted the value regardless. ⛔ NOT a `.trim()`: a trimming '
34+
+ 'schema makes `\' stage\'` and `\'stage\'` silently equivalent, the consumer-tolerance '
35+
+ 'direction AGENTS.md #0.1 refuses — and on the REQUIRED kanban key the author cannot '
36+
+ 'withdraw the value by omitting the key, so a normalising producer would be their only '
37+
+ 'feedback channel and it would say nothing. The narrowing is non-padded ONLY and '
38+
+ 'deliberately not the snake_case machine-name grammar `/^[a-z_][a-z0-9_]*$/` this package '
39+
+ 'spells inline for object/field/tool NAMES: a `groupByField` is authored as a field '
40+
+ 'REFERENCE and a dotted relationship path (`owner.name`) is an in-tree spelling of one. '
41+
+ 'Ships at once, no deprecation window (2026-08-27 maintainer ruling 「短期不考虑渐进」).',
42+
acceptanceCriteria:
43+
'Measured against the shipped schemas, not restated from the card. Every stored view whose '
44+
+ '`kanban.groupByField`, `gantt.groupByField` or `timeline.groupByField` carries leading or '
45+
+ 'trailing whitespace is refused on its next authoring-path save, with a `custom` issue at '
46+
+ 'that key\'s own path (`groupByField`, or `kanban.groupByField` when the view is parsed '
47+
+ 'whole) naming the offending spelling verbatim and the trimmed name to write instead; a '
48+
+ 'value that is nothing but whitespace is refused with the remedy "Name the field to group '
49+
+ 'by" rather than a trimmed name, since there is none. Refused: leading, trailing and both; '
50+
+ 'a tab, a newline and a non-breaking space in those positions; whitespace-only. NOT refused, '
51+
+ 'on purpose: whitespace INSIDE the name (`\'Group by field\'` parses), and the EMPTY string '
52+
+ '(unchanged on all three keys, this narrowing covers the silent case only). Nothing is '
53+
+ 'normalised on the way through — an accepted name arrives byte-identical, `\'owner.name\'` '
54+
+ 'included — so a consumer proves the migration by re-saving each view and seeing either a '
55+
+ 'refusal naming the field or a value it can compare byte-for-byte with what it wrote. Every '
56+
+ '`groupByField` spelling in the repo at the time of the change parses unchanged: 14 distinct '
57+
+ 'literals harvested across every `.ts` / `.tsx` / `.mdx` / `.json` / `.mjs` outside '
58+
+ '`node_modules`, zero of them padded, so no fixture had to be rewritten to keep the tree '
59+
+ 'green.',
60+
};

0 commit comments

Comments
 (0)