Skip to content

Commit 4d7d39d

Browse files
committed
fix(spec): revert ListView.sort strictness — the console stamps a UI row id
The full suite caught this: `view-metadata-schema.test.ts` pins `sort: [{ id, field, order }]` as "the exact shape normalizeViewMetadata persists on a console column-sort PUT", and objectui stamps that `id` per row (components/src/custom/sort-builder.tsx:68/:94, crypto.randomUUID()). Closing the sort entry made a console column-sort a 422. The mechanism is the finding, and it governs every nested block in this file: `.strip()` does NOT recurse, any more than `.strict()` does. ViewMetadataSchema rescues Studio's round-trip keys by making its flattened members `.strip()`, but that re-opens the TOP level only — a nested block closed inside ListViewSchema is still reached through that member, so a console-stamped key inside it 422s regardless of the member's own posture. The union's block comment read as though the rescue reached all the way down; it does not, and it now says so. `id` was deliberately NOT declared to silence the rejection. It is a React list key, not protocol: declaring it would put a UI artifact on the authorable surface and teach an AI author to emit a UUID. The end state is #5074's authoring/wire split applied one level down. Also adds the variant-docs entry my discriminatedUnion conversion made visible. `FormView.submitBehavior`'s four variants were always authorable; as a plain z.union the gate could not see them. GOVERNED (ui/forms.mdx names all four), not exempt. Ledger: ui/ 74 -> 75 strip, authorable 34 -> 35, recomputed from surviving rows. Filed from this: #5114 — ViewFilterRuleSchema (closed by an EARLIER batch, live on main) rejects the same class of console-stamped `id` on filter rows. Part of #4001 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9
1 parent 16ffb19 commit 4d7d39d

4 files changed

Lines changed: 90 additions & 36 deletions

File tree

docs/audits/2026-07-unknown-key-strictness-ledger.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -755,12 +755,12 @@ it the same way: the decision is also written beside the schema and pinned in a
755755
test (`flow.test.ts`, `etl.test.ts`), because a row in a table is not where the
756756
next person to open that file will look.
757757

758-
#### `ui/`74 strip of 198
758+
#### `ui/`75 strip of 198
759759

