From 1bf71d8824fd584ce3916900aa7a204cfe9e590e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 06:20:58 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix(spec):=20reopen=20ViewFilterRuleSchema?= =?UTF-8?q?=20=E2=80=94=20the=20console=20stamps=20a=20UI=20row=20id=20(#5?= =?UTF-8?q?114)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saving a filter from the console 422'd on `main`. An earlier strictness wave closed `ViewFilterRuleSchema` with `strictObject`, and 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`). `saveMetaItem` validates the PUT body and then persists the AUTHORED body verbatim, so the `id` is on the wire — and the closed shape rejected it. Measured on all three paths before the change, on `origin/main`: ViewFilterRuleSchema -> unrecognized_keys @ [] `id` ListViewSchema.filter -> unrecognized_keys @ ["filter",0] `id` ViewMetadataSchema (overlay) -> invalid_union @ [] "Invalid input" The third is the body the console actually PUTs, and its message is the #5014 flattening: the key that caused the rejection is not in what the author sees, which is how this sat on `main` unnoticed. The mechanism governs every nested block in this file and is the opposite of what the union's comment implies: `.strip()` does NOT recurse, any more than `.strict()` does. `ViewMetadataSchema` re-opens its flattened members so Studio's round-trip aux keys ride along — but that 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 422s regardless of the member's posture. Same finding 批 18 reached one block over on `ListView.sort` (#5070). `id` is deliberately 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 — a `??` fallback wearing a schema. Reopening drops it from `parsed.data` instead, and `saveMetaItem` stores the original body, so the renderer still reads it. The real close is #5074's authoring/wire split applied to this block, whose scope addendum names this site. Verified in both directions: re-close the schema and 7 assertions in the new pin file go red, while its two mechanism CONTROLS (top-level aux key rides, nested `emptyState` still rejects) stay green either way. Ledger gate proven red on both the row (6→5) and the header (76→75). Ledger: ui/ 75 -> 76 strip, authorable 35 -> 36, recomputed from surviving rows. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9 --- .../view-filter-rule-console-id-hotfix.md | 30 +++ .../2026-07-unknown-key-strictness-ledger.md | 14 +- .../src/ui/view-filter-rule-wire-id.test.ts | 184 ++++++++++++++++++ packages/spec/src/ui/view.zod.ts | 39 +++- 4 files changed, 256 insertions(+), 11 deletions(-) create mode 100644 .changeset/view-filter-rule-console-id-hotfix.md create mode 100644 packages/spec/src/ui/view-filter-rule-wire-id.test.ts 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..ebc4ba6dd0 --- /dev/null +++ b/.changeset/view-filter-rule-console-id-hotfix.md @@ -0,0 +1,30 @@ +--- +'@objectstack/spec': patch +--- + +**Saving a view filter from the console 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. Closed, the schema rejected it, and every filter write that +went through the builder 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 body the console actually PUTs. + +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 a8de812bca..5b9de88355 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 | 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 | -| `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 | @@ -827,12 +827,12 @@ kept before the arithmetic was redone from them. Worth naming because 批 16 and sites it did not touch, 批 18 by closing 15 it did — and the merged subtotal is not reachable by applying either delta to the other's base. -**Authorable strip in `ui/`: 35 of 75** (was 123 of 123 when the ruling was -written). Recomputed from the surviving rows at 批 18, not decremented: -29+5+9+2+7+5+4+4+4+3+1+1+1 = 75, of which 40 are the two no-parse classes, so -the authorable half is `component` 29 + `view` 5 + `app` 1 = 35. `app.zod.ts`'s +**Authorable strip in `ui/`: 36 of 76** (was 123 of 123 when the ruling was +written). Recomputed from the surviving rows at #5114, not decremented: +29+6+9+2+7+5+4+4+4+3+1+1+1 = 76, of which 40 are the two no-parse classes, so +the authorable half is `component` 29 + `view` 6 + `app` 1 = 36. `app.zod.ts`'s single site is held pending the finding-16 `.extend()` check rather than counted -as ready. **40 of the 75 are the two no-parse classes**: 38 `no door` — `touch` +as ready. **40 of the 76 are the two no-parse classes**: 38 `no door` — `touch` (7), `animation` (4), `dnd` (4), `keyboard` (4) and `offline` (3) from 批 13, `sharing.zod.ts`'s `EmbedConfig` and `notification.zod.ts`'s `NotificationAction` from 批 14, and `widget.zod.ts` (9) plus `i18n.zod.ts`'s remaining 5 from 批 16 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'), /** From 76a8638ae64e56af1d629c047e95cd81efdf659b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 07:12:21 +0000 Subject: [PATCH 2/2] docs(changeset): stop claiming the console filter save is restored (#5114) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browser verification against the real vendored console found a second, independent defect stacked on the same request: the list toolbar persists the filter builder's whole `FilterGroup` object into `filter`, where the spec declares `ViewFilterRule[]`. That type mismatch rejects before the `id` is ever reached, so reopening `ViewFilterRuleSchema` does not by itself make "save a filter from the console" work — it is necessary, not sufficient. Replaying the captured console body against two running servers separates them: variant pre-fix post-fix as sent (FilterGroup object) 422 422 <- producer bug unwrapped to rule[] WITH the UI ids 422 ACCEPTED <- this change unwrapped to rule[] without ids ACCEPTED ACCEPTED <- control The changeset headline said the console save no longer 422s. It does. Release notes are the one place that claim would have reached users unqualified, so it now states what actually changed and names the remaining blocker. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9 --- .../view-filter-rule-console-id-hotfix.md | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/.changeset/view-filter-rule-console-id-hotfix.md b/.changeset/view-filter-rule-console-id-hotfix.md index ebc4ba6dd0..47206cba01 100644 --- a/.changeset/view-filter-rule-console-id-hotfix.md +++ b/.changeset/view-filter-rule-console-id-hotfix.md @@ -2,16 +2,26 @@ '@objectstack/spec': patch --- -**Saving a view filter from the console no longer 422s (#5114).** +**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. Closed, the schema rejected it, and every filter write that -went through the builder 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 body the console actually PUTs. +`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