diff --git a/.changeset/view-filter-rule-console-id-hotfix.md b/.changeset/view-filter-rule-console-id-hotfix.md new file mode 100644 index 0000000000..47206cba01 --- /dev/null +++ b/.changeset/view-filter-rule-console-id-hotfix.md @@ -0,0 +1,40 @@ +--- +'@objectstack/spec': patch +--- + +**A view filter rule carrying the console's UI row `id` no longer 422s (#5114).** + +`ViewFilterRuleSchema` had been closed to unknown keys by an earlier strictness +wave. The filter builder the console renders stamps `id: crypto.randomUUID()` on +every filter row it creates (a React list key), and the metadata write path +validates the PUT body and then persists the **authored** body verbatim — so the +`id` is on the wire, and in already-stored view rows. Closed, the schema rejected +it: every filter write carrying one came back `422 Unrecognized key(s) on this +view filter rule: 'id'`. Measured on all three paths, including the flattened +personalization overlay that is the shape the console PUTs. + +⚠️ **This does not on its own restore "save a filter from the console".** Browser +verification found a second, independent defect stacked on the same request: the +list toolbar persists the filter builder's whole `FilterGroup` object (`{ id, +logic, conditions }`) into `filter`, where the spec declares `ViewFilterRule[]` — +a type mismatch that rejects before the `id` is ever reached. That one belongs to +the producer and is tracked separately; until it lands, the console's filter save +still fails. What this change fixes is every writer that sends a well-formed +`ViewFilterRule[]` whose rows carry the UI `id` — including view rows already +stored with one. + +The shape is reopened (unknown keys are dropped again, as before the closure). +`id` is deliberately **not** declared: it is a UI artifact, and declaring it would +put it on the authorable surface and tell an AI author to generate a UUID for a +filter rule. Nothing else changed — the operator vocabulary, the legacy-spelling +normalization and the required `field` all still validate, so an invented operator +is still rejected. + +Worth knowing for anyone tightening a neighbouring block: **`.strip()` does not +recurse**, any more than `.strict()` does. `ViewMetadataSchema` re-opens its +flattened members so Studio's round-trip keys ride along, but that re-opens the +top level only — a nested block closed inside the list view is still reached +through that member, so a console-stamped key inside it 422s regardless. The +durable fix is the authoring/wire split tracked in #5074, which this site is now +named in; the verdict is recorded on the schema, in +`view-filter-rule-wire-id.test.ts`, and in the `ui/` row of the strictness ledger. diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index fbfa8d96d4..f7f5eccb1e 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -755,12 +755,12 @@ it the same way: the decision is also written beside the schema and pinned in a test (`flow.test.ts`, `etl.test.ts`), because a row in a table is not where the next person to open that file will look. -#### `ui/` — 75 strip of 198 +#### `ui/` — 76 strip of 198 | File | Strip | Sites | Class | Batch | |---|---|---|---|---| | `component.zod.ts` | 29 | 29 | **no gate** | ⛔ **not strictness work** — measured at 批 17 as having no parse at all: BFS-unreachable from every metadata root (all 52 targets, controls green in the same run), zero production `.parse()` sites in the three repos, and an unknown key inside `components[].properties` demonstrably survives the live `definePage()` door. The carrier (`PageComponentSchema.properties`) is live but is `z.record(z.string(), z.unknown())` — ADR-0089 D3a strictness does not recurse into it. Closing these 29 sites would gate nothing (#4583). Blocked on wiring the parse at the carrier — **#5068**. See the triage row for the full measurement | -| `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) | +| `view.zod.ts` | 6 | 50 | mixed | **15 of 20 closed at #4001 批 18**, and a sixteenth — `ViewFilterRuleSchema`, closed by an EARLIER wave — reopened at **#5114**; the 6 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 six 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. `ViewFilterRuleSchema` — **the same wire contamination, one block over, and it was already LIVE on `main`** (#5114): closed by an earlier wave, while objectui's filter builder stamps `id: crypto.randomUUID()` on every row it writes (`components/src/custom/filter-builder.tsx:228`, re-stamped on read-back at `plugin-view/src/config/view-config-utils.ts:146`/`:160`), and `saveMetaItem` persists the AUTHORED body verbatim — so saving a filter from the console 422'd, on all three paths including the flattened overlay that is the body actually PUT. Reopened as a p1 hotfix; `id` deliberately NOT declared, for the reason given for `sort` above. Two details worth keeping: the overlay path's rejection surfaces as `invalid_union` / *"Invalid input"* — the #5014 flattening, so the key that caused it is not in the message the author sees, which is why this sat on `main` unnoticed; and the reopening was verified in BOTH directions (re-close it and 7 assertions in `view-filter-rule-wire-id.test.ts` go red, while that file's two mechanism CONTROLS — top-level aux key rides, nested `emptyState` still rejects — stay green either way, which is what makes them controls). #5074's scope addendum names this site: its wire variant must re-open RECURSIVELY, and re-closing here is gated on that. Each verdict is recorded in three places (schema JSDoc + `view-strictness-batch18.test.ts` / `view-filter-rule-wire-id.test.ts` + this row) | | `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**) | | `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 (``) 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 | | `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 | @@ -837,19 +837,23 @@ CLOSED 15 of `view`'s 20, makes it **`6 of 75`**. Four right answers against fou trees, none of them the merge. Every time git merged the ROWS and conflicted only the prose, because 批 17 changes just its own row's Class column — so the table was right and this paragraph was wrong on every side, every time. That is ten for -ten, and it is why the subtotal below is recomputed from the surviving rows +ten — and #5114 made it **eleven**, from the other direction: it REOPENED one +`view` site (a live 422, see that row), computed `36 of 76` against a tree where +批 17's reclassification had not landed, and merges to `7 of 76`, a number its +branch never wrote either. A reopening moves this line exactly as a closure does. and it is why the subtotal below is recomputed from the surviving rows rather than adjusted by anyone's delta. -**Authorable strip in `ui/`: 6 of 75** (was 123 of 123 when the ruling was -written). Recomputed from the surviving rows after the 批 18 + 批 17 merge, not -decremented: 29+5+9+2+7+5+4+4+4+3+1+1+1 = 75, of which **69** are the two -no-parse classes, leaving the authorable half as `view` 5 + `app` 1 = **6**. +**Authorable strip in `ui/`: 7 of 76** (was 123 of 123 when the ruling was +written). Recomputed from the surviving rows after the 批 18 + 批 17 + #5114 merge, not +decremented: 29+6+9+2+7+5+4+4+4+3+1+1+1 = 76, of which **69** are the two +no-parse classes, leaving the authorable half as `view` 6 + `app` 1 = **7**. `app.zod.ts`'s single site is held pending the finding-16 `.extend()` check rather than counted as ready. -**69 of the 75 — 92% of what is left in this directory — are the two no-parse -classes.** After 批 18 closed 15 real doors and 批 17 measured 29 sites as having -none, `ui/` has **six** authorable strip sites left in total. That is the single +**69 of the 76 — 91% of what is left in this directory — are the two no-parse +classes.** After 批 18 closed 15 real doors, 批 17 measured 29 sites as having +none, and #5114 reopened one, `ui/` has **seven** authorable strip sites left in +total. That is the single largest fact about this directory now, and it should be read before any further `ui/` strictness batch is scheduled — the ratchet is very nearly done here, and what remains open is overwhelmingly work for OTHER issues: diff --git a/packages/spec/src/ui/view-filter-rule-wire-id.test.ts b/packages/spec/src/ui/view-filter-rule-wire-id.test.ts new file mode 100644 index 0000000000..b4f18f078b --- /dev/null +++ b/packages/spec/src/ui/view-filter-rule-wire-id.test.ts @@ -0,0 +1,184 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #5114 — `ViewFilterRuleSchema` stays OPEN: the console stamps a UI row `id` + * into every filter rule it writes. + * + * This is the third of the three places the verdict is recorded (the others: + * the JSDoc on the shape itself, and the `ui/` row in + * `docs/audits/2026-07-unknown-key-strictness-ledger.md`), and it is the same + * verdict #4001 批 18 reached one block over on `ListView.sort` (#5070) — + * reached there by the full suite, and here by measuring `main`. + * + * WHAT WENT WRONG. An earlier wave closed this shape with `strictObject`. The + * filter builder objectui renders stamps `id: crypto.randomUUID()` on every row + * it creates (`components/src/custom/filter-builder.tsx:228`; the same value is + * re-stamped when a stored filter is read back into the builder, + * `plugin-view/src/config/view-config-utils.ts:146`/`:160`), and `saveMetaItem` + * persists the authored body VERBATIM — it validates, then stores what was sent, + * so the `id` is on the wire and in the store. A closed shape therefore turns + * every filter write that carries one into a 422. + * + * THE MECHANISM, which is worth more than this one site: **`.strip()` does not + * recurse, any more than `.strict()` does.** `ViewMetadataSchema` re-opens its + * flattened personalization member so Studio's round-trip aux keys ride along — + * but that re-opens the TOP level only. This block is reached THROUGH that + * member, so closing it here 422s a console-stamped key regardless of the + * member's own posture. 批 18 pinned that property in + * `view-strictness-batch18.test.ts`; this file pins the consequence for the + * filter surface, which is the one live path it broke. + * + * WHY `id` IS NOT DECLARED. It is a React list key, not protocol. Declaring it + * would put a UI artifact on the authorable surface and tell an AI author to + * generate a UUID for a filter rule — the "declared = encouraged" failure this + * campaign exists to remove. A schema-shaped `??` fallback is still a `??` + * fallback. So the shape stays open rather than half-closed against the + * platform's own writes, and the real close is #5074's authoring/wire split + * applied to this block: an authoring variant that rejects `id` and a wire + * variant that tolerates it, with the re-opening able to REACH a nested block. + */ + +import { describe, it, expect } from 'vitest'; + +import { + ViewFilterRuleSchema, + ListViewSchema, + ViewMetadataSchema, +} from './view.zod'; +import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; + +/** + * One filter row exactly as the console writes it: the three declared keys plus + * the `crypto.randomUUID()` the builder stamps for React. + */ +const CONSOLE_FILTER_ROW = { + id: 'c0ffee00-dead-beef-cafe-000000000000', + field: 'stage', + operator: 'equals', + value: 'won', +} as const; + +/** + * The flattened personalization overlay (#2555) minus the filter — identity + * inherited from the entry it shadows. Shape 3 of the three `ViewMetadataSchema` + * runtime shapes. Kept filter-free so the mechanism controls in section 4 stay + * independent of the fix under test. + */ +const OVERLAY_BASE = { + type: 'grid', + data: { provider: 'object', object: 'showcase_task' }, + columns: ['title'], + name: 'showcase_task.default', + viewKind: 'list', + object: 'showcase_task', + label: 'All Tasks', +} as const; + +/** …and the body the console actually PUTs when a filter is saved. */ +const CONSOLE_PUT_BODY = { ...OVERLAY_BASE, filter: [CONSOLE_FILTER_ROW] } as const; + +// =========================================================================== +// 1. The door — a parse must exist, or none of the rest means anything +// =========================================================================== +describe('#5114 — the door this shape is reached through', () => { + it('the `view` metadata type resolves to a registered schema (the save-time 422 door)', () => { + // `saveMetaItem` validates the PUT body against this schema and answers 422 + // on failure. Without this door the rest of the file would be theory. + expect(getMetadataTypeSchema('view')).toBeDefined(); + }); +}); + +// =========================================================================== +// 2. The regression itself — all three paths the console body travels +// =========================================================================== +describe('#5114 — a console-written filter row parses on every path', () => { + it('1/3 `ViewFilterRuleSchema` accepts the row directly', () => { + expect(ViewFilterRuleSchema.safeParse(CONSOLE_FILTER_ROW).success).toBe(true); + }); + + it('2/3 `ListViewSchema.filter` accepts an array of them', () => { + expect( + ListViewSchema.safeParse({ columns: ['name'], filter: [CONSOLE_FILTER_ROW] }).success, + ).toBe(true); + }); + + it('3/3 `ViewMetadataSchema` accepts the flattened overlay the console PUTs', () => { + // The path that actually 422'd the user. It reaches this block through the + // flattened member, whose `.strip()` re-opens the top level ONLY. + expect(ViewMetadataSchema.safeParse(CONSOLE_PUT_BODY).success).toBe(true); + }); + + it('and the same row rides along on a view TAB filter (`ViewTabSchema.filter`)', () => { + // The second carrier of `ViewFilterRuleSchema` in this file — the per-tab + // filter Studio writes through the SAME builder widget, so it stamps the + // same `id`. Probed at its own path because strictness does not recurse in + // either direction. + expect( + ViewMetadataSchema.safeParse({ + ...CONSOLE_PUT_BODY, + tabs: [{ name: 'won', label: 'Won', filter: [CONSOLE_FILTER_ROW] }], + }).success, + ).toBe(true); + }); +}); + +// =========================================================================== +// 3. Open is not undefended — what reopening did NOT give away +// =========================================================================== +describe('#5114 — reopening dropped the unknown-key gate, and nothing else', () => { + it('`id` is DROPPED from the parsed result, not declared onto the surface', () => { + // The distinction the fix turns on. Declaring `id` would make it authorable + // (and teach an AI author to emit a UUID); stripping leaves the authorable + // surface exactly three keys. `saveMetaItem` stores the ORIGINAL body, so + // the console's `id` still round-trips to the renderer either way. + const parsed = ViewFilterRuleSchema.parse(CONSOLE_FILTER_ROW) as Record; + expect(Object.keys(parsed).sort()).toEqual(['field', 'operator', 'value']); + expect('id' in parsed).toBe(false); + }); + + it('the operator vocabulary still bites — an invented operator is still rejected', () => { + // Reopening is about UNKNOWN KEYS. Every declared key keeps its own + // validation, so this is not a shape that accepts anything now. + expect( + ViewFilterRuleSchema.safeParse({ ...CONSOLE_FILTER_ROW, operator: 'sorta_equals' }).success, + ).toBe(false); + }); + + it('a missing required `field` is still rejected', () => { + expect(ViewFilterRuleSchema.safeParse({ operator: 'equals', value: 'won' }).success).toBe(false); + }); + + it('legacy operator spellings still fold to canonical on parse', () => { + // `z.preprocess(normalizeFilterOperator, …)` is untouched by the posture + // change — the one vocabulary is still one vocabulary. Probed on a bare + // row so this measures the folding, not the `id` tolerance. + expect(ViewFilterRuleSchema.parse({ field: 'amount', operator: 'gte', value: 1 }).operator) + .toBe('greater_than_or_equal'); + }); +}); + +// =========================================================================== +// 4. The mechanism, pinned where it bit — `.strip()` does not recurse +// =========================================================================== +describe('#5114 — why the flattened member could not rescue this block', () => { + // Both assertions here run on the FILTER-FREE overlay on purpose: they are + // controls for the member's own posture, so they must hold whichever way + // `ViewFilterRuleSchema` is written. Re-close the fix and case 3/3 above goes + // red while these two stay green — that gap IS the finding. + it('the flattened member re-opens the TOP level: an unknown aux key rides along', () => { + expect( + ViewMetadataSchema.safeParse({ ...OVERLAY_BASE, someStudioAuxKey: 1 }).success, + ).toBe(true); + }); + + it('…but a still-CLOSED nested block rejects through that same member', () => { + // `emptyState` was closed at 批 18 and stays closed. The top level rides, + // the nested block does not: `.strip()` re-opened one level, not the tree. + expect( + ViewMetadataSchema.safeParse({ + ...OVERLAY_BASE, + emptyState: { title: 'None', notAnEmptyStateKey: 1 }, + }).success, + ).toBe(false); + }); +}); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 564c68b539..24ddef0d9f 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -231,6 +231,40 @@ export function normalizeFilterOperator(op: unknown): string { * Standardized filter condition used in list views, tabs, and page-level filters. * Uses a declarative array-of-objects format: [{ field, operator, value }]. * + * ⚠️ [#5114] Deliberately still STRIP — an earlier wave closed this shape, and + * that closure is REVERTED here because it 422'd a live console path. + * + * **Wire-contaminated.** The filter builder objectui renders stamps + * `id: crypto.randomUUID()` on every row it creates + * (`components/src/custom/filter-builder.tsx:228`; stamped again when a stored + * filter is read back into the builder — + * `plugin-view/src/config/view-config-utils.ts:146`/`:160`). `saveMetaItem` + * validates the PUT body and then persists the AUTHORED body verbatim, so that + * `id` is on the wire and in the store. Closed, this shape turned every filter + * write carrying one into a 422 — measured on all three paths, including the + * flattened personalization overlay that is the body the console actually PUTs. + * + * The mechanism is the part worth carrying to the next block, and it is NOT what + * the `ViewMetadataSchema` union's own comment implies: that union re-opens its + * flattened members with `.strip()` so Studio's round-trip aux keys ride along — + * but **`.strip()` does not recurse**, any more than `.strict()` does. It + * re-opens the TOP level only, so a nested block closed here is still reached + * through that member and a console-stamped key inside it becomes a 422 + * regardless of the member's posture. Same finding as `ListView.sort` at #4001 + * 批 18 (#5070), one block over. + * + * `id` was NOT declared to make the rejection go away. It is a React list key, + * not protocol: declaring it would put a UI artifact on the authorable surface + * and tell an AI author to generate a UUID for a filter rule — a `??` fallback + * wearing a schema. The real close is #5074's authoring/wire split applied to + * this block (an authoring variant that rejects `id`, a wire variant that + * tolerates it, and a re-opening that can REACH a nested block); #5074's scope + * addendum names this site. Until then the shape stays open rather than + * half-closed against the platform's own writes. + * + * Recorded in three places: this JSDoc, `view-filter-rule-wire-id.test.ts`, and + * the `ui/` row of `docs/audits/2026-07-unknown-key-strictness-ledger.md`. + * * @example * ```ts * filter: [ @@ -240,10 +274,7 @@ export function normalizeFilterOperator(op: unknown): string { * ] * ``` */ -export const ViewFilterRuleSchema = lazySchema(() => strictObject({ - surface: 'this view filter rule', - history: VIEW_HISTORY, -}, { +export const ViewFilterRuleSchema = lazySchema(() => z.object({ /** Field name to filter on */ field: z.string().describe('Field name to filter on'), /**