760760
| File | Strip | Sites | Class | Batch |
761761
|---|---|---|---|---|
762762
| `component.zod.ts` | 29 | 29 | authorable (p) | Largest single block left. SDUI component props — **verify the React-prop open slots first**; `check:react-declaration-parity` compares two DECLARATIONS and cannot tell you which props a renderer reads |
763-
| `view.zod.ts` | 4 | 50 | mixed | **16 of 20 closed at #4001 批 18**; the 4 that remain are each measured, and none is unfinished work. Closed: `ViewDataSchema`'s four provider arms, `UserFilterField.options`, `GanttQuickFilter.options`, `GanttConfig.tooltipFields`, `ListView.sort` / `.conditionalFormatting` / `.emptyState`, `FormFieldBase.keyField`, `FormView.subforms`, and `submitBehavior`'s four arms. Reachability was measured, not assumed: a BFS from all 24 metadata-type roots plus `ObjectStackSchema` resolves every one `root-graph`, with `ViewSchema`/`FormViewSchema`/`ViewItemSchema`/`PageSchema` as positive controls and 批 13's no-door shapes UNREACHABLE **in the same run** — and the instrument had to be fixed first: `lazySchema` returns a Proxy, but a carrier writes `X.optional()`, which RESOLVES it, so the closure holds the real instance and comparing the Proxy alone false-negatived `ViewDataSchema` (caught by cross-checking its two literal carrier keys, not by trusting the reading). ⚠️ **Re-checked against #5056**: every 批 18 target is `root-graph` by **identity**, so **none** of the sixteen rests on the `derived-clone` bridge that 批 16 found can mark a dead shape reachable. The one `derived-clone` verdict in the run is `ListViewSchema` — a positive CONTROL, not a target, and independently identity-reachable via `ObjectListViewSchema`. Every closed shape also has a literal carrier key in this file and a named parse door (`defineView` / `defineViewItem` / the `view` metadata-type schema / objectui's `GanttConfigSchema.safeParse` at `plugin-gantt/src/ObjectGantt.tsx:408`) — the strong-evidence class #5056 leaves standing. Curation anchored to named siblings: `direction → order` is the alias #4721 put on `SortNodeSchema` for the identical tuple (it measured `{field, direction:'desc'}` parsing to `{field, order:'asc'}` — a silently REVERSED sort); an option `count` gets a wrong-layer pointer to `showCount` because objectui COMPUTES it per render; and a bare `name` on the `object` data source is deliberately NOT aliased — it is a real key on the view ITEM, so a rename would be finding 7 again. `submitBehavior` became a `discriminatedUnion` on the `kind` literal it already required: as a plain union of four strict members the rejection is an `invalid_union` whose prescription #5014 measured the renderers flattening away. ⚠️ **`GanttConfigSchema` / `TreeConfigSchema` are `strictObject(…).passthrough()`** — open at the parent by design, and this ledger's own counter reads them as `strict` because `postureOf` returns early on the `strictObject` idiom without walking the chain (**#5072**); it inflates the strict count and does not affect this row's strip count. **Still open, all four measured:** `UserFiltersSchema` — closing it would 422 `allowAddTab`, which objectui's renderer reads (`plugin-list/src/UserFilters.tsx:182`/`:742`) and the spec never declared; `saveMetaItem` validates but persists the ORIGINAL body, so the stripped key still reaches the renderer and the capability WORKS today — closing removes a capability rather than making a silent failure loud (**#5073**). The 批 6e reliance question IS answered: `ObjectUserFiltersSchema` is `.omit()`ed off this base and `.omit()` inherits posture, so the pin flips from "drops" to "rejects" — that flip is wanted, and gated only on `allowAddTab`. `ViewItemSchema` ×2 — **wire, not authorable**: objectui's pin control PUTs `{...storedItem, isPinned}` (`ObjectView.tsx:882` → `data-objectstack/src/index.ts:2801`); a stored ViewItem record carries `viewKind` AND `config`, so it lands on THIS member (the flattened members are excluded by their `config: z.undefined()` guard) and closing it would 422 pinning a saved view (**#5074**). `FormFieldBaseSchema` — a module-private BASE whose sole consumer already applies `.strict()` plus the ADR-0089 `strictVisibilityError` map; the door is closed, the ledger counts the base. Each verdict is recorded in three places (schema JSDoc + `view-strictness-batch18.test.ts` + this row) |
763+
| `view.zod.ts` | 5 | 50 | mixed | **15 of 20 closed at #4001 批 18**; the 5 that remain are each measured, and none is unfinished work. Closed: `ViewDataSchema`'s four provider arms, `UserFilterField.options`, `GanttQuickFilter.options`, `GanttConfig.tooltipFields`, `ListView.conditionalFormatting` / `.emptyState`, `FormFieldBase.keyField`, `FormView.subforms`, and `submitBehavior`'s four arms. Reachability was measured, not assumed: a BFS from all 24 metadata-type roots plus `ObjectStackSchema` resolves every one `root-graph`, with `ViewSchema`/`FormViewSchema`/`ViewItemSchema`/`PageSchema` as positive controls and 批 13's no-door shapes UNREACHABLE **in the same run** — and the instrument had to be fixed first: `lazySchema` returns a Proxy, but a carrier writes `X.optional()`, which RESOLVES it, so the closure holds the real instance and comparing the Proxy alone false-negatived `ViewDataSchema` (caught by cross-checking its two literal carrier keys, not by trusting the reading). ⚠️ **Re-checked against #5056**: every 批 18 target is `root-graph` by **identity**, so **none** of the fifteen rests on the `derived-clone` bridge that 批 16 found can mark a dead shape reachable. The one `derived-clone` verdict in the run is `ListViewSchema` — a positive CONTROL, not a target, and independently identity-reachable via `ObjectListViewSchema`. Every closed shape also has a literal carrier key in this file and a named parse door (`defineView` / `defineViewItem` / the `view` metadata-type schema / objectui's `GanttConfigSchema.safeParse` at `plugin-gantt/src/ObjectGantt.tsx:408`) — the strong-evidence class #5056 leaves standing. ⚠️ **`ListView.sort` was closed and then REVERTED, and that is the batch's most useful finding.** It carried `direction → order`, the #4721 alias for the identical tuple (`{field, direction:'desc'}` parsed to `{field, order:'asc'}` — a silently REVERSED sort). The full suite then failed one case: `view-metadata-schema.test.ts` pins `sort: [{ id, field, order }]` as the exact body a console column-sort PUT persists, and objectui stamps that `id` per row (`components/src/custom/sort-builder.tsx:68`/`:94`, `crypto.randomUUID()`). **The mechanism governs every nested block in this file and is the opposite of what the union's own comment implies: `.strip()` does NOT recurse.** `ViewMetadataSchema` rescues Studio's round-trip keys by making its flattened members `.strip()`, but that re-opens the TOP level only — a nested block closed inside `ListViewSchema` is still reached through that member, so a console-stamped key inside it becomes a 422 regardless. `id` was deliberately NOT declared to silence it: it is a React list key, and declaring it would put a UI artifact on the authorable surface and tell an AI author to emit one. The end state is #5074's authoring/wire split applied one level down; until then the shape stays open rather than half-closed against the platform's own writes. Curation on what DID close is anchored to named siblings: an option `count` gets a wrong-layer pointer to `showCount` because objectui COMPUTES it per render; and a bare `name` on the `object` data source is deliberately NOT aliased — it is a real key on the view ITEM, so a rename would be finding 7 again. `submitBehavior` became a `discriminatedUnion` on the `kind` literal it already required: as a plain union of four strict members the rejection is an `invalid_union` whose prescription #5014 measured the renderers flattening away. ⚠️ **`GanttConfigSchema` / `TreeConfigSchema` are `strictObject(…).passthrough()`** — open at the parent by design, and this ledger's own counter reads them as `strict` because `postureOf` returns early on the `strictObject` idiom without walking the chain (**#5072**); it inflates the strict count and does not affect this row's strip count. **Still open, all five measured:** `UserFiltersSchema` — closing it would 422 `allowAddTab`, which objectui's renderer reads (`plugin-list/src/UserFilters.tsx:182`/`:742`) and the spec never declared; `saveMetaItem` validates but persists the ORIGINAL body, so the stripped key still reaches the renderer and the capability WORKS today — closing removes a capability rather than making a silent failure loud (**#5073**). The 批 6e reliance question IS answered: `ObjectUserFiltersSchema` is `.omit()`ed off this base and `.omit()` inherits posture, so the pin flips from "drops" to "rejects" — that flip is wanted, and gated only on `allowAddTab`. `ViewItemSchema` ×2 — **wire, not authorable**: objectui's pin control PUTs `{...storedItem, isPinned}` (`ObjectView.tsx:882` → `data-objectstack/src/index.ts:2801`); a stored ViewItem record carries `viewKind` AND `config`, so it lands on THIS member (the flattened members are excluded by their `config: z.undefined()` guard) and closing it would 422 pinning a saved view (**#5074**). `FormFieldBaseSchema` — a module-private BASE whose sole consumer already applies `.strict()` plus the ADR-0089 `strictVisibilityError` map; the door is closed, the ledger counts the base. `ListView.sort` — reverted, see above. Each verdict is recorded in three places (schema JSDoc + `view-strictness-batch18.test.ts` + this row) |
764764
| `widget.zod.ts` | 9 | 9 | **no door** |**not strictness work** — the whole file measured unreachable from every authoring root (#4001 批 16), with no carrier key and zero parse in all three repos. ADR-0049 triage is **#5055**. See the triage row above, including why the campaign's own BFS said otherwise first (**#5056**) |
765765
| `chart.zod.ts` | 2 | 7 | **no gate** | `ChartAggregateSchema` + `ChartGroupBySchema`'s object arm. Config / axis / series / annotation / interaction closed at 批 15; these two are NOT unfinished work — their carrier (`<ObjectChart aggregate>`) is live but nothing parses them, so closing them would gate nothing (#4583). Blocked on wiring the react-page publish gate to parse the schema instead of re-deriving it — see the triage row |
766766
| `touch.zod.ts` | 7 | 7 | **no door** |**not strictness work** — measured unreachable from every authoring root (#4001 批 13); ADR-0049 triage is #4988. See the triage row above |
@@ -819,20 +819,20 @@ metadata-type roots), and it was returning `aria: {}` for a legacy-spelled block
819819

820820
**批 18 is the ninth instance.** It computed 84 against a tree where 批 16's
821821
rows still existed (`widget` still `authorable` at 9, `i18n` still 6) — right
822-
against its own branch, wrong against the merge, which is **74**: a number
822+
against its own branch, wrong against the merge, which is **75**: a number
823823
neither side wrote down. Git conflicted three regions here (header, the
824824
`view`/`widget` row pair, and this paragraph) and every row from both sides was
825825
kept before the arithmetic was redone from them. Worth naming because 批 16 and
826826
批 18 moved the same two numbers for OPPOSITE reasons — 批 16 by reclassifying 14
827-
sites it did not touch, 批 18 by closing 16 it did — and the merged subtotal is
827+
sites it did not touch, 批 18 by closing 15 it did — and the merged subtotal is
828828
not reachable by applying either delta to the other's base.
829829

830-
**Authorable strip in `ui/`: 34 of 74** (was 123 of 123 when the ruling was
830+
**Authorable strip in `ui/`: 35 of 75** (was 123 of 123 when the ruling was
831831
written). Recomputed from the surviving rows at 批 18, not decremented:
832-
29+4+9+2+7+5+4+4+4+3+1+1+1 = 74, of which 40 are the two no-parse classes, so
833-
the authorable half is `component` 29 + `view` 4 + `app` 1 = 34. `app.zod.ts`'s
832+
29+5+9+2+7+5+4+4+4+3+1+1+1 = 75, of which 40 are the two no-parse classes, so
833+
the authorable half is `component` 29 + `view` 5 + `app` 1 = 35. `app.zod.ts`'s
834834
single site is held pending the finding-16 `.extend()` check rather than counted
835-
as ready. **40 of the 74 are the two no-parse classes**: 38 `no door``touch`
835+
as ready. **40 of the 75 are the two no-parse classes**: 38 `no door``touch`
836836
(7), `animation` (4), `dnd` (4), `keyboard` (4) and `offline` (3) from 批 13,
837837
`sharing.zod.ts`'s `EmbedConfig` and `notification.zod.ts`'s `NotificationAction`
838838
from 批 14, and `widget.zod.ts` (9) plus `i18n.zod.ts`'s remaining 5 from 批 16

packages/spec/src/ui/view-strictness-batch18.test.ts

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,6 @@ describe('#4001 批 18 — closed sites reject unknown keys where they live', ()
142142
});
143143

144144
it.each([
145-
['sort', { sort: [{ field: 'name', order: 'asc', notASortKey: 1 }] }],
146145
['conditionalFormatting', { conditionalFormatting: [{ condition: 'true', style: {}, notAFormatKey: 1 }] }],
147146
['emptyState', { emptyState: { title: 'None', notAnEmptyStateKey: 1 } }],
148147
])('ListViewSchema.%s rejects an undeclared key', (_block, patch) => {
@@ -181,15 +180,6 @@ describe('#4001 批 18 — closed sites reject unknown keys where they live', ()
181180
// 3. Curation — the entries that make a rejection fixable
182181
// ===========================================================================
183182
describe('#4001 批 18 — the rejection carries a usable prescription', () => {
184-
it('`direction` → `order` on a sort entry — the #4721 sibling contract, second declaration', () => {
185-
// `SortNodeSchema` (data/query.zod.ts) closed with this exact alias after
186-
// #4721 measured `{ field, direction: 'desc' }` parsing to
187-
// `{ field, order: 'asc' }` — a silently REVERSED sort reported as valid.
188-
// The view surface declares the same two-key tuple and must not re-learn it.
189-
expect(reject(ListViewSchema, { ...LIST_BASE, sort: [{ field: 'name', direction: 'desc' }] }))
190-
.toContain('`direction` → `order`');
191-
});
192-
193183
it('`object` → `childObject` on a subform — the word every neighbouring block uses', () => {
194184
expect(reject(FormViewSchema, { ...FORM_BASE, subforms: [{ childObject: 'x', object: 'crm_line' }] }))
195185
.toContain('`object` → `childObject`');
@@ -331,6 +321,32 @@ describe('#4001 批 18 — deliberately still open (do not close without re-meas
331321
).toBe(false);
332322
});
333323

324+
it('ListViewSchema.sort stays open: the console stamps a UI row `id` into it', () => {
325+
// Batch 18 CLOSED this (with `direction → order`, the #4721 alias for the
326+
// identical tuple) and the full suite caught it: `view-metadata-schema.test.ts`
327+
// pins `sort: [{ id, field, order }]` as the exact body a console column-sort
328+
// PUT persists, and objectui stamps that `id` per row
329+
// (`components/src/custom/sort-builder.tsx:68`, `:94` — `crypto.randomUUID()`).
330+
// `id` was deliberately NOT declared to silence the rejection: it is a React
331+
// list key, and declaring it would put a UI artifact on the authorable
332+
// surface and teach an AI author to emit one.
333+
expect(ListViewSchema.safeParse({ ...LIST_BASE, sort: [{ id: 'uuid', field: 'name', order: 'asc' }] }).success).toBe(true);
334+
});
335+
336+
it('…and the mechanism that made it a regression: `.strip()` does NOT recurse', () => {
337+
// This is the load-bearing fact for every nested block in this file, and it
338+
// is the opposite of what the union's comment implies. `ViewMetadataSchema`
339+
// rescues Studio's round-trip keys by making its flattened members
340+
// `.strip()` — but that re-opens the TOP level only. A nested block closed
341+
// inside `ListViewSchema` is still reached through that member, so a
342+
// console-stamped key inside it becomes a 422 no matter what the member does.
343+
const overlay = { type: 'grid', columns: ['name'], name: 'o.default', viewKind: 'list', object: 'o' };
344+
// top level: an unknown aux key rides along, because the member strips.
345+
expect(ViewMetadataSchema.safeParse({ ...overlay, someStudioAuxKey: 1 }).success).toBe(true);
346+
// nested: a CLOSED sub-block still rejects through that same member.
347+
expect(ViewMetadataSchema.safeParse({ ...overlay, emptyState: { title: 'x', notAnEmptyStateKey: 1 } }).success).toBe(false);
348+
});
349+
334350
it('FormFieldBaseSchema stays a bare z.object: its ONE consumer already `.strict()`s it', () => {
335351
// The ledger reads this site as `strip` because it counts the base; the
336352
// base is not a door. `FormFieldSchema` = base.extend({fields}).strict(),

packages/spec/src/ui/view.zod.ts

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -922,19 +922,38 @@ export const ListViewSchema = lazySchema(() => strictObject({
922922
* renderer in objectui#2601 — kept covered by a live fixture). Removal will
923923
* go through its own deprecation cycle; do not drop it here.
924924
*/
925+
/**
926+
* ⚠️ [#4001 批 18] Deliberately still STRIP — reverted after the closed
927+
* version broke a live console path, which is the finding rather than a
928+
* setback.
929+
*
930+
* This batch closed it (with `direction → order`, the #4721 alias for the
931+
* identical tuple — `{ field, direction: 'desc' }` parsed to
932+
* `{ field, order: 'asc' }`, a silently REVERSED sort). The full suite then
933+
* failed one case: `view-metadata-schema.test.ts` pins
934+
* `sort: [{ id, field, order }]` as *"the exact shape normalizeViewMetadata
935+
* persists on a console column-sort PUT"*, and `id` is a UI row identity
936+
* objectui stamps per row (`components/src/custom/sort-builder.tsx:68`,
937+
* `:94` — `crypto.randomUUID()`), persisted verbatim because `saveMetaItem`
938+
* stores the original body.
939+
*
940+
* The mechanism is worth stating, because it governs every nested block in
941+
* this file and is NOT what the union's comment implies: `ViewMetadataSchema`
942+
* rescues Studio's round-trip keys with `.strip()` on its flattened members —
943+
* but **`.strip()` does not recurse** any more than `.strict()` does. It
944+
* re-opens the TOP level only, so a nested block closed here is still reached
945+
* through that member and a console-stamped key inside it becomes a 422.
946+
*
947+
* `id` was NOT declared to make the rejection go away. It is a React list key,
948+
* not protocol: declaring it would put a UI artifact on the authorable surface
949+
* and tell an AI author to generate one. The real end state is the same
950+
* authoring/wire split filed as #5074, applied one level down — until then
951+
* this shape stays open rather than half-closed against the platform's own
952+
* writes.
953+
*/
925954
sort: z.union([
926955
z.string(), //Legacy "field desc"
927-
z.array(strictObject({
928-
surface: 'this list view sort entry',
929-
history: VIEW_HISTORY,
930-
// Same two-key tuple as `SortNodeSchema` (`data/query.zod.ts`), which
931-
// #4721 closed with this exact alias after measuring that
932-
// `{ field, direction: 'desc' }` parsed to `{ field, order: 'asc' }` —
933-
// a silently REVERSED sort reported as valid. This is the second
934-
// declaration of that tuple, so it carries the same prescription rather
935-
// than leaving the view surface to re-learn it.
936-
aliases: { direction: 'order', dir: 'order', sortOrder: 'order', by: 'field', fieldName: 'field' },
937-
}, {
956+
z.array(z.object({
938957
field: z.string(),
939958
order: z.enum(['asc', 'desc'])
940959
}))

0 commit comments

Comments
 (0)