Skip to content

Commit 8aa2f28

Browse files
committed
feat(spec)!: retire the bare string sort clause on the list-view doors
`ListViewSchema.sort` accepted both the legacy string clause (`"field desc"`) and the structured `{ field, order }[]` array. objectui ruled one sort orthography platform-wide -- the array (objectui#8221, decision batch #77, 2026-09-07, option B) -- and objectui PR #8758 executes it: its `convertSortToQueryParams` refuses a runtime string outright. That made this schema the PRODUCER of documents its own consumer rejects: a view authored with `sort: 'created_at desc'` validated here, cleanly, and then failed downstream, with the author told off by the wrong layer. The grade sat on that asymmetry, not on the string. `sort` survives as a key, one union arm lighter, so this is a VALUE narrowing with no `retiredKey()` tombstone to hang a prescription on. The surviving array member's own `error` map carries it, keyed on `issue.input` being a string -- the shape `view.type`'s retired `'page'` and `view.exportOptions`' retired `'pdf'` already use here. Every other invalid value, and any string reaching a descendant, keeps zod's default report. The narrowing is not free, so the population was measured rather than assumed: a tree-wide census read as structures (not counted as tokens) found the clause authored on three live sites, all converted here -- the shipped showcase list view, the frozen lint snapshot of that same shape, and the published objectstack-ui list-view rule. It therefore carries a D2 conversion, `list-view-sort-string-clause-to-array`, wired into the protocol-18 chain step: the rewrite is lossless and wholly mechanical. A clause that does not parse as `<field> [asc|desc]` is deliberately left alone -- the `'-field'` dialect belongs to `RecordRelatedListProps.sort`, never reaches `convertSortToQueryParams`, and retiring it was not ruled. Retiring the string arm also made the sort entry's own keys visible to the liveness walk, which reported `view/list.sort` as an undeclared container. Drilled with in-repo evidence rather than recorded in the shrink-only baseline: `normalizeSortNodes` reads both `field` and `order`. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
1 parent a9c6477 commit 8aa2f28

14 files changed

Lines changed: 383 additions & 59 deletions

File tree

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec)!: `ListViewSchema.sort` retires the bare string clause — the PRODUCER half of the sort seam, so the contract stops minting documents its own consumer refuses (#17053; objectui#8221, decision batch #77 option B)
6+
7+
<!-- adr-0087: registered list-view-sort-string-clause-to-array -->
8+
9+
**BREAKING** accept-set narrowing at `view.sort` — the list-view doors
10+
(`ListViewSchema`, and the `ObjectListViewSchema` copy behind `object.list` /
11+
`object.listViews.*`) — shipped as `minor` under this repo's launch-window
12+
convention for breaking changes, the same grade its sibling
13+
`object-block-sort-item-array` took for the two `ComponentPropsMap` doors. The
14+
mechanical prescription is registered under protocol major 18 as
15+
`list-view-sort-string-clause-to-array`.
16+
17+
**Why this is graded on the seam, not on the string.** objectui ruled one sort
18+
orthography platform-wide — the array (objectui#8221, decision batch #77,
19+
2026-09-07, option B) — and objectui PR #8758 executes it: `convertSortToQueryParams`
20+
refuses a runtime string and its diagnostic names the array form. `ListViewSchema`
21+
is the producer of exactly those documents: `object.list.sort` is what
22+
`deriveRelatedLists` reads. So until this release a view authored with
23+
`sort: 'created_at desc'` **validated here, cleanly, and then failed downstream** —
24+
the contract minting a shape its consumer rejects, with the author told off by
25+
the wrong layer. Re-measured on this tree before the change, with `bogusProp`
26+
refused by name on the same call as the firing control: `'name desc'`, `'-name'`
27+
and the array form all returned `success: true`, and only a bare number was
28+
refused (`sort/invalid_union`).
29+
30+
`sort` survives as a key, one union arm lighter, so this is a VALUE narrowing with
31+
no `retiredKey()` tombstone to hang a prescription on. The surviving array member's
32+
own `error` map carries it, keyed on `issue.input` being a string — the same shape
33+
`view.type`'s retired `'page'` value and `view.exportOptions`' retired `'pdf'` value
34+
already use in this schema. Every other invalid value (a number, an object, a
35+
string reaching a *descendant* such as a misspelled `order`) keeps zod's default
36+
report, so nobody is told a clause they never wrote "was removed".
37+
38+
**Migration** (`list-view-sort-string-clause-to-array`, a D2 conversion, not a
39+
semantic TODO — the rewrite is lossless and wholly mechanical):
40+
`sort: 'created_at desc'` becomes `sort: [{ field: 'created_at', order: 'desc' }]`;
41+
a bare field name meant ascending, so `sort: 'created_at'` becomes
42+
`sort: [{ field: 'created_at', order: 'asc' }]` — `order` is required on the entry
43+
and is written out rather than omitted; a comma-separated clause becomes one array
44+
entry per key, in the same order. `os migrate meta --from 17` lists these edits for
45+
author sources, and stored rows replay them through `applyConversionsToStoredItem`.
46+
47+
**The narrowing was not free, and the population was measured rather than assumed.**
48+
A tree-wide census over both the TS and JSON spellings of a string-valued `sort`,
49+
read as STRUCTURES rather than counted as tokens, found the clause authored on
50+
three live in-tree sites, all converted here: the shipped showcase list view
51+
`examples/app-showcase/src/ui/views/task.view.ts` (`'estimate_hours desc'`, carried
52+
since objectui#2601 as a deliberate live coverage fixture for the string form), the
53+
frozen `packages/lint` snapshot of that same shipped shape, and the published
54+
`skills/objectstack-ui` list-view rule. The census fired: it *found* documents, and
55+
`tsc` independently reds on the first two the moment the arm is removed. Sites
56+
deliberately NOT converted, having been read rather than grepped: ObjectQL
57+
`query.sort` and the wire `normalizeSortNodes` (different doors, different
58+
dialects), `packages/spec`'s `book`/`doc` field-mapping records whose `sort: 'order'`
59+
is an unrelated key of the same name, and the `packages/lint` rule fixtures, which
60+
feed the PRE-parse walker and never reach this schema.
61+
62+
**Not moved by this release.** `RecordRelatedListProps.sort` keeps its declared
63+
string arm. That string is the `'field'` / `'-field'` dialect normalised by
64+
objectui's own `RelatedList.normalizeSortSpec`; it never reaches
65+
`convertSortToQueryParams`, and retiring it was not ruled. For the same reason the
66+
conversion above declines any clause that does not parse as `<field> [asc|desc]`:
67+
guessing a direction for `'-name'` would invent an ordering the author never wrote,
68+
so on a list view it meets the door's prescription instead.

‎content/docs/references/api/protocol.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1632,7 +1632,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own
16321632
| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record<string, any> }` | optional | Data source configuration (defaults to "object" provider) |
16331633
| **columns** | `string[] \| { field: string; label?: string \| Record<string, string>; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns |
16341634
| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) |
1635-
| **sort** | `string \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | |
1635+
| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | |
16361636
| **searchableFields** | `string[]` | optional | Fields enabled for search |
16371637
| **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters |
16381638
| **resizable** | `boolean` | optional | Enable column resizing |
@@ -1717,7 +1717,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own
17171717
| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record<string, any> }` | optional | Data source configuration (defaults to "object" provider) |
17181718
| **columns** | `string[] \| { field: string; label?: string \| Record<string, string>; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns |
17191719
| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) |
1720-
| **sort** | `string \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | |
1720+
| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | |
17211721
| **searchableFields** | `string[]` | optional | Fields enabled for search |
17221722
| **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters |
17231723
| **resizable** | `boolean` | optional | Enable column resizing |

‎content/docs/references/data/object.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -366,7 +366,7 @@ const result = ApiMethod.parse(data);
366366
| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record<string, any> }` | optional | Data source configuration (defaults to "object" provider) |
367367
| **columns** | `string[] \| { field: string; label?: string \| Record<string, string>; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns |
368368
| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) |
369-
| **sort** | `string \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | |
369+
| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | |
370370
| **searchableFields** | `string[]` | optional | Fields enabled for search |
371371
| **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters |
372372
| **resizable** | `boolean` | optional | Enable column resizing |

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -789,7 +789,7 @@ Map view configuration
789789
| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record<string, any> }` | optional | Data source configuration (defaults to "object" provider) |
790790
| **columns** | `string[] \| { field: string; label?: string \| Record<string, string>; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns |
791791
| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) |
792-
| **sort** | `string \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | |
792+
| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | |
793793
| **searchableFields** | `string[]` | optional | Fields enabled for search |
794794
| **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters |
795795
| **userFilters** | `{ element?: Enum<'dropdown' \| 'tabs' \| 'toggle'>; fields?: object[]; tabs?: object[]; showAllRecords?: boolean; … }` | optional | End-user quick-filter bar: dropdown/toggle fields or tab presets. Omit to let the renderer derive filters from select/boolean fields |
@@ -1195,7 +1195,7 @@ Tab configuration for multi-tab view interface
11951195
| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record<string, any> }` | optional | Data source configuration (defaults to "object" provider) |
11961196
| **columns** | `string[] \| { field: string; label?: string \| Record<string, string>; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns |
11971197
| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) |
1198-
| **sort** | `string \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | |
1198+
| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | |
11991199
| **searchableFields** | `string[]` | optional | Fields enabled for search |
12001200
| **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters |
12011201
| **resizable** | `boolean` | optional | Enable column resizing |
@@ -1791,7 +1791,7 @@ Tab configuration for multi-tab view interface
17911791
| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record<string, any> }` | optional | Data source configuration (defaults to "object" provider) |
17921792
| **columns** | `string[] \| { field: string; label?: string \| Record<string, string>; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns |
17931793
| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) |
1794-
| **sort** | `string \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | |
1794+
| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | |
17951795
| **searchableFields** | `string[]` | optional | Fields enabled for search |
17961796
| **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters |
17971797
| **resizable** | `boolean` | optional | Enable column resizing |
@@ -1876,7 +1876,7 @@ Tab configuration for multi-tab view interface
18761876
| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record<string, any> }` | optional | Data source configuration (defaults to "object" provider) |
18771877
| **columns** | `string[] \| { field: string; label?: string \| Record<string, string>; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns |
18781878
| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) |
1879-
| **sort** | `string \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | |
1879+
| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | |
18801880
| **searchableFields** | `string[]` | optional | Fields enabled for search |
18811881
| **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters |
18821882
| **resizable** | `boolean` | optional | Enable column resizing |
@@ -2116,7 +2116,7 @@ This schema accepts one of the following structures:
21162116
| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record<string, any> }` | optional | Data source configuration (defaults to "object" provider) |
21172117
| **columns** | `string[] \| { field: string; label?: string \| Record<string, string>; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns |
21182118
| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) |
2119-
| **sort** | `string \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | |
2119+
| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | |
21202120
| **searchableFields** | `string[]` | optional | Fields enabled for search |
21212121
| **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters |
21222122
| **userFilters** | `{ element?: Enum<'dropdown' \| 'tabs' \| 'toggle'>; fields?: object[]; tabs?: object[]; showAllRecords?: boolean; … }` | optional | End-user quick-filter bar: dropdown/toggle fields or tab presets. Omit to let the renderer derive filters from select/boolean fields |
@@ -2292,7 +2292,7 @@ This schema accepts one of the following structures:
22922292
| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record<string, any> }` | optional | Data source configuration (defaults to "object" provider) |
22932293
| **columns** | `string[] \| { field: string; label?: string \| Record<string, string>; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns |
22942294
| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) |
2295-
| **sort** | `string \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | |
2295+
| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | |
22962296
| **searchableFields** | `string[]` | optional | Fields enabled for search |
22972297
| **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters |
22982298
| **userFilters** | `{ element?: Enum<'dropdown' \| 'tabs' \| 'toggle'>; fields?: object[]; tabs?: object[]; showAllRecords?: boolean; … }` | optional | End-user quick-filter bar: dropdown/toggle fields or tab presets. Omit to let the renderer derive filters from select/boolean fields |

‎examples/app-showcase/src/ui/views/task.view.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -200,12 +200,12 @@ export const TaskViews = defineView({
200200
{ field: 'estimate_hours' },
201201
],
202202

203-
// @objectstack/spec ListViewSchema.sort accepts a bare STRING
204-
// ("field [asc|desc]"), not only the {field,order}[] array form. This
205-
// is the exact shape that used to crash the renderer with
206-
// "schema.sort.map is not a function" (objectui#2601) — kept here as a
207-
// live coverage fixture so a real list view exercises the string form.
208-
sort: 'estimate_hours desc',
203+
// [#17053] Was the bare STRING clause `'estimate_hours desc'`, kept here
204+
// as the live coverage fixture for the shape that once crashed the
205+
// renderer (objectui#2601). That arm is RETIRED — one sort spelling
206+
// platform-wide, the array (objectui#8221 decision batch #77) — so this
207+
// is the same ordering in the surviving form.
208+
sort: [{ field: 'estimate_hours', order: 'desc' }],
209209

210210
// ADR-0053 — NO `userFilters` here: on an object list view ("views"
211211
// mode) the console suppresses them by design (the view switcher is

‎packages/lint/src/showcase-shape.fixtures.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,8 +182,12 @@ export const SnapshotTaskViews = defineView({
182182
{ field: 'status' },
183183
{ field: 'estimate_hours' },
184184
],
185-
// The bare-string `sort` spelling (objectui#2601), kept as authored.
186-
sort: 'estimate_hours desc',
185+
// [#17053] The bare-string `sort` spelling (objectui#2601) was RETIRED
186+
// from `ListViewSchema`; this snapshot tracks the shipped shape through
187+
// `defineView`, so it carries the same ordering in the array form the
188+
// showcase now authors. The snapshot's SUBJECT is the three nameless
189+
// form sections below — untouched.
190+
sort: [{ field: 'estimate_hours', order: 'desc' }],
187191
},
188192
grid: {
189193
label: 'Grid',

‎packages/spec/liveness/state-counts.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ for both corollaries.
3939
| `skill` | 16 | 0 | 0 | 1 | 0 | 17 |
4040
| `dataset` | 27 | 0 | 0 | 0 | 0 | 27 |
4141
| `page` | 23 | 0 | 0 | 0 | 1 | 24 |
42-
| `view` | 77 | 0 | 0 | 11 | 0 | 88 |
42+
| `view` | 78 | 0 | 0 | 11 | 0 | 89 |
4343
| `report` | 21 | 0 | 0 | 0 | 0 | 21 |
4444
| `dashboard` | 45 | 0 | 0 | 10 | 0 | 55 |
4545
| `webhook` | 19 | 0 | 0 | 0 | 0 | 19 |
@@ -63,4 +63,4 @@ for both corollaries.
6363
| `batch_endpoints` | 5 | 0 | 0 | 2 | 0 | 7 |
6464
| `route_generation` | 0 | 0 | 0 | 4 | 0 | 4 |
6565
| `realtime_subscription` | 0 | 0 | 0 | 6 | 0 | 6 |
66-
| **total** | **861** | **5** | **1** | **95** | **10** | **972** |
66+
| **total** | **862** | **5** | **1** | **95** | **10** | **973** |

0 commit comments

Comments
 (0)