You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 12bb672
Browse filesBrowse the repository at this point in the historyBrowse files
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>
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.
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.
Copy file name to clipboardExpand all lines: content/docs/references/ui/component.mdx
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -476,7 +476,7 @@ Sort field and direction pair
476
476
|**typeField**|`string`| optional | Field whose value maps to task/summary/milestone |
477
477
|**baselineStartField**|`string`| optional | Baseline (planned) start field |
478
478
|**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.|
480
480
|**resourceView**|`boolean`| optional | Render a per-resource workload histogram instead of the timeline |
481
481
|**assigneeField**|`string`| optional | Resource field to bucket load by (resource view) |
|**typeField**|`string`| optional | Field whose value maps to task/summary/milestone |
566
566
|**baselineStartField**|`string`| optional | Baseline (planned) start field |
567
567
|**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.|
569
569
|**resourceView**|`boolean`| optional | Render a per-resource workload histogram instead of the timeline |
570
570
|**assigneeField**|`string`| optional | Resource field to bucket load by (resource view) |
@@ -696,7 +696,7 @@ HTTP methods a view data source may request — the subset of `HttpMethod` witho
696
696
697
697
| Property | Type | Required | Description |
698
698
| :--- | :--- | :--- | :--- |
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.|
700
700
|**summarizeField**|`string`| optional | Field to sum at top of column (e.g. amount) |
701
701
|**titleField**|`string`| optional | Field displayed as the card title. Omit to fall back to the record display name (ADR-0079 resolver chain) |
702
702
|**columns**|`string[]`| ✅ | Fields to show on cards |
@@ -933,7 +933,7 @@ View filter rule
933
933
934
934
| Property | Type | Required | Description |
935
935
| :--- | :--- | :--- | :--- |
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.|
937
937
|**summarizeField**|`string`| optional | Field to sum at top of column (e.g. amount) |
938
938
|**titleField**|`string`| optional | Field displayed as the card title. Omit to fall back to the record display name (ADR-0079 resolver chain) |
939
939
|**columns**|`string[]`| ✅ | Fields to show on cards |
@@ -962,7 +962,7 @@ View filter rule
962
962
|**typeField**|`string`| optional | Field whose value maps to task/summary/milestone |
963
963
|**baselineStartField**|`string`| optional | Baseline (planned) start field |
964
964
|**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.|
966
966
|**resourceView**|`boolean`| optional | Render a per-resource workload histogram instead of the timeline |
967
967
|**assigneeField**|`string`| optional | Resource field to bucket load by (resource view) |
|**startDateField**|`string`| ✅ | Field for timeline item start date |
1000
1000
|**endDateField**|`string`| optional | Field for timeline item end date |
1001
1001
|**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.|
1003
1003
|**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 |
|**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.|
1335
1335
|**summarizeField**|`string`| optional | Field to sum at top of column (e.g. amount) |
1336
1336
|**titleField**|`string`| optional | Field displayed as the card title. Omit to fall back to the record display name (ADR-0079 resolver chain) |
1337
1337
|**columns**|`string[]`| ✅ | Fields to show on cards |
@@ -1360,7 +1360,7 @@ View filter rule
1360
1360
|**typeField**|`string`| optional | Field whose value maps to task/summary/milestone |
1361
1361
|**baselineStartField**|`string`| optional | Baseline (planned) start field |
1362
1362
|**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.|
1364
1364
|**resourceView**|`boolean`| optional | Render a per-resource workload histogram instead of the timeline |
1365
1365
|**assigneeField**|`string`| optional | Resource field to bucket load by (resource view) |
|**startDateField**|`string`| ✅ | Field for timeline item start date |
1398
1398
|**endDateField**|`string`| optional | Field for timeline item end date |
1399
1399
|**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.|
1401
1401
|**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 |
|**startDateField**|`string`| ✅ | Field for timeline item start date |
1650
1650
|**endDateField**|`string`| optional | Field for timeline item end date |
1651
1651
|**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.|
1653
1653
|**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 |
0 commit comments