diff --git a/.changeset/rare-jars-shave.md b/.changeset/rare-jars-shave.md new file mode 100644 index 0000000000..5b1a616386 --- /dev/null +++ b/.changeset/rare-jars-shave.md @@ -0,0 +1,49 @@ +--- +'@objectstack/spec': major +--- + +**BREAKING (authoring gate tightens): `ViewItemSchema` is split into an authoring schema and a wire variant.** + +`ViewItemSchema` used to carry two contracts at once — the authoring surface +`defineViewItem()` and Studio's view-create form parse, AND member 1 of the +`ViewMetadataSchema` union that `saveMetaItem` validates every persisted `view` +body against. Because the second role needs Studio's round-trip keys through, +the shape stayed open, and an authoring typo was silently dropped: + +```ts +defineViewItem({ name: 'crm_lead.pipeline', object: 'crm_lead', viewKind: 'list', confg: { … } }) +// before: parsed clean → a ViewItem with NO view configuration at all +// after: Unrecognized key(s) on this view item: `confg`. Did you mean `config`? +``` + +**What changed** + +- `ViewItemSchema` is now strict on both arms. It is the authoring gate. +- `ViewItemWireSchema` (new export) is the `.strip()` wire variant and is + member 1 of `ViewMetadataSchema`. It **declares** `isPinned` and `sortOrder`, + the Studio switcher keys the console round-trips. +- `ViewFilterRuleSchema` and the `ListView.sort[]` entry are now strict too, + and `ListView.sort[]` rejects `direction` with a pointer to `order` (the two + spell the same tuple, and the wrong one reversed the sort silently). +- New exports: `ViewItemWireSchema`, `ViewItemWire`, `stripViewConsoleDecorations`, + `VIEW_CONSOLE_ROW_DECORATIONS`. + +**Migration — authored metadata (`*.view.ts`, `defineViewItem`, published packages)** + +| you wrote | on a … | now | +|:---|:---|:---| +| `confg:` / any undeclared key | view item | rejected, with the closest declared key suggested | +| `isPinned:` | view item | remove it — per-user Studio state, written by the console | +| `sortOrder:` | view item | use `order` for the authored default position | +| `id:` | filter rule / sort entry | remove it — a console row key, never authored | +| `direction: 'desc'` | sort entry | `order: 'desc'` | + +**Nothing changes for the console/write path.** The `view` metadata write door +still accepts every body the platform itself writes: pinning a saved view, the +column-sort PUT and the filter-save PUT all parse exactly as before. The +console's row `id`s are removed by `stripViewConsoleDecorations` before +validation — the write-path mirror of `stripReadDecorations` — and +`saveMetaItem` still persists the original body verbatim, so those ids +round-trip to the renderer untouched. `id` was deliberately **not** declared: +it is a React list key, and declaring it would put a UI artifact on the +authorable surface. diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 2f6da23083..e25e72c547 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -16,8 +16,8 @@ Migrated to [shared/http.zod.ts](/docs/references/shared/http). Re-exported here ## TypeScript Usage ```typescript -import { AddRecordConfigSchema, AppearanceConfigSchema, CalendarConfigSchema, ColumnPrefixSchema, ColumnSummarySchema, ColumnSummaryConfigSchema, FormButtonConfigSchema, FormFieldSchema, FormSectionSchema, FormViewSchema, GalleryConfigSchema, GanttConfigSchema, GanttQuickFilterSchema, GroupingConfigSchema, GroupingFieldSchema, KanbanConfigSchema, ListChartConfigSchema, ListColumnSchema, ListViewSchema, NavigationConfigSchema, NavigationModeSchema, ObjectListViewSchema, ObjectUserFiltersSchema, PaginationConfigSchema, RowColorConfigSchema, RowHeightSchema, SelectionConfigSchema, TimelineConfigSchema, TreeConfigSchema, UserActionsConfigSchema, UserFilterFieldSchema, UserFiltersSchema, ViewSchema, ViewDataSchema, ViewFilterRuleSchema, ViewItemSchema, ViewItemNameSchema, ViewKindSchema, ViewScopeSchema, ViewSharingSchema, ViewTabSchema, VisualizationTypeSchema } from '@objectstack/spec/ui'; -import type { AddRecordConfig, AppearanceConfig, ColumnPrefix, ColumnSummary, ColumnSummaryConfig, FormButtonConfig, FormField, FormSection, FormView, GalleryConfig, GroupingConfig, ListChartConfig, ListColumn, ListView, NavigationConfig, PaginationConfig, RowColorConfig, RowHeight, SelectionConfig, TimelineConfig, UserActionsConfig, UserFilterField, UserFilters, View, ViewData, ViewFilterRule, ViewItem, ViewKind, ViewScope, ViewSharing, ViewTab, VisualizationType } from '@objectstack/spec/ui'; +import { AddRecordConfigSchema, AppearanceConfigSchema, CalendarConfigSchema, ColumnPrefixSchema, ColumnSummarySchema, ColumnSummaryConfigSchema, FormButtonConfigSchema, FormFieldSchema, FormSectionSchema, FormViewSchema, GalleryConfigSchema, GanttConfigSchema, GanttQuickFilterSchema, GroupingConfigSchema, GroupingFieldSchema, KanbanConfigSchema, ListChartConfigSchema, ListColumnSchema, ListViewSchema, NavigationConfigSchema, NavigationModeSchema, ObjectListViewSchema, ObjectUserFiltersSchema, PaginationConfigSchema, RowColorConfigSchema, RowHeightSchema, SelectionConfigSchema, TimelineConfigSchema, TreeConfigSchema, UserActionsConfigSchema, UserFilterFieldSchema, UserFiltersSchema, ViewSchema, ViewDataSchema, ViewFilterRuleSchema, ViewItemSchema, ViewItemNameSchema, ViewItemWireSchema, ViewKindSchema, ViewScopeSchema, ViewSharingSchema, ViewTabSchema, VisualizationTypeSchema } from '@objectstack/spec/ui'; +import type { AddRecordConfig, AppearanceConfig, ColumnPrefix, ColumnSummary, ColumnSummaryConfig, FormButtonConfig, FormField, FormSection, FormView, GalleryConfig, GroupingConfig, ListChartConfig, ListColumn, ListView, NavigationConfig, PaginationConfig, RowColorConfig, RowHeight, SelectionConfig, TimelineConfig, UserActionsConfig, UserFilterField, UserFilters, View, ViewData, ViewFilterRule, ViewItem, ViewItemWire, ViewKind, ViewScope, ViewSharing, ViewTab, VisualizationType } from '@objectstack/spec/ui'; // Validate data const result = AddRecordConfigSchema.parse(data); @@ -817,6 +817,73 @@ This schema accepts one of the following structures: --- +--- + +## ViewItemWire + +### Union Options + +This schema accepts one of the following structures: + +#### Option 1 + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **viewKind** | `'list'` | ✅ | | +| **config** | `{ name?: string; label?: string; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: { provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }; … }` | ✅ | List-family view configuration. | +| **name** | `string` | ✅ | Globally-unique view id, `.`. | +| **object** | `string` | ✅ | Bound object name — the foreign key used to aggregate views. | +| **label** | `string` | optional | Display label (supports i18n). | +| **isDefault** | `boolean` | optional | Whether this is the object's default view in the switcher. | +| **order** | `integer` | optional | Sort order within the object's view switcher / left rail. | +| **scope** | `Enum<'package' \| 'shared' \| 'personal'>` | optional | Identity layer (defaults to `package` for source-loaded views). | +| **owner** | `string` | optional | Owner user id — set when `scope` is `personal`. | +| **hidden** | `boolean` | optional | Hidden from the switcher (per-user / per-org declutter). | +| **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this view. | +| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | +| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | +| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | +| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). | +| **_packageId** | `string` | optional | Owning package machine id. | +| **_packageVersion** | `string` | optional | Owning package version. | +| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | +| **isPinned** | `boolean` | optional | Studio round-trip: view pinned in the switcher (per-user state, written by the console — not authored). | +| **sortOrder** | `integer` | optional | Studio round-trip: position within the switcher (per-user state, written by the console — not authored). | + +--- + +#### Option 2 + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **viewKind** | `'form'` | ✅ | | +| **config** | `{ type?: Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>; layout?: Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>; columns?: integer; title?: string; … }` | ✅ | Form view configuration. | +| **name** | `string` | ✅ | Globally-unique view id, `.`. | +| **object** | `string` | ✅ | Bound object name — the foreign key used to aggregate views. | +| **label** | `string` | optional | Display label (supports i18n). | +| **isDefault** | `boolean` | optional | Whether this is the object's default view in the switcher. | +| **order** | `integer` | optional | Sort order within the object's view switcher / left rail. | +| **scope** | `Enum<'package' \| 'shared' \| 'personal'>` | optional | Identity layer (defaults to `package` for source-loaded views). | +| **owner** | `string` | optional | Owner user id — set when `scope` is `personal`. | +| **hidden** | `boolean` | optional | Hidden from the switcher (per-user / per-org declutter). | +| **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this view. | +| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | +| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | +| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | +| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). | +| **_packageId** | `string` | optional | Owning package machine id. | +| **_packageVersion** | `string` | optional | Owning package version. | +| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | +| **isPinned** | `boolean` | optional | Studio round-trip: view pinned in the switcher (per-user state, written by the console — not authored). | +| **sortOrder** | `integer` | optional | Studio round-trip: position within the switcher (per-user state, written by the console — not authored). | + +--- + + --- ## ViewKind diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index 20eb02f0b7..16f2db9079 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -21,17 +21,17 @@ regenerate. | Measure | Value | |---|---| | Triaged directories | 5 | -| Object sites in them | 474 | -| Still-open (strip) sites | 219 | +| Object sites in them | 476 | +| Still-open (strip) sites | 217 | | Files carrying at least one | 34 | Remaining strip sites by class: | Bucket | Sites | |---|---| -| authorable — the ruling's forced scope | 15 | +| authorable — the ruling's forced scope | 11 | | unresolved — needs a per-schema verdict | 33 | -| wire / open — out of forced scope | 104 | +| wire / open — out of forced scope | 106 | | no door — no carrier, ADR-0049 territory | 36 | | no gate — carrier live, no parse | 31 | @@ -43,12 +43,12 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `ui/` | 190 | 112 | 5 | 0 | 73 | +| `ui/` | 192 | 116 | 5 | 0 | 71 | | `data/` | 162 | 54 | 1 | 0 | 107 | | `automation/` | 75 | 49 | 0 | 0 | 26 | | `security/` | 20 | 7 | 0 | 0 | 13 | | `studio/` | 27 | 27 | 0 | 0 | 0 | -| **total** | **474** | **249** | **6** | **0** | **219** | +| **total** | **476** | **253** | **6** | **0** | **217** | ## File-level triage — site counts @@ -78,9 +78,9 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `sharing.zod.ts` | 1 | | `theme.zod.ts` | 6 | | `touch.zod.ts` | 7 | -| `view.zod.ts` | 51 | +| `view.zod.ts` | 53 | | `widget.zod.ts` | 9 | -| **total** | **190** | +| **total** | **192** | ### `data/` — sites @@ -160,7 +160,7 @@ over it is here. ### `ui/` — open -**73 strip of 190**, in 11 file(s). +**71 strip of 192**, in 11 file(s). | File | Strip | Sites | |---|---|---| @@ -173,15 +173,15 @@ over it is here. | `keyboard.zod.ts` | 4 | 4 | | `offline.zod.ts` | 3 | 3 | | `touch.zod.ts` | 7 | 7 | -| `view.zod.ts` | 5 | 51 | +| `view.zod.ts` | 3 | 53 | | `widget.zod.ts` | 9 | 9 | -| **total** | **73** | **190** | +| **total** | **71** | **192** | | Bucket | Sites | |---|---| -| authorable — the ruling's forced scope | 6 | +| authorable — the ruling's forced scope | 2 | | unresolved — needs a per-schema verdict | 0 | -| wire / open — out of forced scope | 0 | +| wire / open — out of forced scope | 2 | | no door — no carrier, ADR-0049 territory | 36 | | no gate — carrier live, no parse | 31 | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index bc4469107e..babb61d50d 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -824,7 +824,7 @@ next person to open that file will look. | File | Class | Batch | |---|---|---| | `component.zod.ts` | **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` | mixed · 5 authorable | **15 of 20 closed at #4001 批 18**, a sixteenth (`UserFiltersSchema`) at **#5073** once its protocol blocker was adjudicated, and a seventeenth — `ViewFilterRuleSchema`, closed by an EARLIER wave — reopened at **#5114**; 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 used to read them as `strict`, because `postureOf` returned early on the `strictObject` idiom instead of walking the chain. **Fixed at #5072**: the idiom now seeds the initial posture and the chain always runs, so the two read `passthrough` and the directory's strict count drops by 2. The strip count was never affected — neither posture is strip — so this row's numbers do not move. **`UserFiltersSchema` is CLOSED as of #5073, and it is the one site in this file whose blocker was never a strictness question.** Closing it would have 422'd `allowAddTab` — a key objectui's renderer reads (`plugin-list/src/UserFilters.tsx:182`/`:742`) and the spec never declared; because `saveMetaItem` validates but persists the ORIGINAL body, the stripped key still reached the renderer, so the capability WORKED and closing would have removed it rather than making a silent failure loud. 批 18 stopped and filed rather than guessing, and the maintainer adjudicated **promote, then close, in one PR** (2026-08-04): `allowAddTab` is now DECLARED here, so the capability is discoverable from the contract (JSON Schema / Studio SchemaForm / an AI author) instead of living in one React file, and the shape closes behind it with no intermediate state. The rejected option was `SANCTIONED_LOCAL` in objectui, which would have made spec and objectui two sources of truth for one contract — the fork #2231's derive-by-reference exists to prevent (PD#12) — and would have taught authors to delete a working key with a rejection that was itself "correct" (finding 7). Two details the close is worth remembering for. **(a)** The promotion is scoped to what the renderer really does: the add-tab button objectui renders carries no click handler, so `allowAddTab` declares that the affordance RENDERS and deliberately says nothing about creating presets — a `.describe()` promising more would be PD#10's advertise-what-you-don't-deliver, and the renderer gap is filed as **#5236**. **(b)** The 批 6e reliance question resolved exactly as predicted — `ObjectUserFiltersSchema` is `.omit()`ed off this base and `.omit()` inherits posture, so the pin flipped from "drops" to "rejects", which is wanted (the CLI lint `validate-list-view-mode.ts` was already reporting these) — but inheriting the posture also inherits the base's ERROR MAP, whose `knownKeys` were read from the base shape and therefore still listed the omitted keys. Measured on the flip: `tab` was answered *"Did you mean `tab` → `tabs`?"*, steering the author at the one key that surface refuses — finding 7 produced by the fix for finding 7. So the object variant now carries its own map built over the OMITTED shape (the shape still derived by `.omit()`, so #2231 holds), with `guidance` pointing all three page-only keys at `listViews`. **Still open, all five measured:** `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) | +| `view.zod.ts` | mixed · 1 authorable, 2 wire | **15 of 20 closed at #4001 批 18**, a sixteenth (`UserFiltersSchema`) at **#5073** once its protocol blocker was adjudicated, a seventeenth — `ViewFilterRuleSchema`, closed by an EARLIER wave — reopened at **#5114**, and then the file's last authoring debt cleared at **#5074**, which closed `ViewItemSchema` (×2 arms), `ListView.sort` AND `ViewFilterRuleSchema` in one structural change. **The strip count went 5 → 3, and the arithmetic is the finding, not the number: FOUR sites closed and TWO were ADDED** — the two arms of the new `ViewItemWireSchema`, which are strip BY DESIGN. That is why this row's Class cell is now a split (`1 authorable, 2 wire`) rather than a smaller `authorable` count: the wire contract that used to live on "the member nobody closed" now has a name, and this map measures posture, not intent. 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, REVERTED, and closed again at #5074 — the round trip is the file'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. **#5074 supplied the missing half and the shape is now CLOSED**: the write door removes the declared decoration vocabulary (`VIEW_CONSOLE_ROW_DECORATIONS` / `stripViewConsoleDecorations`, the mirror of `stripReadDecorations`) BEFORE the union runs, so the opening is recursive-effective where a member-level `.strip()` can never be, and the authoring surface never grew the key. The `direction → order` alias came back with it. 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 used to read them as `strict`, because `postureOf` returned early on the `strictObject` idiom instead of walking the chain. **Fixed at #5072**: the idiom now seeds the initial posture and the chain always runs, so the two read `passthrough` and the directory's strict count drops by 2. The strip count was never affected — neither posture is strip — so this row's numbers do not move. **`UserFiltersSchema` is CLOSED as of #5073, and it is the one site in this file whose blocker was never a strictness question.** Closing it would have 422'd `allowAddTab` — a key objectui's renderer reads (`plugin-list/src/UserFilters.tsx:182`/`:742`) and the spec never declared; because `saveMetaItem` validates but persists the ORIGINAL body, the stripped key still reached the renderer, so the capability WORKED and closing would have removed it rather than making a silent failure loud. 批 18 stopped and filed rather than guessing, and the maintainer adjudicated **promote, then close, in one PR** (2026-08-04): `allowAddTab` is now DECLARED here, so the capability is discoverable from the contract (JSON Schema / Studio SchemaForm / an AI author) instead of living in one React file, and the shape closes behind it with no intermediate state. The rejected option was `SANCTIONED_LOCAL` in objectui, which would have made spec and objectui two sources of truth for one contract — the fork #2231's derive-by-reference exists to prevent (PD#12) — and would have taught authors to delete a working key with a rejection that was itself "correct" (finding 7). Two details the close is worth remembering for. **(a)** The promotion is scoped to what the renderer really does: the add-tab button objectui renders carries no click handler, so `allowAddTab` declares that the affordance RENDERS and deliberately says nothing about creating presets — a `.describe()` promising more would be PD#10's advertise-what-you-don't-deliver, and the renderer gap is filed as **#5236**. **(b)** The 批 6e reliance question resolved exactly as predicted — `ObjectUserFiltersSchema` is `.omit()`ed off this base and `.omit()` inherits posture, so the pin flipped from "drops" to "rejects", which is wanted (the CLI lint `validate-list-view-mode.ts` was already reporting these) — but inheriting the posture also inherits the base's ERROR MAP, whose `knownKeys` were read from the base shape and therefore still listed the omitted keys. Measured on the flip: `tab` was answered *"Did you mean `tab` → `tabs`?"*, steering the author at the one key that surface refuses — finding 7 produced by the fix for finding 7. So the object variant now carries its own map built over the OMITTED shape (the shape still derived by `.omit()`, so #2231 holds), with `guidance` pointing all three page-only keys at `listViews`. **⚠️ #5074 — the authoring/wire SPLIT, and the row's headline.** `ViewItemSchema` wore two contracts: the authoring gate (`defineViewItem`, objectui's view-create form, which validates `createBuildBody`'s output against the real spec schema) and member 1 of `ViewMetadataSchema`, the union `saveMetaItem` validates every persisted `view` body against. The wire role was measured, not inferred — 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 the merged body lands on member 1 (the flattened members are excluded by their `config: z.undefined()` guard) and closing the one schema would have 422'd pinning a saved view. The maintainer ruled **split** (2026-08-04), and the two-axis reasoning is worth keeping: `defineViewItem({name, object, viewKind, confg: {…}})` — one letter — used to strip the typo and hand back a ViewItem with **no view configuration at all**, parsed clean, which is #1535's `workflows: [...]` replayed on the file's densest authoring surface. `ViewItemSchema` is now `strictObject` on both arms; `ViewItemWireSchema` is the `.strip()` wire variant, built from the SAME `viewItemArmShape()` (derive-by-reference, #2231 — a `discriminatedUnion` cannot be `.extend()`ed, so sharing the shape factory is what keeps one contract from becoming two transcriptions), and `isPinned`/`sortOrder` are DECLARED on it — an explicit home, instead of surviving because nobody closed the member. **The scope addendum's hard requirement was recursive-effective openness, and that is the part a posture flip could not deliver.** `.strip()` re-opens a member's TOP level only, so the two console-decorated NESTED blocks (`ListView.sort[].id`, `ViewFilterRule.id`) were still reached at full strictness through it. The route taken is the addendum's second sanctioned one: a declared decoration vocabulary stripped before validation, at the wire door, reaching every carrier at every depth — including ones added later, which a hand-maintained parallel wire tree would not. It is deliberately NOT a second schema tree (PD#12's fork) and deliberately NOT a declared `id` (批 18 Q1's two-axis rejection: a React list key on the authoring surface teaches AI authors to emit UUIDs). Two landmines were named in the ruling and both are pinned in `view-authoring-wire-split.test.ts` §5: `z.toJSONSchema()` must still emit a four-member `anyOf` (the `/api/v1/meta/types/view` endpoint feeds Studio's SchemaForm from it — it does; a pipe converts to its output side, asserted in BOTH io directions), and the `lazySchema` Proxy's ADR-0089 D3a crash (`Cannot set properties of undefined (setting 'ref')`) must not recur under a pipe-rooted lazy schema — it does not, and each new schema is converted directly rather than only through its parent. **One real hazard the change surfaced, fixed in the same PR:** a `z.preprocess` at a registered root put TWO gate walkers into the exact blind spot #4488 had already found and fixed in `check-liveness.mts` — `metadata-authoring-lint.ts` and `metadata-form-zod-reconciliation.test.ts` both unwrapped a pipe via `def.in`, which for a preprocess is the TRANSFORM, so each reported `view` as *not key-bearing* and silently stopped covering it. Caught by their own coverage assertions (`lintables.length >= 1`, `root schema is not key-bearing`), which is precisely what those assertions exist for; both now prefer whichever side is not the transform. **A gate going quiet is worse than a gate failing** — and the pattern will recur on the next preprocess-rooted registration, so it is recorded here rather than only in the diff. **Still open, one site, measured:** `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. The two remaining strip sites beyond it are `ViewItemWireSchema`'s arms, which are `wire` by design and are not debt. `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. **That reopen was explicitly PROVISIONAL — "pending #5074" — and #5074 retired it rather than leaving it standing: the shape is CLOSED again, by the same decoration strip that closed `sort`, so the authoring gate rejects `id` by name while the console's own three paths still parse.** Its pin file now asserts the split per door, and the direction is the INVERTED one worth flagging to the next reader: probes 1/3 and 2/3 were GREEN before #5074 and are RED after (that IS the close), while 3/3 — the body the console actually PUTs — is green on BOTH sides and must stay so; a file that only asserted "the console body parses" would have passed unchanged through a change that quietly declared `id` as authorable. 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 named this site; the gate it was waiting on — a wire opening that REACHES a nested block — landed with it. 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` | **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` | **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. **#5022 added an eighth site to this file, and it is the one worth copying**: `ChartDrillDownSchema` arrived with its gate already wired — `packages/lint/src/validate-react-page-props.ts` PARSES it against a static `drillDown={{…}}` literal instead of re-deriving the rules the way `CHART_FUNCTIONS` does for `aggregate` beside it. That is exactly the fix this row is blocked on, demonstrated on one key; the two sites here are unchanged because their prop is `aggregate`, not `drillDown` | | `touch.zod.ts` | **no door** | ⛔ **not strictness work** — measured unreachable from every authoring root (#4001 批 13); ADR-0049 triage is #4988. See the triage row above | diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 88e05ea272..2bea64727c 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -3480,6 +3480,7 @@ "UserFilterFieldSchema (const)", "UserFilters (type)", "UserFiltersSchema (const)", + "VIEW_CONSOLE_ROW_DECORATIONS (const)", "VIEW_FILTER_OPERATORS (const)", "VIEW_FILTER_OPERATOR_ALIASES (const)", "View (type)", @@ -3491,6 +3492,8 @@ "ViewItem (type)", "ViewItemNameSchema (const)", "ViewItemSchema (const)", + "ViewItemWire (type)", + "ViewItemWireSchema (const)", "ViewKeyCollision (interface)", "ViewKind (type)", "ViewKindSchema (const)", @@ -3545,6 +3548,7 @@ "reactBlockTagFor (function)", "reportForm (const)", "reportSelectionOrder (function)", + "stripViewConsoleDecorations (function)", "validateActionParams (function)", "viewForm (const)" ], diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index 335c675a19..5d7bf97be0 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -1626,6 +1626,7 @@ "ui/ViewFilterRule", "ui/ViewItem", "ui/ViewItemName", + "ui/ViewItemWire", "ui/ViewKind", "ui/ViewScope", "ui/ViewSharing", diff --git a/packages/spec/scripts/strictness-ledger.test.ts b/packages/spec/scripts/strictness-ledger.test.ts index 94f6834c1c..ac4331c702 100644 --- a/packages/spec/scripts/strictness-ledger.test.ts +++ b/packages/spec/scripts/strictness-ledger.test.ts @@ -232,10 +232,19 @@ describe('posture reading, with a red control for each', () => { // is planned against — is untouched by this fix. // // The number itself tracks real batches: 6 when #5072 was written, 5 since - // #5073 closed `UserFiltersSchema`. It is the file's live strip count, not a - // #5072 invariant — what #5072 pins is that ITS OWN change moved no strip - // site, and that still reads correctly against whatever the current count is. - expect(countStripSites(at('ui/view.zod.ts'))).toBe(5); + // #5073 closed `UserFiltersSchema`, 3 since #5074. It is the file's live + // strip count, not a #5072 invariant — what #5072 pins is that ITS OWN + // change moved no strip site, and that still reads correctly against + // whatever the current count is. + // + // 5 → 3 at #5074, and the arithmetic is worth spelling out because it is + // not "two more closed": FOUR closed (both `ViewItemSchema` arms, the + // `ListView.sort` entry, `ViewFilterRuleSchema`) and TWO were ADDED — the + // two arms of `ViewItemWireSchema`, which are strip BY DESIGN and are the + // declared home the wire contract moved into. A strip site that exists on + // purpose still counts here; this map measures posture, not intent, which + // is exactly why the ledger row carries the intent in prose. + expect(countStripSites(at('ui/view.zod.ts'))).toBe(3); }); it('lets a chained posture override the idiom in either direction (#5072)', () => { diff --git a/packages/spec/src/kernel/metadata-authoring-lint.ts b/packages/spec/src/kernel/metadata-authoring-lint.ts index 013932dc07..051ae749d9 100644 --- a/packages/spec/src/kernel/metadata-authoring-lint.ts +++ b/packages/spec/src/kernel/metadata-authoring-lint.ts @@ -101,8 +101,21 @@ function unwrap(schema: unknown, depth = 0): any { return unwrap(d.innerType, depth + 1); case 'lazy': return unwrap(d.getter(), depth + 1); - case 'pipe': - return unwrap(d.in, depth + 1); + case 'pipe': { + // Two pipes, opposite authorable sides — the #4488 finding, applied here + // at #5074. `a.transform(fn)` authors against the IN side (a is the + // accepted input shape); `z.preprocess(fn, schema)` also compiles to a + // pipe, but its IN side is the TRANSFORM and the authorable surface is + // the OUT schema. Taking `def.in` unconditionally makes this walker + // return the transform, report "not key-bearing", and go SILENT on the + // type — the failure mode #4488 measured on `translation` and #5074 + // would have reproduced on `view` the moment `ViewMetadataSchema` gained + // its console-decoration preprocess. A gate that stops covering a type is + // worse than one that fails. + const inner = unwrap(d.in, depth + 1); + const innerType = (inner?.def ?? inner?._def)?.type; + return innerType === 'transform' ? unwrap(d.out, depth + 1) : inner; + } default: return s; } diff --git a/packages/spec/src/kernel/metadata-type-schemas.test.ts b/packages/spec/src/kernel/metadata-type-schemas.test.ts index 73092d3b1a..147692bf76 100644 --- a/packages/spec/src/kernel/metadata-type-schemas.test.ts +++ b/packages/spec/src/kernel/metadata-type-schemas.test.ts @@ -227,18 +227,32 @@ describe('registered metadata types', () => { * ## `view` is the end state, not the last item of debt * * 24 of 25 are closed and the 25th will not be, for a reason worth stating so - * nobody "finishes the job" by force. The registered `view` schema is a UNION of - * three runtime shapes, and the third — the flattened overlay — is deliberately - * `.strip()`: it carries Studio's auxiliary round-trip keys (`isPinned`, - * `sortOrder`, …) that `saveMetaItem` persists verbatim, so closing it would 422 - * a shape the platform itself writes. A union is only as closed as its most open + * nobody "finishes the job" by force. The registered `view` schema is a UNION + * over the runtime shapes a `view` body really takes, and THREE of its four + * members are deliberately `.strip()`: + * + * • member 1, `ViewItemWireSchema` — the wire variant of `ViewItemSchema`; + * • members 3 and 4, the flattened personalization overlays. + * + * All three carry Studio's auxiliary round-trip keys (`isPinned`, `sortOrder`, + * …) that `saveMetaItem` persists verbatim, so closing any of them would 422 a + * shape the platform itself writes. A union is only as closed as its most open * member, so `view` reads `strip` and always will. * + * ⚠️ [#5074] This paragraph used to name the flattened overlay as "the one + * deliberately open member" and say nothing about member 1 — which was ALSO + * open, and open by accident rather than by decision. That gap was #5074's + * finding: member 1 was the shape `updateView` PUTs a pin through, so it had a + * wire contract nobody had written down. It now has a name, a declared home for + * the aux keys, and a strict authoring twin (`ViewItemSchema`). Do not shorten + * this back to "the overlay". + * * What DID close is everything an author writes: `ViewSchema` (the container), - * `ListViewSchema`, `FormViewSchema`, and the ~28 config shapes under them. The - * open member is a wire shape wearing the same type name — which is exactly the - * distinction the ledger's classification rule exists to draw, arriving here as - * the campaign's final answer rather than as an exception to it. + * `ViewItemSchema`, `ListViewSchema`, `FormViewSchema`, and the ~28 config + * shapes under them. The open members are wire shapes, now wearing their own + * names — which is exactly the distinction the ledger's classification rule + * exists to draw, arriving here as the campaign's final answer rather than as an + * exception to it. */ const STILL_STRIP = new Set(['view']); diff --git a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts index 628a6bdd50..ea1d6cad9d 100644 --- a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts +++ b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts @@ -125,8 +125,18 @@ function unwrap(schema: unknown, depth = 0): any { return unwrap(d.valueType, depth + 1); case 'lazy': return unwrap(d.getter(), depth + 1); - case 'pipe': - return unwrap(d.in, depth + 1); + case 'pipe': { + // #4488's finding, applied here at #5074: `a.transform(fn)` authors + // against the IN side, while `z.preprocess(fn, schema)` puts the TRANSFORM + // on IN and the authorable schema on OUT. Taking `def.in` unconditionally + // made this gate report `view` as "not key-bearing" — i.e. stop + // reconciling it — the moment `ViewMetadataSchema` gained its + // console-decoration preprocess. Same shape as the `translation` outage + // #4488 fixed in `check-liveness.mts`. + const inner = unwrap(d.in, depth + 1); + const innerType = (inner?.def ?? inner?._def)?.type; + return innerType === 'transform' ? unwrap(d.out, depth + 1) : inner; + } default: return s; } diff --git a/packages/spec/src/ui/view-authoring-wire-split.test.ts b/packages/spec/src/ui/view-authoring-wire-split.test.ts new file mode 100644 index 0000000000..39e0fb4ac0 --- /dev/null +++ b/packages/spec/src/ui/view-authoring-wire-split.test.ts @@ -0,0 +1,382 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #5074 — `ViewItemSchema` wore two contracts; this splits them. + * + * The maintainer's ruling (2026-08-04, option A) and its scope addendum: + * + * 1. `ViewItemSchema` TIGHTENS for the authoring gate (`defineViewItem`, + * objectui's view-create form). + * 2. `ViewMetadataSchema`'s member 1 becomes `ViewItemWireSchema` — a + * `.strip()`-reopened wire variant, following the file's OWN proven pattern + * for members 3/4 — with `isPinned`/`sortOrder` given a DECLARED home. + * 3. ⚠️ The wire opening must be **recursive-effective**. `.strip()` no more + * recurses than `.strict()`, so re-opening member 1's top level protects + * nothing nested. The two known console-decorated nested blocks — + * `ListView.sort[].id` (批 18 rolled back, #5070) and `ViewFilterRule.id` + * (#5114's provisional hotfix) — must survive the WRITE path while closing + * for authoring. + * 4. ⛔ Not solved by declaring `id` as authorable (批 18 Q1, two-axis + * rejection on record: a React list key on the authoring surface teaches AI + * authors to emit UUIDs). + * + * ## Reverse verification — the direction, decided before it was run + * + * For the two nested sites this is the **INVERTED** case, not the usual + * before-green/after-red, and saying so is the point of writing it down. Before + * this change `ViewFilterRuleSchema.safeParse(consoleRow)` was GREEN (the shape + * was open) and after it is RED — the authoring gate now rejects `id` by name. + * What must NOT move is the wire door: every console PUT body parses clean + * before AND after. So the honest reverse check is per-door, not per-schema: + * delete `stripViewConsoleDecorations` from `ViewMetadataSchema` and §3 below + * goes red while §2 stays green — that gap is the finding. Re-open + * `ViewFilterRuleSchema` instead and §2 goes red while §3 stays green. + * + * §5 covers the two landmines the ruling named by hand. + */ + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; + +import { + ViewItemSchema, + ViewItemWireSchema, + ViewMetadataSchema, + ViewFilterRuleSchema, + ListViewSchema, + defineViewItem, + stripViewConsoleDecorations, + VIEW_CONSOLE_ROW_DECORATIONS, +} from './view.zod'; +import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; + +/** Reject `value` through `schema` and return its issues as a searchable string. */ +function reject(schema: { safeParse: (v: unknown) => { success: boolean; error?: unknown } }, value: unknown): string { + const r = schema.safeParse(value); + expect(r.success, `expected REJECTION, got a successful parse of ${JSON.stringify(value)}`).toBe(false); + return JSON.stringify((r.error as { issues?: unknown })?.issues ?? r.error ?? []); +} + +/** Parse `value` and fail loudly (with the issues) if it does not succeed. */ +function accept(schema: { safeParse: (v: unknown) => { success: boolean; error?: unknown; data?: unknown } }, value: unknown): unknown { + const r = schema.safeParse(value); + expect(r.success, `expected ACCEPTANCE, got ${JSON.stringify((r.error as { issues?: unknown })?.issues ?? '')}`).toBe(true); + return r.data; +} + +/** A canonical, spec-valid ViewItem record — what `defineViewItem` authors. */ +const RECORD = { + name: 'crm_lead.pipeline', + object: 'crm_lead', + viewKind: 'list' as const, + label: 'Pipeline', + config: { type: 'grid', data: { provider: 'object', object: 'crm_lead' }, columns: ['name'] }, +}; + +/** One filter row exactly as the console writes it (`filter-builder.tsx:228`). */ +const CONSOLE_FILTER_ROW = { + id: 'c0ffee00-dead-beef-cafe-000000000000', + field: 'stage', + operator: 'equals', + value: 'won', +} as const; + +/** One sort row exactly as the console writes it (`sort-builder.tsx:68`/`:94`). */ +const CONSOLE_SORT_ROW = { + id: '29200fa8-c416-471e-9ca3-913f9308ad89', + field: 'estimate_hours', + order: 'desc', +} as const; + +/** The flattened personalization overlay (#2555) the console PUTs. */ +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; + +// =========================================================================== +// 1. The doors — a posture is a property of a PARSE (#4583) +// =========================================================================== +describe('#5074 — the two doors, which is the whole point of the split', () => { + it('the `view` metadata type resolves to a registered schema (the wire / 422 door)', () => { + expect(getMetadataTypeSchema('view')).toBeDefined(); + }); + + it('`defineViewItem()` is a real parse door — it throws on a malformed config', () => { + expect(() => + defineViewItem({ + ...RECORD, + config: { type: 'grid', columns: 'not-an-array' }, + } as never), + ).toThrow(); + }); + + it('controls parse — these tests fail closed, they do not reject everything', () => { + accept(ViewItemSchema, RECORD); + accept(ViewItemWireSchema, RECORD); + accept(ViewMetadataSchema, RECORD); + }); +}); + +// =========================================================================== +// 2. The AUTHORING gate — tightened +// =========================================================================== +describe('#5074 — `ViewItemSchema` is the authoring gate and is now strict', () => { + it('the one-letter `confg` typo is REJECTED, not stripped into an empty ViewItem', () => { + // The failure the ruling turned on: before the split this parsed clean and + // produced a ViewItem with no view configuration at all — #1535's + // `workflows: [...]` replayed on the view surface. + const { config: _dropped, ...withoutConfig } = RECORD; + const msg = reject(ViewItemSchema, { ...withoutConfig, confg: RECORD.config }); + expect(msg).toContain('confg'); + expect(msg).toContain('config'); + }); + + it('an undeclared key is rejected on BOTH arms, at the arm where it lives', () => { + expect(reject(ViewItemSchema, { ...RECORD, notAViewItemKey: 1 })).toContain('notAViewItemKey'); + expect( + reject(ViewItemSchema, { + name: 'crm_lead.intake', + object: 'crm_lead', + viewKind: 'form', + config: { type: 'simple', sections: [{ fields: ['name'] }] }, + notAViewItemKey: 1, + }), + ).toContain('notAViewItemKey'); + }); + + it('the wire keys are rejected HERE, each with the prescription for its layer', () => { + // Not a bare refusal: an author who wrote `sortOrder` in a `*.view.ts` is + // pointed at `order`, the authored key that does mean what they wanted. + expect(reject(ViewItemSchema, { ...RECORD, isPinned: true })).toContain('isPinned'); + expect(reject(ViewItemSchema, { ...RECORD, sortOrder: 3 })).toContain('`order`'); + }); + + it('`defineViewItem` — the door, not just the schema — refuses the typo', () => { + const { config: _dropped, ...withoutConfig } = RECORD; + expect(() => defineViewItem({ ...withoutConfig, confg: RECORD.config } as never)).toThrow(/confg/); + }); + + it('every key objectui\'s `createBuildBody` emits still parses (the create form must not break)', () => { + // Mirrors objectui `app-shell/src/views/metadata-admin/anchors.ts` + // `createBuildBody`, guarded there by `view-create-body.test.ts`. Closing + // this shape is only safe because that emitter writes declared keys only. + accept(ViewItemSchema, { + name: 'crm_lead.all_leads', + object: 'crm_lead', + viewKind: 'list', + label: 'All Leads', + config: { type: 'grid', columns: [], data: { provider: 'object', object: 'crm_lead' } }, + }); + accept(ViewItemSchema, { + name: 'crm_lead.intake', + object: 'crm_lead', + viewKind: 'form', + label: 'Intake', + config: { type: 'tabbed', data: { provider: 'object', object: 'crm_lead' }, sections: [] }, + }); + }); + + it('the two nested console-decorated blocks now reject `id` at their own path', () => { + // Strictness does not recurse, so each is probed where it lives — and each + // rejection carries the reason, not a bare "unrecognized key". + expect(reject(ViewFilterRuleSchema, CONSOLE_FILTER_ROW)).toContain('console row key'); + expect(reject(ListViewSchema, { columns: ['name'], sort: [CONSOLE_SORT_ROW] })).toContain('console row key'); + }); + + it('…and `direction` is still answered with `order` — #4721, the silently REVERSED sort', () => { + // `{ field, direction: 'desc' }` used to parse to `{ field, order: 'asc' }`. + // This alias is why closing the sort entry was worth doing at all. + expect(reject(ListViewSchema, { columns: ['name'], sort: [{ field: 'name', direction: 'desc' }] })) + .toContain('`direction` → `order`'); + }); + + it('`id` is NOT declared anywhere on the authoring surface (批 18 Q1)', () => { + const filterKeys = Object.keys((ViewFilterRuleSchema as unknown as { shape: Record }).shape); + expect(filterKeys.sort()).toEqual(['field', 'operator', 'value']); + }); +}); + +// =========================================================================== +// 3. The WIRE door — every console PUT body still parses, at every depth +// =========================================================================== +describe('#5074 — the wire door accepts what the platform itself writes', () => { + it('member 1 carries the pin round-trip: `{...storedItem, isPinned}` (`ObjectView.tsx:882`)', () => { + accept(ViewItemWireSchema, { ...RECORD, isPinned: true, sortOrder: 3 }); + accept(ViewMetadataSchema, { ...RECORD, isPinned: true, sortOrder: 3 }); + }); + + it('…and the aux keys are DECLARED, so they survive the parse instead of being dropped', () => { + // The difference between "has a home" and "nobody closed this member". + // `saveMetaItem` stores the original body either way; a declared key is the + // one a JSON Schema consumer and an AI author can actually discover. + const parsed = accept(ViewMetadataSchema, { ...RECORD, isPinned: true, sortOrder: 3 }) as Record; + expect(parsed.isPinned).toBe(true); + expect(parsed.sortOrder).toBe(3); + }); + + it('…while a genuinely broken record is still NOT rescued by a lenient member', () => { + // The flattened members pin `config` to undefined, so a record with a + // broken config cannot slip through them with its payload stripped. + expect( + ViewMetadataSchema.safeParse({ ...RECORD, config: { type: 'grid', columns: 'not-an-array' } }).success, + ).toBe(false); + }); + + it('an undeclared aux key still rides on the wire member (`.strip()`, as before)', () => { + accept(ViewMetadataSchema, { ...RECORD, someFutureStudioAuxKey: 1 }); + }); + + // ── the acceptance criteria named in the scope addendum ────────────────── + it('ACCEPTANCE 1/2 — the console COLUMN-SORT PUT body parses clean', () => { + accept(ViewMetadataSchema, { ...OVERLAY_BASE, sort: [CONSOLE_SORT_ROW] }); + }); + + it('ACCEPTANCE 2/2 — the console FILTER-SAVE PUT body parses clean', () => { + accept(ViewMetadataSchema, { ...OVERLAY_BASE, filter: [CONSOLE_FILTER_ROW] }); + }); + + it.each([ + ['flattened overlay — `sort[]`', { ...OVERLAY_BASE, sort: [CONSOLE_SORT_ROW] }], + ['flattened overlay — `filter[]`', { ...OVERLAY_BASE, filter: [CONSOLE_FILTER_ROW] }], + ['flattened overlay — `tabs[].filter[]`', { ...OVERLAY_BASE, tabs: [{ name: 'won', label: 'Won', filter: [CONSOLE_FILTER_ROW] }] }], + ['flattened overlay — `userFilters.tabs[].filter[]`', { + ...OVERLAY_BASE, + userFilters: { element: 'tabs', tabs: [{ name: 'won', label: 'Won', filter: [CONSOLE_FILTER_ROW] }] }, + }], + ['ViewItem record — `config.sort[]`', { ...RECORD, config: { ...RECORD.config, sort: [CONSOLE_SORT_ROW] } }], + ['ViewItem record — `config.filter[]`', { ...RECORD, config: { ...RECORD.config, filter: [CONSOLE_FILTER_ROW] } }], + ['ViewItem record — pin + decorated config', { + ...RECORD, + isPinned: true, + config: { ...RECORD.config, sort: [CONSOLE_SORT_ROW], filter: [CONSOLE_FILTER_ROW] }, + }], + ['container — `list.sort[]`', { list: { type: 'grid', data: { provider: 'object', object: 'crm_lead' }, columns: ['name'], sort: [CONSOLE_SORT_ROW] } }], + ])('RECURSIVE-effective: %s survives the wire door', (_case, body) => { + // This is the addendum's hard requirement. A member-level `.strip()` can + // only reach the TOP level; every one of these is nested, and several are + // nested under a member that is itself strict. + accept(ViewMetadataSchema, body); + }); +}); + +// =========================================================================== +// 4. The strip removes the decoration and NOTHING else +// =========================================================================== +describe('#5074 — `stripViewConsoleDecorations`, the write-path mirror of `stripReadDecorations`', () => { + it('declares its vocabulary rather than hard-coding a key at the call site', () => { + expect([...VIEW_CONSOLE_ROW_DECORATIONS]).toEqual(['id']); + }); + + it('removes the row `id` at every carrier depth', () => { + const out = stripViewConsoleDecorations({ + ...OVERLAY_BASE, + sort: [CONSOLE_SORT_ROW], + filter: [CONSOLE_FILTER_ROW], + tabs: [{ name: 'won', filter: [CONSOLE_FILTER_ROW] }], + }) as Record; + expect(out.sort[0]).toEqual({ field: 'estimate_hours', order: 'desc' }); + expect(out.filter[0]).toEqual({ field: 'stage', operator: 'equals', value: 'won' }); + expect(out.tabs[0].filter[0]).not.toHaveProperty('id'); + }); + + it('leaves a TOP-LEVEL `id` alone — only builder ROWS are decorated', () => { + // Scoped to the carriers that hold rows. A body-level `id` is somebody + // else's contract and is not this function's to delete. + const out = stripViewConsoleDecorations({ ...OVERLAY_BASE, id: 'row_1' }) as Record; + expect(out.id).toBe('row_1'); + }); + + it('returns the SAME reference when there is nothing to strip', () => { + const clean = { ...OVERLAY_BASE, sort: [{ field: 'a', order: 'asc' }] }; + expect(stripViewConsoleDecorations(clean)).toBe(clean); + }); + + it('does not mutate its input — `saveMetaItem` persists the ORIGINAL body', () => { + // Load-bearing: the console reads its own ids back out of the store. If + // this stripped in place, the round-trip would lose them. + const body = { ...OVERLAY_BASE, filter: [{ ...CONSOLE_FILTER_ROW }] }; + stripViewConsoleDecorations(body); + expect(body.filter[0].id).toBe(CONSOLE_FILTER_ROW.id); + }); + + it('passes non-objects through untouched', () => { + expect(stripViewConsoleDecorations(null)).toBe(null); + expect(stripViewConsoleDecorations('str')).toBe('str'); + }); + + it('the operator vocabulary still bites through the wire door — openness is about UNKNOWN KEYS', () => { + expect( + ViewMetadataSchema.safeParse({ ...OVERLAY_BASE, filter: [{ ...CONSOLE_FILTER_ROW, operator: 'sorta_equals' }] }).success, + ).toBe(false); + expect( + ViewMetadataSchema.safeParse({ ...OVERLAY_BASE, sort: [{ ...CONSOLE_SORT_ROW, order: 'sideways' }] }).success, + ).toBe(false); + }); + + it('a still-CLOSED nested block that is NOT a console decoration still rejects', () => { + // The control that keeps §3 honest: the strip is a named vocabulary, not a + // blanket re-opening of the tree. + expect( + ViewMetadataSchema.safeParse({ ...OVERLAY_BASE, emptyState: { title: 'None', notAnEmptyStateKey: 1 } }).success, + ).toBe(false); + }); +}); + +// =========================================================================== +// 5. The two landmines the ruling named +// =========================================================================== +describe('#5074 — landmine 1: `/api/v1/meta/types/view` must still get an `anyOf`', () => { + it('the REGISTERED schema converts to a four-member `anyOf`, through the preprocess', () => { + // Studio's SchemaForm is built from this. `z.preprocess` makes the root a + // pipe; a pipe converts to its output side, so the union survives — but + // that is a fact about zod, so it is asserted rather than assumed. + const json = z.toJSONSchema(getMetadataTypeSchema('view')!, { unrepresentable: 'any' }) as Record; + expect(Array.isArray(json.anyOf)).toBe(true); + expect((json.anyOf as unknown[]).length).toBe(4); + }); + + it('converts in the INPUT direction too — the form is built from what an author sends', () => { + const json = z.toJSONSchema(ViewMetadataSchema, { unrepresentable: 'any', io: 'input' }) as Record; + expect(Array.isArray(json.anyOf)).toBe(true); + expect((json.anyOf as unknown[]).length).toBe(4); + }); + + it('the emitted schema does NOT advertise `id` on a filter/sort row', () => { + // The whole reason the strip exists instead of a declaration: whatever the + // wire tolerates, the published contract must not teach an author to write + // a UUID. Serialized-and-searched because the key could appear at any depth. + const json = JSON.stringify(z.toJSONSchema(ViewItemSchema, { unrepresentable: 'any' })); + expect(json).toContain('"operator"'); + expect(json).not.toContain('"console row key"'); + }); +}); + +describe('#5074 — landmine 2: the lazySchema Proxy / ADR-0089 D3a trap', () => { + // `lazySchema` returns a Proxy, and zod keys its `toJSONSchema` `seen` map on + // the node it was handed. When a wrapper-type processor (pipe/lazy/optional) + // then looks itself up by the REAL instance, the entry is missing and zod + // throws `Cannot set properties of undefined (setting 'ref')`. This change + // adds a pipe at the root of a lazy schema — exactly that shape — so each + // new/changed schema is converted directly, not only through its parent. + it.each([ + ['ViewMetadataSchema (lazy → pipe → union)', () => ViewMetadataSchema], + ['ViewItemWireSchema (lazy → discriminatedUnion)', () => ViewItemWireSchema], + ['ViewItemSchema (lazy → discriminatedUnion, strict arms)', () => ViewItemSchema], + ['ViewFilterRuleSchema (lazy → strict object)', () => ViewFilterRuleSchema], + ])('%s converts without the `seen`-map crash', (_name, get) => { + expect(() => z.toJSONSchema(get(), { unrepresentable: 'any' })).not.toThrow(); + }); + + it('converts twice in a row — the memoised `_zod` facade must be re-entrant', () => { + expect(() => { + z.toJSONSchema(ViewMetadataSchema, { unrepresentable: 'any' }); + z.toJSONSchema(ViewMetadataSchema, { unrepresentable: 'any' }); + }).not.toThrow(); + }); +}); 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 index b4f18f078b..d45fce9b04 100644 --- a/packages/spec/src/ui/view-filter-rule-wire-id.test.ts +++ b/packages/spec/src/ui/view-filter-rule-wire-id.test.ts @@ -1,8 +1,20 @@ // 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. + * #5114 → **#5074**: the console stamps a UI row `id` into every filter rule it + * writes, and the two doors now answer that differently — the AUTHORING shape + * rejects it by name, the WIRE door removes it before validating. + * + * ⚠️ #5114 was an explicitly PROVISIONAL hotfix: it reopened + * `ViewFilterRuleSchema` to stop a live 422, and said so, "pending #5074's + * authoring/wire split applied to this block". That split has landed, so this + * file no longer pins an open shape — it pins the split, per door. The + * direction of the change is worth stating because it is the INVERTED one: + * §2's first two cases were GREEN before #5074 and are RED after (that is the + * close), while §2's third case — the body the console actually PUTs — is green + * on both sides and must stay that way. A file that only asserted "console body + * parses" would have passed unchanged through a change that quietly declared + * `id` as authorable, which is the outcome 批 18 Q1 rejected on record. * * 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 @@ -28,14 +40,15 @@ * `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. + * WHY `id` IS STILL 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. #5074 therefore closed the shape WITHOUT declaring the key: + * the write door strips the declared decoration vocabulary + * (`stripViewConsoleDecorations`, the mirror of `stripReadDecorations`) ahead of + * the union, which is the only route that is recursive-effective — a `.strip()` + * on the wire member can never reach a nested block. */ import { describe, it, expect } from 'vitest'; @@ -91,20 +104,28 @@ describe('#5114 — the door this shape is reached through', () => { // =========================================================================== // 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); +describe('#5114 — a console-written filter row, judged per door (#5074)', () => { + it('1/3 `ViewFilterRuleSchema` — the AUTHORING shape — now REJECTS the row, by name', () => { + // Inverted vs #5114: this was green while the hotfix stood. The rejection + // has to carry its reason, or an author fixes it by inventing a key. + const r = ViewFilterRuleSchema.safeParse(CONSOLE_FILTER_ROW); + expect(r.success).toBe(false); + expect(JSON.stringify(r.error?.issues ?? [])).toContain('console row key'); }); - it('2/3 `ListViewSchema.filter` accepts an array of them', () => { + it('2/3 `ListViewSchema.filter` — reached through its carrier — rejects it too', () => { + // Probed at its own path: strictness does not recurse in either direction, + // so the carrier has to be measured, not inferred from case 1. expect( ListViewSchema.safeParse({ columns: ['name'], filter: [CONSOLE_FILTER_ROW] }).success, - ).toBe(true); + ).toBe(false); }); - 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. + it('3/3 `ViewMetadataSchema` — the WIRE door — still accepts the body the console PUTs', () => { + // The path that actually 422'd the user, and the case that must NOT move. + // It is green before and after #5074: before because the block was open, + // after because the decoration is stripped ahead of the union. If this ever + // goes red the close has re-become the #5114 outage. expect(ViewMetadataSchema.safeParse(CONSOLE_PUT_BODY).success).toBe(true); }); @@ -125,22 +146,32 @@ describe('#5114 — a console-written filter row parses on every path', () => { // =========================================================================== // 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); +describe('#5114 — the close gave away no validation, and declared no UI key', () => { + it('`id` is DROPPED on the wire path, not declared onto the surface', () => { + // The distinction the whole fix turns on, re-measured at the door that now + // owns the tolerance. 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 = ViewMetadataSchema.parse(CONSOLE_PUT_BODY) as { filter?: Array> }; + const row = parsed.filter?.[0] ?? {}; + expect(Object.keys(row).sort()).toEqual(['field', 'operator', 'value']); + expect('id' in row).toBe(false); + // …and the authoring shape never grew the key. + expect( + Object.keys((ViewFilterRuleSchema as unknown as { shape: Record }).shape).sort(), + ).toEqual(['field', 'operator', 'value']); }); - 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. + it('the operator vocabulary still bites ON THE WIRE — an invented operator is still rejected', () => { + // The tolerance is about UNKNOWN KEYS only. Every declared key keeps its own + // validation, so the wire door is not a shape that accepts anything now. + // Probed through the WIRE door, because that is the one that got looser. expect( - ViewFilterRuleSchema.safeParse({ ...CONSOLE_FILTER_ROW, operator: 'sorta_equals' }).success, + ViewMetadataSchema.safeParse({ + ...CONSOLE_PUT_BODY, + filter: [{ ...CONSOLE_FILTER_ROW, operator: 'sorta_equals' }], + }).success, ).toBe(false); }); @@ -163,8 +194,11 @@ describe('#5114 — reopening dropped the unknown-key gate, and nothing else', ( 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. + // `ViewFilterRuleSchema` is written — and they did, across the #5114 reopen + // AND the #5074 re-close, which is what makes them controls rather than + // assertions about the fix. Remove the decoration strip from the wire door + // and case 3/3 above goes red while these two stay green: that gap is still + // the finding, now pointing at the mechanism instead of the posture. it('the flattened member re-opens the TOP level: an unknown aux key rides along', () => { expect( ViewMetadataSchema.safeParse({ ...OVERLAY_BASE, someStudioAuxKey: 1 }).success, diff --git a/packages/spec/src/ui/view-strictness-batch18.test.ts b/packages/spec/src/ui/view-strictness-batch18.test.ts index b908ece9a1..4a6911f305 100644 --- a/packages/spec/src/ui/view-strictness-batch18.test.ts +++ b/packages/spec/src/ui/view-strictness-batch18.test.ts @@ -8,14 +8,23 @@ * sites that still dropped unknown keys silently. 16 were closed in the batch * itself; `UserFiltersSchema` followed at **#5073**, once the maintainer * adjudicated the protocol question that blocked it (promote `allowAddTab`, - * then close). The other THREE stay open, each for a measured reason, and those - * reasons are pinned in this file too — a deliberately-open shape that is only - * explained in prose is indistinguishable from one nobody has got to yet, which - * is how the next sweep "finishes the job" and breaks something. + * then close). Of the three the batch left open, TWO closed at **#5074** — + * `ViewItemSchema` and `ListView.sort` — once the authoring/wire split the + * batch filed rather than guessed was adjudicated and built. Their cases below + * are marked `[RESOLVED at #5074]` and now assert the split (authoring rejects, + * wire accepts) instead of the openness; they were kept rather than deleted + * because the trace each recorded is still the evidence the wire side rests on. * - * ⚠️ A fourth shape, `ViewFilterRuleSchema`, is open on separate evidence and - * pinned in its OWN file (`view-filter-rule-wire-id.test.ts`, #5114). It is not - * this batch's to reason about; do not fold the two sets together. + * ONE stays open — `FormFieldBaseSchema`, a module-private base whose sole + * consumer already `.strict()`s it — and its reason is pinned here too: a + * deliberately-open shape that is only explained in prose is indistinguishable + * from one nobody has got to yet, which is how the next sweep "finishes the + * job" and breaks something. + * + * ⚠️ A fourth shape, `ViewFilterRuleSchema`, was open on separate evidence and + * is pinned in its OWN file (`view-filter-rule-wire-id.test.ts`, #5114). It + * closed at #5074 too, by the same mechanism. It was never this batch's to + * reason about; do not fold the two sets together. * * This file is the third of the three places each verdict is recorded (the * others: the JSDoc on the shape itself, and the `ui/` row in @@ -349,21 +358,29 @@ describe('#4001 批 18 — union error behaviour (#5014), pinned as it really is // =========================================================================== // 5. The shapes left OPEN — with the evidence, so nobody "finishes" them // =========================================================================== -describe('#4001 批 18 — deliberately still open (do not close without re-measuring)', () => { - it('ViewItemSchema stays open: it is the member Studio round-trips `isPinned` through', () => { - // objectui's pin control PUTs `{ ...storedItem, isPinned }` - // (`ObjectView.tsx:882` → `data-objectstack/src/index.ts:2801`). A stored +describe('#4001 批 18 — the shapes left open, and what became of them', () => { + it('[RESOLVED at #5074] ViewItemSchema SPLIT — the authoring gate closed, the wire member kept the round-trip', () => { + // 批 18 left this open and filed the design question as #5074; the + // maintainer ruled "split" and it has landed, so this case is replaced + // rather than deleted — the trace it recorded is still the reason the wire + // side exists. 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 the merged body lands - // on THIS member — the flattened members are excluded by their - // `config: z.undefined()` guard. Closed, pinning a saved view would 422. + // on member 1 (the flattened members are excluded by their + // `config: z.undefined()` guard). + // + // Both halves are asserted here because either alone is satisfiable by the + // wrong outcome: authoring-only would pass if the wire member had been + // closed too (a 422 on pinning), wire-only would pass if nothing had closed. const record = { name: 'crm_lead.pipeline', object: 'crm_lead', viewKind: 'list' as const, config: { type: 'grid', columns: ['name'] }, }; - expect(ViewItemSchema.safeParse({ ...record, isPinned: true, sortOrder: 3 }).success).toBe(true); + expect(ViewItemSchema.safeParse({ ...record, isPinned: true, sortOrder: 3 }).success).toBe(false); expect(ViewMetadataSchema.safeParse({ ...record, isPinned: true, sortOrder: 3 }).success).toBe(true); + // The full split is pinned in `view-authoring-wire-split.test.ts`. }); it('…and the aux keys really do land on member 1, not on a flattened member', () => { @@ -381,29 +398,39 @@ describe('#4001 批 18 — deliberately still open (do not close without re-meas ).toBe(false); }); - it('ListViewSchema.sort stays open: the console stamps a UI row `id` into it', () => { - // Batch 18 CLOSED this (with `direction → order`, the #4721 alias for the - // identical tuple) and the full suite caught it: `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()`). - // `id` was deliberately NOT declared to silence the rejection: it is a React - // list key, and declaring it would put a UI artifact on the authorable - // surface and teach an AI author to emit one. - expect(ListViewSchema.safeParse({ ...LIST_BASE, sort: [{ id: 'uuid', field: 'name', order: 'asc' }] }).success).toBe(true); + it('[RESOLVED at #5074] ListViewSchema.sort CLOSED — the console `id` is stripped on the wire instead', () => { + // 批 18 closed this (with `direction → order`, the #4721 alias for the + // identical tuple), hit a live 422 and reverted (#5070). The revert was + // provisional pending #5074, so the case is replaced with what actually + // holds now — the two doors, separately: + // + // authoring — `id` is rejected by name. It was never declared: it is a + // React list key (`components/src/custom/sort-builder.tsx:68`/`:94`, + // `crypto.randomUUID()`), and declaring it would put a UI artifact on the + // authorable surface and teach an AI author to emit one. + // + // wire — the same body still parses, because the write door removes the + // decoration BEFORE validating (`stripViewConsoleDecorations`). + const sorted = { id: 'uuid', field: 'name', order: 'asc' }; + expect(ListViewSchema.safeParse({ ...LIST_BASE, sort: [sorted] }).success).toBe(false); + expect( + ViewMetadataSchema.safeParse({ + type: 'grid', columns: ['name'], name: 'o.default', viewKind: 'list', object: 'o', sort: [sorted], + }).success, + ).toBe(true); }); - it('…and the mechanism that made it a regression: `.strip()` does NOT recurse', () => { - // This is the load-bearing fact for every nested block in this file, and it - // is the opposite of what the union's comment implies. `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 no matter what the member does. + it('…and the mechanism, which is why a posture flip could NOT have fixed it: `.strip()` does not recurse', () => { + // The load-bearing fact for every nested block in this file. A member's + // `.strip()` re-opens the TOP level only, so a nested block closed inside + // `ListViewSchema` is still reached at full strictness through that member. + // #5074's answer is not a deeper strip — it is removing the named + // decoration ahead of the parse. These two assertions are the CONTROLS for + // that claim and must hold whichever way the decorated blocks are written. const overlay = { type: 'grid', columns: ['name'], name: 'o.default', viewKind: 'list', object: 'o' }; // top level: an unknown aux key rides along, because the member strips. expect(ViewMetadataSchema.safeParse({ ...overlay, someStudioAuxKey: 1 }).success).toBe(true); - // nested: a CLOSED sub-block still rejects through that same member. + // nested: a CLOSED sub-block that is NOT a declared decoration still rejects. expect(ViewMetadataSchema.safeParse({ ...overlay, emptyState: { title: 'x', notAnEmptyStateKey: 1 } }).success).toBe(false); }); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 9c6ffa31b1..ea83ebfe05 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -226,41 +226,155 @@ export function normalizeFilterOperator(op: unknown): string { return VIEW_FILTER_OPERATOR_ALIASES[op] ?? VIEW_FILTER_OPERATOR_ALIASES[op.toLowerCase()] ?? op; } +// ─────────────────────────────────────────────────────────────────────────── +// Write-time console decorations (#5074) — the mirror of `stripReadDecorations` +// ─────────────────────────────────────────────────────────────────────────── +// +// `stripReadDecorations` (`kernel/metadata-read-decorations.ts`) exists because +// the READ path stamps keys onto a served document that were never part of it, +// so a served body is not a valid input to the schema that produced it. The +// console's row builders create the same problem from the other side, and this +// is that function's write-path twin. +// +// The producer is a React list key, not a protocol decision: the filter builder +// (`components/src/custom/filter-builder.tsx:228`, re-stamped on read-back at +// `plugin-view/src/config/view-config-utils.ts:146`/`:160`) and the sort builder +// (`components/src/custom/sort-builder.tsx:68`/`:94`) both stamp +// `id: crypto.randomUUID()` on every row they render. `saveMetaItem` validates +// the PUT body and persists the AUTHORED body verbatim, so those ids reach the +// wire and the store. +// +// ⚠️ Why a `.strip()` on the wire member cannot do this job — the #4001 批 18 / +// #5114 finding, and the reason this vocabulary exists at all: **`.strip()` does +// not recurse, any more than `.strict()` does.** Re-opening a union member +// re-opens its TOP level; every nested block is still reached through it at that +// block's own posture. `filter[]` and `sort[]` are nested blocks, so a top-level +// reopen leaves them 422ing the platform's own writes. Removing the decoration +// BEFORE validation is what makes the wire opening recursive-effective, and it +// is the only one of the two routes that does not require the authoring surface +// to declare a UI artifact (批 18 Q1: declaring `id` teaches an AI author to +// emit a UUID for a filter rule — a `??` fallback wearing a schema). +// +// Deliberately NOT solved by a second parallel schema tree: a hand-maintained +// wire twin of every carrier of `ViewFilterRuleSchema` is a second copy of the +// truth (PD#12's fork), and it rots silently the first time someone adds a new +// carrier. One declared vocabulary, applied at the wire door, covers every +// carrier that exists today and every one added later. + +/** Keys the console stamps onto builder ROWS, which are therefore never authored. */ +export const VIEW_CONSOLE_ROW_DECORATIONS = ['id'] as const; + +/** + * Keys whose array value holds console builder rows. Both are written by a + * row-per-entry widget that needs a stable React key; neither element shape + * declares `id`, on any surface, by design. + */ +const VIEW_DECORATED_ROW_CARRIERS: readonly string[] = ['filter', 'sort']; + +/** Depth guard — a view body is a bounded document, not a general graph. */ +const VIEW_DECORATION_MAX_DEPTH = 12; + +/** The prescription an authored `id` gets on a row shape. Shared by both sites. */ +const VIEW_CONSOLE_ROW_ID_GUIDANCE = + '`id` is a console row key (the filter/sort builders stamp a `crypto.randomUUID()` ' + + 'per row for React) — it is not part of the authoring contract, and the write path ' + + 'removes it before validating. Delete it from authored metadata.'; + +function stripRowDecorations(value: unknown, isRow: boolean, depth: number): unknown { + if (depth > VIEW_DECORATION_MAX_DEPTH || !value || typeof value !== 'object') return value; + + if (Array.isArray(value)) { + let changed = false; + const next = value.map((el) => { + const out = stripRowDecorations(el, isRow, depth + 1); + if (out !== el) changed = true; + return out; + }); + return changed ? next : value; + } + + const dict = value as Record; + let next: Record | undefined; + + if (isRow) { + for (const k of VIEW_CONSOLE_ROW_DECORATIONS) { + if (k in dict) { + next ??= { ...dict }; + delete next[k]; + } + } + } + + for (const [k, v] of Object.entries(next ?? dict)) { + const out = stripRowDecorations(v, VIEW_DECORATED_ROW_CARRIERS.includes(k), depth + 1); + if (out !== v) { + next ??= { ...dict }; + next[k] = out; + } + } + + return next ?? value; +} + +/** + * Remove {@link VIEW_CONSOLE_ROW_DECORATIONS} from the builder rows of a `view` + * body, at every depth they occur — `filter[]` / `sort[]` under a flattened + * overlay, under a ViewItem's `config`, under `userFilters.tabs[]`, under + * `tabs[]`, and under any carrier added later. + * + * A **silent** removal, for the same reason `stripReadDecorations` is silent: + * this is our own UI's decoration riding on a document that is otherwise exactly + * what the author meant, so rejecting it would be hostile. It runs on the WIRE + * door only ({@link ViewMetadataSchema}) — {@link defineViewItem} and the other + * authoring doors keep rejecting the key by name, which is the whole point of + * the split. + * + * Nothing is lost at rest: `saveMetaItem` persists the ORIGINAL body, so the + * console still reads its own ids back. + * + * Returns the SAME reference when there is nothing to strip, so the common path + * allocates nothing. Non-object inputs pass through — the schema owns those. + */ +export function stripViewConsoleDecorations(body: unknown): unknown { + return stripRowDecorations(body, false, 0); +} + /** * View Filter Rule Schema * 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. + * ⚠️ [#5074] CLOSED — this is the authoring shape, and it rejects the console's + * row `id`. #5114 had reopened it as a provisional hotfix, explicitly pending + * this split; that hotfix is now retired rather than left standing. * - * **Wire-contaminated.** The filter builder objectui renders stamps - * `id: crypto.randomUUID()` on every row it creates + * **Why the reopen was needed, and why it no longer is.** 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. + * `id` is on the wire and in the store. Closed *without* a wire route, 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, and it is why a top-level reopen + * could never have rescued this block: **`.strip()` does not recurse**, any more + * than `.strict()` does. `ViewMetadataSchema` re-opens its wire members' TOP + * level only, so a nested block closed here is still reached through those + * members at full strictness. Same finding as `ListView.sort` at #4001 批 18 + * (#5070), one block over. + * + * `id` is still NOT declared here. 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. Instead the WIRE door removes it before validating, via the declared + * {@link VIEW_CONSOLE_ROW_DECORATIONS} vocabulary and + * {@link stripViewConsoleDecorations} — the write-path mirror of + * `stripReadDecorations`, and the piece that makes the wire opening + * recursive-effective where a `.strip()` cannot reach. So the authoring surface + * stays exactly three keys and the console's own writes still parse. * * 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`. @@ -274,7 +388,13 @@ export function normalizeFilterOperator(op: unknown): string { * ] * ``` */ -export const ViewFilterRuleSchema = lazySchema(() => z.object({ +export const ViewFilterRuleSchema = lazySchema(() => strictObject({ + surface: 'this filter rule', + history: VIEW_HISTORY, + guidance: { + id: VIEW_CONSOLE_ROW_ID_GUIDANCE, + }, +}, { /** Field name to filter on */ field: z.string().describe('Field name to filter on'), /** @@ -971,37 +1091,44 @@ export const ListViewSchema = lazySchema(() => strictObject({ * go through its own deprecation cycle; do not drop it here. */ /** - * ⚠️ [#4001 批 18] Deliberately still STRIP — reverted after the closed - * version broke a live console path, which is the finding rather than a - * setback. + * ⚠️ [#5074] CLOSED — the entry is the authoring shape and rejects the + * console's row `id`. 批 18 closed it, hit a live 422, and reverted (#5070); + * that revert was explicitly provisional pending this split. * - * This batch closed it (with `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 shape normalizeViewMetadata - * persists on a console column-sort PUT"*, and `id` is a UI row identity - * objectui stamps per row (`components/src/custom/sort-builder.tsx:68`, - * `:94` — `crypto.randomUUID()`), persisted verbatim because `saveMetaItem` - * stores the original body. + * The close carries `direction → order`, the #4721 alias for the identical + * tuple: `{ field, direction: 'desc' }` used to parse to `{ field, order: + * 'asc' }` — a silently REVERSED sort, which is the reason closing this entry + * was worth doing at all. * - * The mechanism is worth stating, because it governs every nested block in - * this file and is NOT what the union's comment implies: `ViewMetadataSchema` - * rescues Studio's round-trip keys with `.strip()` on its flattened members — - * 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. + * What made the first attempt a regression: `view-metadata-schema.test.ts` + * pins `sort: [{ id, field, order }]` as *"the exact shape + * normalizeViewMetadata persists on a console column-sort PUT"*, and `id` is + * a UI row identity objectui stamps per row + * (`components/src/custom/sort-builder.tsx:68`, `:94` — + * `crypto.randomUUID()`), persisted verbatim because `saveMetaItem` stores the + * original body. **`.strip()` on a wire member could not rescue it** — it + * re-opens the TOP level only, and this is a nested block reached through that + * member. See {@link stripViewConsoleDecorations}, which removes the + * decoration on the wire door instead, at every depth. * - * `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 one. The real end state is the same - * authoring/wire split filed as #5074, applied one level down — until then - * this shape stays open rather than half-closed against the platform's own - * writes. + * `id` is still NOT declared: it is a React list key, and declaring it would + * put a UI artifact on the authorable surface and teach an AI author to emit + * one (批 18 Q1, two-axis rejection on record). */ sort: z.union([ z.string(), //Legacy "field desc" - z.array(z.object({ + z.array(strictObject({ + surface: 'this sort entry', + history: VIEW_HISTORY, + aliases: { + // #4721: the same tuple under a different word. Edit distance cannot + // reach it, and getting it wrong reverses the sort silently. + direction: 'order', + }, + guidance: { + id: VIEW_CONSOLE_ROW_ID_GUIDANCE, + }, + }, { field: z.string(), order: z.enum(['asc', 'desc']) })) @@ -1945,50 +2072,116 @@ function viewItemBaseShape() { * ``` */ /** - * [#4001 批 18] Both arms stay STRIP — measured `wire`, not unfinished work. + * One arm of the ViewItem discriminated union, as a raw shape. * - * This shape looks purely authorable ({@link defineViewItem} parses it, and - * objectui's create form validates its build output against it), which is why - * the ledger carried it as `authorable (p)`. It is also the FIRST member of - * {@link ViewMetadataSchema}, the schema `saveMetaItem` validates every - * persisted `view` body against — and that second role is a wire role. + * Both postures below are built from THIS function, so the authoring gate and + * the wire member cannot drift into two transcriptions of one contract (#2231's + * derive-by-reference; the fork PD#12 exists to prevent). The two differ in + * exactly two ways, both visible at the call site: the unknown-key posture, and + * the round-trip keys the wire arm additionally declares. + */ +function viewItemArmShape(viewKind: K, config: z.ZodTypeAny) { + return { + viewKind: z.literal(viewKind), + config, + ...viewItemBaseShape(), + }; +} + +/** The authoring surface `defineViewItem` and Studio's create form are judged by. */ +const VIEW_ITEM_SURFACE = { + surface: 'this view item', + history: VIEW_HISTORY, + guidance: { + // The failure this close exists for (#5074): one letter, and the author got + // a ViewItem with NO view configuration that parsed clean. + confg: 'Did you mean `config`? A ViewItem carries its whole view definition under `config`.', + // Wire keys, named so the rejection tells an author where they belong + // instead of leaving them to guess. + isPinned: 'Pinning is per-user Studio state, not authored metadata — the console writes it through the `view` metadata API. Remove it from authored metadata.', + sortOrder: 'Switcher position is per-user Studio state, not authored metadata — use `order` for the authored default. Remove it from authored metadata.', + }, +} as const; + +/** + * [#5074] The AUTHORING gate — strict. Split from the wire member it used to be. * - * Traced end to end rather than inferred. objectui's "pin this view" control - * calls `dataSource.updateView(object, id, { isPinned })` + * Until #5074 one schema wore two contracts. It is what {@link defineViewItem} + * parses and what objectui's view-create form validates `createBuildBody`'s + * output against (`app-shell/src/views/metadata-admin/view-create-body.test.ts`) + * — an authoring door that genuinely wants strict. It was ALSO the first member + * of {@link ViewMetadataSchema}, the union `saveMetaItem` validates every + * persisted `view` body against — a wire role that genuinely needs Studio's + * round-trip keys through. + * + * Traced end to end rather than inferred, and the trace is why the split was + * necessary rather than cosmetic. objectui's "pin this view" control calls + * `dataSource.updateView(object, id, { isPinned })` * (`app-shell/src/views/ObjectView.tsx:882`), and `updateView` * (`data-objectstack/src/index.ts:2801`) GETs the stored item and PUTs * `{ ...current, ...partial }`. For a standalone ViewItem record `current` - * carries `viewKind` AND `config`, so the merged body matches THIS member — - * the flattened-overlay members are excluded by their `config: z.undefined()` - * guard — and it arrives carrying `isPinned`, which this shape does not - * declare. Today it is stripped from the discarded parse result and the save - * succeeds. Closed, pinning a saved view would 422. - * - * That is the same "auxiliary Studio round-trip keys ride along" contract the - * two flattened members are explicitly `.strip()` for, reaching one member - * further than the block comment below realised. It is finding 16's - * `.extend()`/union trap in its most expensive form: the strictness of a union - * member is decided by a consumer none of this file's authoring doors mention. - * - * ⚠️ Closing this needs a DESIGN decision, not a posture flip: the authoring - * door (`defineViewItem`, Studio's create form) genuinely wants strict, and the - * metadata door genuinely needs the aux keys through. Splitting them — a strict - * authoring schema plus a `.strip()`-reopened wire member, exactly how - * `ListViewSchema` / `FormViewSchema` are already handled below — is one shape; - * leaving one lenient schema is another. Filed rather than guessed. + * carries `viewKind` AND `config`, so the merged body matches member 1 — the + * flattened-overlay members are excluded by their `config: z.undefined()` guard + * — and it arrives carrying `isPinned`, which the authoring shape does not + * declare. That body is now judged by {@link ViewItemWireSchema} instead. + * + * What closing this buys, in the words of the ruling that ordered it: + * `defineViewItem({ name, object, viewKind, confg: {…} })` — one letter wrong — + * used to strip the typo and hand back a ViewItem with **no view configuration + * at all**, parsed clean. That is #1535's `workflows: [...]` replayed on the + * surface with the highest author density in the file. */ export const ViewItemSchema = lazySchema(() => + z.discriminatedUnion('viewKind', [ + strictObject(VIEW_ITEM_SURFACE, viewItemArmShape('list', ListViewSchema.describe('List-family view configuration.'))), + strictObject(VIEW_ITEM_SURFACE, viewItemArmShape('form', FormViewSchema.describe('Form view configuration.'))), + ]), +); + +/** + * Auxiliary Studio round-trip keys, given an explicit DECLARED home on the wire + * variant (#5074) instead of living implicitly on "the member nobody closed". + * + * These are per-user switcher state the console writes through the `view` + * metadata API and reads back; `saveMetaItem` persists the body verbatim, so + * they are on the wire and in the store. They are deliberately declared HERE and + * not on {@link ViewItemSchema}: an author who writes `isPinned` in a `*.view.ts` + * gets a named rejection pointing at `order`, while the console's own PUT parses. + */ +function viewItemWireFields() { + return { + isPinned: z.boolean().optional() + .describe('Studio round-trip: view pinned in the switcher (per-user state, written by the console — not authored).'), + sortOrder: z.number().int().optional() + .describe('Studio round-trip: position within the switcher (per-user state, written by the console — not authored).'), + }; +} + +/** + * [#5074] The WIRE variant of {@link ViewItemSchema} — member 1 of + * {@link ViewMetadataSchema}, re-opened with `.strip()`. + * + * Exactly the pattern the two flattened members below already use + * (`ListViewSchema.extend(flattenedViewOverlayFields()).strip()`), reached one + * member further. `z.discriminatedUnion` cannot be `.extend()`ed, so the two + * postures share {@link viewItemArmShape} rather than a `.extend()` chain — + * derive-by-reference either way, one shape, two doors. + * + * `.strip()` covers the TOP level only. Nested console decorations + * (`config.filter[].id`, `config.sort[].id`) are handled by + * {@link stripViewConsoleDecorations} on the wire door — see that function for + * why a recursive strip is the piece a posture flip cannot provide. + */ +export const ViewItemWireSchema = lazySchema(() => z.discriminatedUnion('viewKind', [ z.object({ - viewKind: z.literal('list'), - config: ListViewSchema.describe('List-family view configuration.'), - ...viewItemBaseShape(), - }), + ...viewItemArmShape('list', ListViewSchema.describe('List-family view configuration.')), + ...viewItemWireFields(), + }).strip(), z.object({ - viewKind: z.literal('form'), - config: FormViewSchema.describe('Form view configuration.'), - ...viewItemBaseShape(), - }), + ...viewItemArmShape('form', FormViewSchema.describe('Form view configuration.')), + ...viewItemWireFields(), + }).strip(), ]), ); @@ -2044,20 +2237,30 @@ export function defineViewItem(config: z.input): ViewItem // Auxiliary Studio round-trip keys (`isPinned`, `sortOrder`, …) ride along on // the shapes Studio actually round-trips, matching the "persist the payload // verbatim" contract in `saveMetaItem` (it validates but stores the original -// item). ⚠️ [#4001 批 18] The line that used to stand here said "all four -// members strip-parse (no `.strict()`)". That was true when it was written and -// is now false in one direction and load-bearing in the other — measured: +// item). ⚠️ [#5074] The line that used to stand here said "all four members +// strip-parse (no `.strict()`)". It was true when written, then half-false and +// half-load-bearing (批 18 measured it), and is now replaced by the split. As +// it stands, measured: // +// • member 1 is `ViewItemWireSchema` — the `.strip()` WIRE variant of +// `ViewItemSchema`. The authoring schema of the same shape is strict and +// lives at its own name; this is the body `updateView` PUTs for a +// standalone ViewItem record, and `isPinned`/`sortOrder` are DECLARED on it +// rather than surviving because nobody closed the member. // • member 2 (the container) IS strict. `ViewSchema` was closed by an earlier // batch, so `{ list: …, isPinned: true }` 422s. Not a regression: nothing // sends it. `updateView` unwraps a container to its inner list config // (`if (current?.list) current = current.list`) before merging, so a // container body never reaches this union carrying an aux key. -// • members 1, 3 and 4 must keep stripping, and only 3 and 4 say so in code. -// Member 1 is the one `updateView` hits for a standalone ViewItem record — -// see the note on `ViewItemSchema`. +// • members 3 and 4 are the flattened overlays, `.strip()` and saying so. // -// Anyone closing a member here must re-run that trace, not re-read this comment. +// ⚠️ All three `.strip()`s re-open the TOP level ONLY — `.strip()` no more +// recurses than `.strict()` does. The nested console decorations +// (`filter[].id`, `sort[].id`) are removed by `stripViewConsoleDecorations` +// BEFORE the union runs, which is what makes the wire opening +// recursive-effective and what let `ViewFilterRuleSchema` / `ListView.sort` +// close for authoring. Anyone closing a member here must re-run that trace, +// not re-read this comment. /** * Optional identity + structural-guard fields layered onto the two "flattened @@ -2114,13 +2317,23 @@ function containerHasAView(v: unknown): boolean { * instead of stripping them to `{}` (#3095). * * `z.toJSONSchema()` emits this as an `anyOf` of the four members, which the - * `/api/v1/meta/types/view` endpoint serves to Studio's SchemaForm. + * `/api/v1/meta/types/view` endpoint serves to Studio's SchemaForm. That still + * holds through the #5074 `z.preprocess` wrapper — a pipe converts to its + * output side, so the top level is still an `anyOf` of four. Pinned in + * `view-metadata-schema.test.ts` and `view-authoring-wire-split.test.ts`, + * because the endpoint is what feeds every generated view form. + * + * [#5074] The `z.preprocess` is the WIRE door's decoration strip — see + * {@link stripViewConsoleDecorations}. It runs once, ahead of every member, so + * the openness this union needs reaches nested blocks that a member-level + * `.strip()` can never reach. */ export const ViewMetadataSchema = lazySchema(() => - z.union([ - // 2. Standalone ViewItem record — nested config validated genuinely. - ViewItemSchema, - // 1. Non-empty defineView container. + z.preprocess(stripViewConsoleDecorations, z.union([ + // 1. Standalone ViewItem record — nested config validated genuinely, and + // the WIRE variant, so Studio's round-trip keys have a declared home. + ViewItemWireSchema, + // 2. Non-empty defineView container. ViewSchema.refine(containerHasAView, { message: 'A view container must define at least one of `list`, `form`, `listViews`, or `formViews`.', @@ -2139,7 +2352,7 @@ export const ViewMetadataSchema = lazySchema(() => // schema must strip back, or an upstream field addition becomes a crash. ListViewSchema.extend(flattenedViewOverlayFields()).strip(), FormViewSchema.extend(flattenedViewOverlayFields()).strip(), - ]), + ])), ); // ─────────────────────────────────────────────────────────────────────────── @@ -2408,6 +2621,8 @@ export function defineForm( export type View = z.infer; export type ViewItem = z.infer; +/** A ViewItem record as it travels the WIRE — the authoring shape plus Studio's round-trip keys (#5074). */ +export type ViewItemWire = z.infer; /** Any persisted `view` metadata body: container | ViewItem record | flattened overlay (#3095). */ export type ViewMetadata = z.infer; export type ViewScope = z.infer;