From 3bdab880214670bf6ca242d6fb14797d7c1de66c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 11:38:09 +0000 Subject: [PATCH 1/3] feat(spec)!: promote `userFilters.allowAddTab`, then close UserFiltersSchema (#5073) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `UserFiltersSchema` was the last shape #4001 批 18 left open in `ui/view.zod.ts` whose blocker was a capability question rather than a strictness one. objectui's renderer reads `config.allowAddTab` and renders an add-tab affordance from it (`plugin-list/src/UserFilters.tsx:182`/`:742`), and its own schema declared the key — the difference between the two shapes was exactly that one key. Because `saveMetaItem` validates with `safeParse` but persists the ORIGINAL body, the stripped key still reached the renderer, so the capability worked. Closing without declaring it would have 422'd a shipped config with a message naming a key the author was right to write (finding 7), not converted a silent failure into a loud one. Maintainer adjudication (2026-08-04) chose promote-then-close in one PR: - `allowAddTab` is DECLARED, so the capability is discoverable from the contract (JSON Schema / Studio SchemaForm / AI authors) instead of one React file. The `.describe()` is scoped to what the renderer really does — it renders the affordance; the button carries no click handler today, filed separately, and promising more would be PD#10. - `UserFiltersSchema` closes behind it, in the same change, so there is no state where the key is declared but its neighbours still vanish silently. Closing flips `ObjectUserFiltersSchema` (`.omit()`ed off the base, and `.omit()` inherits posture) from dropping the page-only keys to rejecting them — 批 6e's question, and the wanted answer: the CLI lint already reported them, so the two doors now agree. But `.omit()` also inherits the base's error map, whose `knownKeys` 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. The object variant now builds its map over the OMITTED shape (shape still derived by `.omit()`, so #2231 holds) with `guidance` pointing all three page-only keys at `listViews`. Ledger: view.zod.ts Class 6 -> 5 authorable; counts regenerated via `gen:strictness-ledger` (ui/ strict 117 -> 119, strip 76 -> 75, sites 50 -> 51). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9 --- ...filters-allow-add-tab-promote-and-close.md | 41 ++++++ content/docs/references/ui/page.mdx | 2 +- content/docs/references/ui/view.mdx | 3 +- ...07-unknown-key-strictness-ledger.counts.md | 22 +-- .../2026-07-unknown-key-strictness-ledger.md | 2 +- packages/spec/authorable-surface.json | 1 + .../spec/scripts/strictness-ledger.test.ts | 7 +- packages/spec/src/ui/object-list-view.test.ts | 35 ++++- .../src/ui/view-strictness-batch18.test.ts | 124 +++++++++++----- packages/spec/src/ui/view.test.ts | 13 ++ packages/spec/src/ui/view.zod.ts | 134 ++++++++++++------ 11 files changed, 284 insertions(+), 100 deletions(-) create mode 100644 .changeset/user-filters-allow-add-tab-promote-and-close.md diff --git a/.changeset/user-filters-allow-add-tab-promote-and-close.md b/.changeset/user-filters-allow-add-tab-promote-and-close.md new file mode 100644 index 0000000000..eba6bb6143 --- /dev/null +++ b/.changeset/user-filters-allow-add-tab-promote-and-close.md @@ -0,0 +1,41 @@ +--- +'@objectstack/spec': major +--- + +spec(ui): `userFilters.allowAddTab` 提升进契约,`UserFiltersSchema` 随之收紧 (#5073) + +`UserFiltersSchema` 是 #4001 批 18 在 `ui/view.zod.ts` 留下的最后一块「不是 strictness 问题」的开放形状 —— 挡住它的是一条**能力声明**,不是姿态判断。 + +**为什么之前不能直接关。** objectui 的列表渲染器真读 `config.allowAddTab` 并据此渲染「新增 tab」控件(`plugin-list/src/UserFilters.tsx:182` / `:742`),它自己的 `UserFiltersSchema` 也声明了这个键 —— 两边形状的差集恰好只有这一个。而 `saveMetaItem` 用 `safeParse` 校验后**原样存原始 body**(丢弃 `parsed.data`,好让 Studio 的辅助键活过往返),所以被 strip 掉的只是那份被丢弃的解析结果:存储里键还在,渲染器读得到,**这个能力今天是工作的**。直接收紧不是「把静默失效变响亮」,而是把一个已发布、在用的配置变成 422 —— 而且 422 会点名一个作者本来写对了的键,正是本战役 finding 7 的形状(平台权威把作者引向删掉能工作的东西)。 + +**裁定与落地(维护者 2026-08-04,选项 A):promote 后收紧,同 PR 完成。** `allowAddTab` 现在**声明**在 `UserFiltersSchema` 上,能力因此可从契约被发现 —— JSON Schema、Studio 的 SchemaForm、AI 作者都看得到,而不是只存在于一个 React 文件里。被否决的是判它为 objectui-only 扩展(`SANCTIONED_LOCAL`):那会让 `packages/spec` 与 objectui 成为同一份契约的两个事实来源,正是 #2231 的 derive-by-reference 统一要消掉的分叉(PD#12)。声明的措辞刻意收窄到渲染器真做的事 —— 它声明「渲染出新增 tab 的入口」,不承诺点击后能创建预设(objectui 那个按钮目前没有 click handler,已另行立案),因为承诺更多就是 PD#10 的「宣传运行时并不交付的能力」。 + +## BREAKING + +**1. `userFilters` 上的未知键从静默丢弃变为拒绝。** + +```diff + userFilters: { + element: 'tabs', +- allowAddTabs: true, // 拼错 → 以前静默消失,现在 422(并提示 → allowAddTab) ++ allowAddTab: true, + } +``` + +FROM → TO:未声明的键 → 删除它,或改成它想表达的那个已声明键。错误信息会点名该键并给出最近的候选。`allowAddTab` 本身**不需要迁移** —— 它现在是合法声明键,原有配置照常通过。 + +**2. 对象列表视图(`ObjectUserFiltersSchema`)拒绝 page-only 的三个键:`tabs` / `showAllRecords` / `allowAddTab`。** + +这三个键在对象视图上一直是无效的(`ObjectUserFiltersSchema` 由 `UserFiltersSchema.omit()` 派生,而 `.omit()` 继承基类姿态),此前被静默丢弃 —— 与此同时 CLI lint(`packages/lint/src/validate-list-view-mode.ts`)早就在报同一个配置。两扇门从此一致。 + +```diff + // 对象视图:tab 栏的角色已被 ViewTabBar(已存视图切换器)占用 + listViews: { +- // userFilters: { element: 'dropdown', tabs: [{ name: 'mine', label: '我的' }] } ++ mine: { label: '我的', filter: [['owner', '=', '{userId}']] }, // 每个具名视图渲染成一个分段 tab + } +``` + +FROM → TO:`userFilters.tabs` → 对象的 `listViews` 具名条目;`showAllRecords` → 默认列表视图本身就是「全部记录」入口;`allowAddTab` → 由 ViewTabBar 自带的新增控件承担。三条拒绝各自带 `guidance` 处方,不是裸的 "unrecognized key"。 + +派生变体同时改为携带**自己的**错误映射:`.omit()` 会连基类的 `knownKeys` 一起继承,而那份候选列表是从基类形状读的、仍然含被 omit 掉的键 —— 实测在对象视图上写 `tab` 会被答复 *"Did you mean `tab` → `tabs`?"*,把作者指向这个形状唯一拒绝的键。形状仍由 `.omit()` 派生(#2231 不变),候选池改为按 omit 后的形状构建。 diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index 667249793d..ed5cfe4595 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -55,7 +55,7 @@ Interface-level page configuration (Airtable parity) | **levels** | `integer` | optional | Number of hierarchy levels to display | | **sourceView** | `string` | optional | @deprecated Legacy named-view inheritance. Define columns/sort/filterBy on the page instead. | | **appearance** | `{ showDescription: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | -| **userFilters** | `{ element: Enum<'dropdown' \| 'tabs' \| 'toggle'>; fields?: { field: string; label?: string; type?: Enum<'select' \| 'multi-select' \| 'boolean' \| 'date-range' \| 'text'>; options?: { value: string \| number \| boolean; label: string; color?: string }[]; … }[]; tabs?: { name: string; label?: string; icon?: string; view?: string; … }[]; showAllRecords?: boolean }` | optional | End-user quick-filter bar for this page (overrides the source view's userFilters) | +| **userFilters** | `{ element: Enum<'dropdown' \| 'tabs' \| 'toggle'>; fields?: { field: string; label?: string; type?: Enum<'select' \| 'multi-select' \| 'boolean' \| 'date-range' \| 'text'>; options?: { value: string \| number \| boolean; label: string; color?: string }[]; … }[]; tabs?: { name: string; label?: string; icon?: string; view?: string; … }[]; showAllRecords?: boolean; … }` | optional | End-user quick-filter bar for this page (overrides the source view's userFilters) | | **userActions** | `{ sort: boolean; search: boolean; filter: boolean; refresh: boolean; … }` | optional | User action toggles | | **addRecord** | `{ enabled: boolean; position: Enum<'top' \| 'bottom' \| 'both'>; mode: Enum<'inline' \| 'form' \| 'modal'>; formView?: string }` | optional | Add record entry point configuration | | **buttons** | `string[]` | optional | Toolbar buttons — names of the source object's actions to surface in the page toolbar | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index eaf3724bd9..2f6da23083 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -375,7 +375,7 @@ List chart view configuration | **sort** | `string \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | -| **userFilters** | `{ element?: Enum<'dropdown' \| 'tabs' \| 'toggle'>; fields?: { field: string; label?: string; type?: Enum<'select' \| 'multi-select' \| 'boolean' \| 'date-range' \| 'text'>; options?: { value: string \| number \| boolean; label: string; color?: string }[]; … }[]; tabs?: { name: string; label?: string; icon?: string; view?: string; … }[]; showAllRecords?: boolean }` | optional | End-user quick-filter bar: dropdown/toggle fields or tab presets. Omit to let the renderer derive filters from select/boolean fields | +| **userFilters** | `{ element?: Enum<'dropdown' \| 'tabs' \| 'toggle'>; fields?: { field: string; label?: string; type?: Enum<'select' \| 'multi-select' \| 'boolean' \| 'date-range' \| 'text'>; options?: { value: string \| number \| boolean; label: string; color?: string }[]; … }[]; tabs?: { name: string; label?: string; icon?: string; view?: string; … }[]; showAllRecords?: boolean; … }` | optional | End-user quick-filter bar: dropdown/toggle fields or tab presets. Omit to let the renderer derive filters from select/boolean fields | | **resizable** | `boolean` | optional | Enable column resizing | | **striped** | `boolean` | optional | Striped row styling | | **bordered** | `boolean` | optional | Show borders | @@ -653,6 +653,7 @@ End-user quick-filter configuration (Airtable "User filters" parity) | **fields** | `{ field: string; label?: string; type?: Enum<'select' \| 'multi-select' \| 'boolean' \| 'date-range' \| 'text'>; options?: { value: string \| number \| boolean; label: string; color?: string }[]; … }[]` | optional | Fields exposed as quick filters (dropdown/toggle elements) | | **tabs** | `{ name: string; label?: string; icon?: string; view?: string; … }[]` | optional | Named filter presets rendered as tabs (tabs element). Reuses ViewTabSchema | | **showAllRecords** | `boolean` | optional | Show an "All records" tab before the presets (tabs element) | +| **allowAddTab** | `boolean` | optional | Render an "add tab" affordance after the presets (tabs element). Page lists only — object views use `listViews` for named presets | --- 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 287264c243..a3080baf21 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -21,15 +21,15 @@ regenerate. | Measure | Value | |---|---| | Triaged directories | 5 | -| Object sites in them | 482 | -| Still-open (strip) sites | 235 | +| Object sites in them | 483 | +| Still-open (strip) sites | 234 | | Files carrying at least one | 36 | Remaining strip sites by class: | Bucket | Sites | |---|---| -| authorable — the ruling's forced scope | 29 | +| authorable — the ruling's forced scope | 28 | | unresolved — needs a per-schema verdict | 33 | | wire / open — out of forced scope | 104 | | no door — no carrier, ADR-0049 territory | 38 | @@ -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/` | 198 | 117 | 5 | 0 | 76 | +| `ui/` | 199 | 119 | 5 | 0 | 75 | | `data/` | 162 | 41 | 1 | 0 | 120 | | `automation/` | 75 | 49 | 0 | 0 | 26 | | `security/` | 20 | 7 | 0 | 0 | 13 | | `studio/` | 27 | 27 | 0 | 0 | 0 | -| **total** | **482** | **241** | **6** | **0** | **235** | +| **total** | **483** | **243** | **6** | **0** | **234** | ## File-level triage — site counts @@ -79,9 +79,9 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `sharing.zod.ts` | 2 | | `theme.zod.ts` | 14 | | `touch.zod.ts` | 7 | -| `view.zod.ts` | 50 | +| `view.zod.ts` | 51 | | `widget.zod.ts` | 9 | -| **total** | **198** | +| **total** | **199** | ### `data/` — sites @@ -161,7 +161,7 @@ over it is here. ### `ui/` — open -**76 strip of 198**, in 13 file(s). +**75 strip of 199**, in 13 file(s). | File | Strip | Sites | |---|---|---| @@ -176,13 +176,13 @@ over it is here. | `offline.zod.ts` | 3 | 3 | | `sharing.zod.ts` | 1 | 2 | | `touch.zod.ts` | 7 | 7 | -| `view.zod.ts` | 6 | 50 | +| `view.zod.ts` | 5 | 51 | | `widget.zod.ts` | 9 | 9 | -| **total** | **76** | **198** | +| **total** | **75** | **199** | | Bucket | Sites | |---|---| -| authorable — the ruling's forced scope | 7 | +| authorable — the ruling's forced scope | 6 | | unresolved — needs a per-schema verdict | 0 | | wire / open — out of forced scope | 0 | | no door — no carrier, ADR-0049 territory | 38 | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index 58b7a815bd..dd95b8132b 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -817,7 +817,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 · 6 authorable | **15 of 20 closed at #4001 批 18**, and a sixteenth — `ViewFilterRuleSchema`, closed by an EARLIER wave — reopened at **#5114**; the 6 that remain are each measured, and none is unfinished work. Closed: `ViewDataSchema`'s four provider arms, `UserFilterField.options`, `GanttQuickFilter.options`, `GanttConfig.tooltipFields`, `ListView.conditionalFormatting` / `.emptyState`, `FormFieldBase.keyField`, `FormView.subforms`, and `submitBehavior`'s four arms. Reachability was measured, not assumed: a BFS from all 24 metadata-type roots plus `ObjectStackSchema` resolves every one `root-graph`, with `ViewSchema`/`FormViewSchema`/`ViewItemSchema`/`PageSchema` as positive controls and 批 13's no-door shapes UNREACHABLE **in the same run** — and the instrument had to be fixed first: `lazySchema` returns a Proxy, but a carrier writes `X.optional()`, which RESOLVES it, so the closure holds the real instance and comparing the Proxy alone false-negatived `ViewDataSchema` (caught by cross-checking its two literal carrier keys, not by trusting the reading). ⚠️ **Re-checked against #5056**: every 批 18 target is `root-graph` by **identity**, so **none** of the fifteen rests on the `derived-clone` bridge that 批 16 found can mark a dead shape reachable. The one `derived-clone` verdict in the run is `ListViewSchema` — a positive CONTROL, not a target, and independently identity-reachable via `ObjectListViewSchema`. Every closed shape also has a literal carrier key in this file and a named parse door (`defineView` / `defineViewItem` / the `view` metadata-type schema / objectui's `GanttConfigSchema.safeParse` at `plugin-gantt/src/ObjectGantt.tsx:408`) — the strong-evidence class #5056 leaves standing. ⚠️ **`ListView.sort` was closed and then REVERTED, and that is the batch's most useful finding.** It carried `direction → order`, the #4721 alias for the identical tuple (`{field, direction:'desc'}` parsed to `{field, order:'asc'}` — a silently REVERSED sort). The full suite then failed one case: `view-metadata-schema.test.ts` pins `sort: [{ id, field, order }]` as the exact body a console column-sort PUT persists, and objectui stamps that `id` per row (`components/src/custom/sort-builder.tsx:68`/`:94`, `crypto.randomUUID()`). **The mechanism governs every nested block in this file and is the opposite of what the union's own comment implies: `.strip()` does NOT recurse.** `ViewMetadataSchema` rescues Studio's round-trip keys by making its flattened members `.strip()`, but that re-opens the TOP level only — a nested block closed inside `ListViewSchema` is still reached through that member, so a console-stamped key inside it becomes a 422 regardless. `id` was deliberately NOT declared to silence it: it is a React list key, and declaring it would put a UI artifact on the authorable surface and tell an AI author to emit one. The end state is #5074's authoring/wire split applied one level down; until then the shape stays open rather than half-closed against the platform's own writes. Curation on what DID close is anchored to named siblings: an option `count` gets a wrong-layer pointer to `showCount` because objectui COMPUTES it per render; and a bare `name` on the `object` data source is deliberately NOT aliased — it is a real key on the view ITEM, so a rename would be finding 7 again. `submitBehavior` became a `discriminatedUnion` on the `kind` literal it already required: as a plain union of four strict members the rejection is an `invalid_union` whose prescription #5014 measured the renderers flattening away. ⚠️ **`GanttConfigSchema` / `TreeConfigSchema` are `strictObject(…).passthrough()`** — open at the parent by design, and this ledger's own counter 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. **Still open, all six measured:** `UserFiltersSchema` — closing it would 422 `allowAddTab`, which objectui's renderer reads (`plugin-list/src/UserFilters.tsx:182`/`:742`) and the spec never declared; `saveMetaItem` validates but persists the ORIGINAL body, so the stripped key still reaches the renderer and the capability WORKS today — closing removes a capability rather than making a silent failure loud (**#5073**). The 批 6e reliance question IS answered: `ObjectUserFiltersSchema` is `.omit()`ed off this base and `.omit()` inherits posture, so the pin flips from "drops" to "rejects" — that flip is wanted, and gated only on `allowAddTab`. `ViewItemSchema` ×2 — **wire, not authorable**: objectui's pin control PUTs `{...storedItem, isPinned}` (`ObjectView.tsx:882` → `data-objectstack/src/index.ts:2801`); a stored ViewItem record carries `viewKind` AND `config`, so it lands on THIS member (the flattened members are excluded by their `config: z.undefined()` guard) and closing it would 422 pinning a saved view (**#5074**). `FormFieldBaseSchema` — a module-private BASE whose sole consumer already applies `.strict()` plus the ADR-0089 `strictVisibilityError` map; the door is closed, the ledger counts the base. `ListView.sort` — reverted, see above. `ViewFilterRuleSchema` — **the same wire contamination, one block over, and it was already LIVE on `main`** (#5114): closed by an earlier wave, while objectui's filter builder stamps `id: crypto.randomUUID()` on every row it writes (`components/src/custom/filter-builder.tsx:228`, re-stamped on read-back at `plugin-view/src/config/view-config-utils.ts:146`/`:160`), and `saveMetaItem` persists the AUTHORED body verbatim — so saving a filter from the console 422'd, on all three paths including the flattened overlay that is the body actually PUT. Reopened as a p1 hotfix; `id` deliberately NOT declared, for the reason given for `sort` above. Two details worth keeping: the overlay path's rejection surfaces as `invalid_union` / *"Invalid input"* — the #5014 flattening, so the key that caused it is not in the message the author sees, which is why this sat on `main` unnoticed; and the reopening was verified in BOTH directions (re-close it and 7 assertions in `view-filter-rule-wire-id.test.ts` go red, while that file's two mechanism CONTROLS — top-level aux key rides, nested `emptyState` still rejects — stay green either way, which is what makes them controls). #5074's scope addendum names this site: its wire variant must re-open RECURSIVELY, and re-closing here is gated on that. Each verdict is recorded in three places (schema JSDoc + `view-strictness-batch18.test.ts` / `view-filter-rule-wire-id.test.ts` + this row) | +| `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 separately. **(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) | | `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 | | `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/authorable-surface.json b/packages/spec/authorable-surface.json index 37111d2e1a..9704769a78 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -7958,6 +7958,7 @@ "ui/UserFilterField:options", "ui/UserFilterField:showCount", "ui/UserFilterField:type", + "ui/UserFilters:allowAddTab", "ui/UserFilters:element", "ui/UserFilters:fields", "ui/UserFilters:showAllRecords", diff --git a/packages/spec/scripts/strictness-ledger.test.ts b/packages/spec/scripts/strictness-ledger.test.ts index 94ee282de0..983c3ae756 100644 --- a/packages/spec/scripts/strictness-ledger.test.ts +++ b/packages/spec/scripts/strictness-ledger.test.ts @@ -226,7 +226,12 @@ describe('posture reading, with a red control for each', () => { // And the claim the ledger rests on: no STRIP site moved. Both postures // involved are non-strip, so the remaining-strip map — the thing every batch // is planned against — is untouched by this fix. - expect(countStripSites(at('ui/view.zod.ts'))).toBe(6); + // + // 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); }); it('lets a chained posture override the idiom in either direction (#5072)', () => { diff --git a/packages/spec/src/ui/object-list-view.test.ts b/packages/spec/src/ui/object-list-view.test.ts index e835c25480..00bd5d325c 100644 --- a/packages/spec/src/ui/object-list-view.test.ts +++ b/packages/spec/src/ui/object-list-view.test.ts @@ -23,15 +23,36 @@ describe('ObjectListViewSchema (ADR-0047 "views" mode)', () => { expect((parsed as { userFilters?: unknown }).userFilters).toMatchObject(uf); }); - it('drops the page-only tabs/showAllRecords keys from a dropdown userFilters', () => { - const parsed = ObjectListViewSchema.parse({ + it('REJECTS the page-only tabs/showAllRecords/allowAddTab keys on a dropdown userFilters', () => { + // Flipped from "drops" at #5073, and the flip is the point: until then the + // schema silently discarded these while the CLI lint + // (`packages/lint/src/validate-list-view-mode.ts`) reported them — two + // doors disagreeing about the same config. They now agree. + const r = ObjectListViewSchema.safeParse({ ...base, - userFilters: { element: 'dropdown', tabs: [{ name: 'mine', label: 'Mine', filter: [] }], showAllRecords: true }, + userFilters: { + element: 'dropdown', + tabs: [{ name: 'mine', label: 'Mine', filter: [] }], + showAllRecords: true, + allowAddTab: true, + }, } as never); - const parsedUf = (parsed as { userFilters?: Record }).userFilters!; - expect(parsedUf).not.toHaveProperty('tabs'); - expect(parsedUf).not.toHaveProperty('showAllRecords'); - expect(parsedUf.element).toBe('dropdown'); + expect(r.success).toBe(false); + const msg = JSON.stringify(r.error?.issues ?? []); + expect(msg).toContain('tabs'); + expect(msg).toContain('showAllRecords'); + expect(msg).toContain('allowAddTab'); + }); + + it('…and the rejection prescribes `listViews`, the thing an object view actually uses', () => { + // A page-only key has a right answer on an object view, so a bare + // "unrecognized key" would be a correct refusal that still leaves the + // author guessing — the failure mode #5073 was filed to avoid. + const r = ObjectListViewSchema.safeParse({ + ...base, + userFilters: { element: 'dropdown', tabs: [{ name: 'mine', label: 'Mine', filter: [] }] }, + } as never); + expect(JSON.stringify(r.error?.issues ?? [])).toContain('listViews'); }); it('rejects a tabs-element userFilters (page-only, would collide with ViewTabBar)', () => { diff --git a/packages/spec/src/ui/view-strictness-batch18.test.ts b/packages/spec/src/ui/view-strictness-batch18.test.ts index 1e596cd5f5..b908ece9a1 100644 --- a/packages/spec/src/ui/view-strictness-batch18.test.ts +++ b/packages/spec/src/ui/view-strictness-batch18.test.ts @@ -5,11 +5,17 @@ * * The long tail of this file: the top level, the form/page shapes and the ~28 * config blocks under them were closed in earlier waves, leaving 20 object - * sites that still dropped unknown keys silently. 16 are closed here. The other - * FOUR 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. + * 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. + * + * ⚠️ 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. * * 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 @@ -28,8 +34,8 @@ * redundant. Where the bare edit-distance suggester already answers * correctly, no alias was added — and where it would answer WRONGLY, the * alias overrules it and this file says so. - * 4. The FOUR shapes left open, with the evidence that they are wire/base - * rather than unfinished. + * 4. The shapes left open, with the evidence that they are wire/base rather + * than unfinished. * 5. The real union ERROR BEHAVIOUR (#5014) — pinned honestly, including the * part that does not reach the author today. */ @@ -222,6 +228,84 @@ describe('#4001 批 18 — the rejection carries a usable prescription', () => { expect(reject(ListViewSchema, { ...LIST_BASE, emptyState: { action: {} } })).toContain('addRecord'); }); + describe('UserFiltersSchema — closed at #5073, after `allowAddTab` was promoted', () => { + // 批 18 left this shape open because closing it would have 422'd + // `allowAddTab`: objectui's renderer reads the key + // (`plugin-list/src/UserFilters.tsx:182` / `:742`), and `saveMetaItem` + // persists the ORIGINAL body rather than `parsed.data`, so the stripped key + // still reached the renderer and the capability WORKED. The maintainer + // adjudicated promote-then-close (2026-08-04), which is what these pin. + + it('`allowAddTab` is DECLARED, so the promoted capability survives the close', () => { + // The whole reason the close was blocked. If this ever goes red, the + // close has silently re-become the capability removal #5073 refused. + const parsed = accept(UserFiltersSchema, { element: 'tabs', allowAddTab: true }) as Record; + expect(parsed.allowAddTab).toBe(true); + expect(reject(ListViewSchema, { ...LIST_BASE, userFilters: { element: 'tabs', allowAddTab: 'yes' } })) + .toContain('allowAddTab'); + }); + + it('rides in through `ListView.userFilters` — the carrier, not just the shape standalone', () => { + const parsed = accept(ListViewSchema, { ...LIST_BASE, userFilters: { element: 'tabs', allowAddTab: true } }) as { + userFilters?: Record; + }; + expect(parsed.userFilters?.allowAddTab).toBe(true); + }); + + it('an unknown key is now REJECTED where it lives, not dropped', () => { + expect(reject(UserFiltersSchema, { element: 'dropdown', notAUserFilterKey: 1 })).toContain('notAUserFilterKey'); + expect(reject(ListViewSchema, { ...LIST_BASE, userFilters: { element: 'dropdown', notAUserFilterKey: 1 } })) + .toContain('notAUserFilterKey'); + }); + + it('the 批 6e strip-reliance flipped from "drops" to "rejects" — the wanted flip', () => { + // `ObjectUserFiltersSchema` is `.omit()`ed off this base and `.omit()` + // inherits posture, which is exactly what 批 6e asked about. Before the + // close these three keys were silently dropped while the CLI lint + // (`validate-list-view-mode.ts`) warned about them — two doors + // disagreeing. They now agree. + for (const pageOnly of [ + { tabs: [{ name: 'mine', label: 'Mine', filter: [] }] }, + { showAllRecords: true }, + { allowAddTab: true }, + ]) { + expect(reject(ObjectUserFiltersSchema, { element: 'dropdown', ...pageOnly })) + .toContain(Object.keys(pageOnly)[0]); + } + }); + + it('…and each rejection carries the `listViews` prescription, not a bare refusal', () => { + // The keys are page-only, so there IS a right answer on an object view; + // a bare "unrecognized key" would leave the author to guess it. + expect(reject(ObjectUserFiltersSchema, { element: 'dropdown', tabs: [] })).toContain('listViews'); + expect(reject(ObjectUserFiltersSchema, { element: 'dropdown', showAllRecords: true })).toContain('listViews'); + expect(reject(ObjectUserFiltersSchema, { element: 'dropdown', allowAddTab: true })).toContain('ViewTabBar'); + }); + + it('…and does NOT suggest an omitted key back — the finding-7 hazard the close created', () => { + // `.omit()` keeps the BASE's error map, whose `knownKeys` were read from + // the base shape — which still lists `tabs`. Measured before the fix: + // `tab` was answered with "Did you mean `tab` → `tabs`?", steering the + // author at the one key this surface refuses. The map is rebuilt over the + // omitted shape so the suggestion pool cannot contain them. + const msg = reject(ObjectUserFiltersSchema, { element: 'dropdown', tab: 1 }); + expect(msg).toContain('`tab`'); + expect(msg).not.toContain('→ `tabs`'); + expect(reject(ObjectUserFiltersSchema, { element: 'dropdown', showAllRecord: 1 })) + .not.toContain('→ `showAllRecords`'); + }); + + it('the object variant still DERIVES its shape from the base (#2231), not a transcription', () => { + // The reason it is `.omit()` + spread rather than a hand-listed shape: a + // key added to the base must appear here, or the two drift into the fork + // derive-by-reference exists to prevent. + const baseKeys = Object.keys((UserFiltersSchema as unknown as { shape: Record }).shape); + const objectKeys = Object.keys((ObjectUserFiltersSchema as unknown as { shape: Record }).shape); + expect(baseKeys).toEqual(['element', 'fields', 'tabs', 'showAllRecords', 'allowAddTab']); + expect(objectKeys).toEqual(['element', 'fields']); + }); + }); + it('`continue` / `next-record` explain they take no options instead of suggesting a key', () => { const msg = reject(FormViewSchema, { ...FORM_BASE, submitBehavior: { kind: 'continue', title: 'Thanks' } }); expect(msg).toContain('thank-you'); @@ -263,33 +347,9 @@ describe('#4001 批 18 — union error behaviour (#5014), pinned as it really is }); // =========================================================================== -// 5. The four shapes left OPEN — with the evidence, so nobody "finishes" them +// 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('UserFiltersSchema stays open: closing it would 422 `allowAddTab`, a LIVE capability', () => { - // objectui reads `config.allowAddTab` and renders an add-tab control from - // it (`plugin-list/src/UserFilters.tsx:182` / `:742`); the spec never - // declared the key. Because `saveMetaItem` validates but persists the - // ORIGINAL body, the key survives the strip and the feature WORKS today — - // so closing here removes a capability rather than making a silent failure - // loud. Blocked on the promote-or-reject decision for `allowAddTab`. - expect(UserFiltersSchema.safeParse({ element: 'dropdown', allowAddTab: true }).success).toBe(true); - }); - - it('…and the 批 6e question IS answered: the strip-reliance is real and named', () => { - // `ObjectUserFiltersSchema` is `UserFiltersSchema.omit({ tabs, - // showAllRecords })` and `.omit()` inherits the base's posture, so closing - // the base flips this from "drops" to "rejects". That flip is wanted (the - // CLI lint already reports it) — it is gated only on `allowAddTab`. - const parsed = accept(ObjectUserFiltersSchema, { - element: 'dropdown', - tabs: [{ name: 'mine', label: 'Mine', filter: [] }], - showAllRecords: true, - }) as Record; - expect(parsed).not.toHaveProperty('tabs'); - expect(parsed).not.toHaveProperty('showAllRecords'); - }); - 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 diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index 2bb211a80a..4f27c27f55 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -2340,6 +2340,19 @@ describe('UserFiltersSchema (ADR-0047)', () => { expect(() => UserFiltersSchema.parse({ element: 'sidebar' })).toThrow(); }); + it('should accept allowAddTab on the tabs element (#5073 — promoted from objectui)', () => { + const uf = UserFiltersSchema.parse({ + element: 'tabs', + allowAddTab: true, + tabs: [{ name: 'mine', label: 'Mine', filter: [] }], + }); + expect(uf.allowAddTab).toBe(true); + }); + + it('should reject an unknown key (#5073 — this shape is closed)', () => { + expect(() => UserFiltersSchema.parse({ element: 'dropdown', allowAddTabb: true })).toThrow(); + }); + it('should attach to ListViewSchema.userFilters', () => { const view = ListViewSchema.parse({ type: 'grid', diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 24ddef0d9f..f31297ea58 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -613,49 +613,64 @@ export const UserFilterFieldSchema = lazySchema(() => strictObject({ * @see Airtable Interface → "User filters" panel (Elements: tabs / dropdowns) */ /** - * Still STRIP after #4001 批 18 — the verification 批 6e asked for was DONE, - * and it turned up a blocker that is not a strictness question. + * CLOSED at **#5073** — the last shape #4001 批 18 left open in this file, and + * the one that needed a protocol decision before strictness could touch it. * - * ## What 批 6e asked, and the answer - * - * 批 6e left this open pending "does anything rely on the strip to NARROW a - * page-shaped block?". It does, and the relier is in this file: - * {@link ObjectUserFiltersSchema} is `UserFiltersSchema.omit({ tabs, - * showAllRecords })`, and `.omit()` inherits the base's posture. Closing this - * base therefore flips `object-list-view.test.ts`'s pin from "drops the - * page-only keys" to "rejects" them. That flip is CORRECT and wanted: the CLI - * lint (`packages/lint/src/validate-list-view-mode.ts`) already reports a - * `tabs`-carrying `userFilters` on an object view, so today the two doors - * disagree — the bespoke guard warns while the schema silently drops. Closing - * makes them agree. On its own this would have shipped in this batch. - * - * ## The blocker: `allowAddTab` is a live capability the spec never declared + * ## Why it could not just be closed (the 批 18 blocker) * * objectui's renderer reads `config.allowAddTab` and renders an "add tab" - * control from it (`packages/plugin-list/src/UserFilters.tsx:182` and `:742`); - * its own `UserFiltersSchema` declares the key. The spec's does not. + * affordance from it (`packages/plugin-list/src/UserFilters.tsx:182` and + * `:742`); its own `UserFiltersSchema` declared the key. The spec's did not — + * and the difference between the two shapes was *exactly* that one key. * - * That gap is not inert, because the metadata write path does NOT persist + * That gap was not inert, because the metadata write path does NOT persist * `parsed.data`: `saveMetaItem` validates with `safeParse` and then stores the * ORIGINAL body verbatim, precisely so Studio-only auxiliary keys survive * (`metadata-protocol/src/protocol.ts`, "Validation policy"). So an authored - * `allowAddTab` is stripped from the parse RESULT, which is discarded — and the - * stored document keeps it, and the renderer reads it. **The capability works - * today.** Closing this shape turns that working config into a 422. - * - * So closing here would not convert a silent failure into a loud one — the - * campaign's whole warrant. It would REMOVE a shipped capability. The fix is to - * decide whether `allowAddTab` is promoted into this schema (objectui's own - * drift guard, `types/src/__tests__/list-view-spec-parity.test.ts`, routes - * exactly this choice to a human: "promote it upstream, or add it to - * SANCTIONED_LOCAL with a rationale") or is rejected on purpose. That is an - * additive protocol decision, not a strictness one, and this campaign's rule - * after #5022 is that a capability question is filed, never guessed. - * - * ⚠️ Do NOT close this shape without resolving `allowAddTab` first — the - * rejection would name a key the author was right to write. - */ -export const UserFiltersSchema = lazySchema(() => z.object({ + * `allowAddTab` was stripped only from the parse RESULT, which is discarded — + * the stored document kept it and the renderer read it. **The capability + * worked.** Closing the shape without declaring the key would have converted a + * working, shipped config into a 422 whose message named a key the author was + * right to write — this campaign's finding 7 (the platform's own authority + * steering an author into deleting something that worked), not the silent-strip + * defect the campaign exists to kill. + * + * ## The resolution: promote, then close (maintainer adjudication, 2026-08-04) + * + * `allowAddTab` is DECLARED here (additive), so the capability is discoverable + * from the contract — JSON Schema, Studio's SchemaForm and an AI author all see + * it, instead of it living only in one React file. Then the shape closes, in + * the same change, so there is no intermediate state where the key is declared + * but neighbours still vanish silently. Rejected alternative: judging it an + * objectui-only extension (`SANCTIONED_LOCAL`), which would have made + * `packages/spec` and objectui two sources of truth for one contract — the + * fork #2231's derive-by-reference unification exists to prevent (PD#12). + * + * ⚠️ Scope of the promotion: `allowAddTab` declares that the tab bar RENDERS an + * add-tab affordance. The button objectui renders today carries no click + * handler, so it is presentational — filed against the renderer, and + * deliberately NOT written into the `.describe()`, because a contract that + * promises "end users can add presets" would be advertising a capability the + * runtime does not deliver (PD#10). + * + * ## What closing flips, and why that flip is wanted (批 6e's question) + * + * 批 6e left this open pending "does anything rely on the strip to NARROW a + * page-shaped block?". It does, and the relier is in this file: + * {@link ObjectUserFiltersSchema} is `UserFiltersSchema.omit({ tabs, + * showAllRecords, allowAddTab })`, and `.omit()` inherits the base's posture, + * so closing this base flips `object-list-view.test.ts`'s pin from "drops the + * page-only keys" to "rejects" them. That flip is CORRECT and wanted: the CLI + * lint (`packages/lint/src/validate-list-view-mode.ts`) already reports a + * `tabs`-carrying `userFilters` on an object view, so the two doors used to + * disagree — the bespoke guard warning while the schema silently dropped. + * Closing makes them agree. See {@link ObjectUserFiltersSchema} for why that + * inheritance is not left bare. + */ +export const UserFiltersSchema = lazySchema(() => strictObject({ + surface: 'these user filters', + history: VIEW_HISTORY, +}, { // `toggle` is DEPRECATED (ADR-0047 §3.4a): it overlaps `tabs` (presets) and // `dropdown` (per-field values) without adding expressive power, needs // per-field defaultValues to be useful, and authoring tooling no longer @@ -669,6 +684,8 @@ export const UserFiltersSchema = lazySchema(() => z.object({ .describe('Named filter presets rendered as tabs (tabs element). Reuses ViewTabSchema'), showAllRecords: z.boolean().optional() .describe('Show an "All records" tab before the presets (tabs element)'), + allowAddTab: z.boolean().optional() + .describe('Render an "add tab" affordance after the presets (tabs element). Page lists only — object views use `listViews` for named presets'), }).describe('End-user quick-filter configuration (Airtable "User filters" parity)')); /** @@ -1635,16 +1652,41 @@ export const FormViewSchema = lazySchema(() => strictObject({ * * A {@link UserFiltersSchema} restricted to the styles that make sense on an object * list view: `dropdown` (per-field value chips — the Airtable "quick filter" pills) - * and the deprecated `toggle`. The `tabs` element and its `showAllRecords` companion - * are OMITTED because an object view's saved-view switcher (`ViewTabBar`) already owns - * the tab-bar role — a `tabs` user-filter would render a second, conflicting tab bar. - * Need named presets on an object? Use `listViews` (each becomes a segmented tab). - */ -export const ObjectUserFiltersSchema = lazySchema(() => - UserFiltersSchema.omit({ tabs: true, showAllRecords: true }).extend({ - element: z.enum(['dropdown', 'toggle']).default('dropdown') - .describe('Filter control style on object views: "dropdown" (per-field value chips). "toggle" is deprecated. "tabs" is page-only — use `listViews` for named presets.'), - })); + * and the deprecated `toggle`. The `tabs` element and its two companions + * (`showAllRecords`, `allowAddTab`) are OMITTED because an object view's saved-view + * switcher (`ViewTabBar`) already owns the tab-bar role — a `tabs` user-filter would + * render a second, conflicting tab bar. Need named presets on an object? Use + * `listViews` (each becomes a segmented tab). + * + * ## Why this carries its OWN error map instead of inheriting the base's (#5073) + * + * The shape is still DERIVED — `.omit()` off {@link UserFiltersSchema}, so a key + * added upstream flows in rather than being re-transcribed (#2231). What is not + * inherited is the unknown-key message, and that is deliberate: `.omit()` keeps the + * base's `strictObject` error map, whose `knownKeys` were read from the base's shape + * — which still contains the three omitted keys. Measured before this was written: + * an author who typed `tab` on an object view got *"Did you mean `tab` → `tabs`?"*, + * a suggestion to write the one key this surface refuses, and a bare `tabs` was + * rejected with no prescription at all. Both are the campaign's finding 7 — the fix + * signposting the way into the failure it exists to kill — and #5073 is the change + * that turned these keys from silently-dropped into rejected, so the message is this + * change's own responsibility. Rebuilding the map over the OMITTED shape drops the + * three keys out of the suggestion pool, and `guidance` gives each the pointer the + * CLI lint (`packages/lint/src/validate-list-view-mode.ts`) already carries. + */ +export const ObjectUserFiltersSchema = lazySchema(() => strictObject({ + surface: 'these object user filters', + history: VIEW_HISTORY, + guidance: { + tabs: '`tabs` presets are page-only: an object view\'s tab bar is its saved-view switcher (ViewTabBar), and a second one would collide. Define each preset as a named entry under the object\'s `listViews` instead — every one renders as a segmented tab.', + showAllRecords: '`showAllRecords` configures the page-only `tabs` element. On an object view the default list view already is the "all records" entry; use `listViews` for the named presets beside it.', + allowAddTab: '`allowAddTab` configures the page-only `tabs` element. An object view\'s tab bar is the saved-view switcher (ViewTabBar), which owns its own add control.', + }, +}, { + ...UserFiltersSchema.omit({ tabs: true, showAllRecords: true, allowAddTab: true }).shape, + element: z.enum(['dropdown', 'toggle']).default('dropdown') + .describe('Filter control style on object views: "dropdown" (per-field value chips). "toggle" is deprecated. "tabs" is page-only — use `listViews` for named presets.'), +})); /** * ADR-0047 "views" mode — an object's default list + named list views. From 6bd954993cfd1bb91c871175bc7b9ea1b9f9b076 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 11:53:03 +0000 Subject: [PATCH 2/3] build(spec): regenerate strictness counts from the merged tree (#5073 + #5221) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge with origin/main brought in #5221 (`chart.zod.ts` gains `ChartDrillDownSchema`). Git text-merged the GENERATED counts file cleanly and WRONGLY — the ledger's own documented failure mode: each side's row deltas do not overlap, so the subtotals merge clean while naming a tree neither branch ever had (483/199, missing #5221's site). Regenerated via `gen:strictness-ledger` from the merged tree, which is the only state in which the numbers are right: 484 sites / ui 200, strict 244 / ui 120. Both hand-written prose rows survive untouched — `view.zod.ts` (this PR) and `chart.zod.ts` (#5221) are different rows. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9 --- ...26-07-unknown-key-strictness-ledger.counts.md | 16 ++++++++-------- packages/spec/authorable-surface.json | 6 ++++++ 2 files changed, 14 insertions(+), 8 deletions(-) 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 a3080baf21..118b195815 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -21,7 +21,7 @@ regenerate. | Measure | Value | |---|---| | Triaged directories | 5 | -| Object sites in them | 483 | +| Object sites in them | 484 | | Still-open (strip) sites | 234 | | Files carrying at least one | 36 | @@ -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/` | 199 | 119 | 5 | 0 | 75 | +| `ui/` | 200 | 120 | 5 | 0 | 75 | | `data/` | 162 | 41 | 1 | 0 | 120 | | `automation/` | 75 | 49 | 0 | 0 | 26 | | `security/` | 20 | 7 | 0 | 0 | 13 | | `studio/` | 27 | 27 | 0 | 0 | 0 | -| **total** | **483** | **243** | **6** | **0** | **234** | +| **total** | **484** | **244** | **6** | **0** | **234** | ## File-level triage — site counts @@ -64,7 +64,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `animation.zod.ts` | 4 | | `app.zod.ts` | 18 | | `bulk-action.zod.ts` | 3 | -| `chart.zod.ts` | 7 | +| `chart.zod.ts` | 8 | | `component.zod.ts` | 29 | | `dashboard.zod.ts` | 11 | | `dataset.zod.ts` | 4 | @@ -81,7 +81,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `touch.zod.ts` | 7 | | `view.zod.ts` | 51 | | `widget.zod.ts` | 9 | -| **total** | **199** | +| **total** | **200** | ### `data/` — sites @@ -161,13 +161,13 @@ over it is here. ### `ui/` — open -**75 strip of 199**, in 13 file(s). +**75 strip of 200**, in 13 file(s). | File | Strip | Sites | |---|---|---| | `animation.zod.ts` | 4 | 4 | | `app.zod.ts` | 1 | 18 | -| `chart.zod.ts` | 2 | 7 | +| `chart.zod.ts` | 2 | 8 | | `component.zod.ts` | 29 | 29 | | `dnd.zod.ts` | 4 | 4 | | `i18n.zod.ts` | 5 | 6 | @@ -178,7 +178,7 @@ over it is here. | `touch.zod.ts` | 7 | 7 | | `view.zod.ts` | 5 | 51 | | `widget.zod.ts` | 9 | 9 | -| **total** | **75** | **199** | +| **total** | **75** | **200** | | Bucket | Sites | |---|---| diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index 9704769a78..0576f943dc 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -7050,6 +7050,12 @@ "ui/ChartConfig:type", "ui/ChartConfig:xAxis", "ui/ChartConfig:yAxis", + "ui/ChartDrillDown:columns", + "ui/ChartDrillDown:enabled", + "ui/ChartDrillDown:filter", + "ui/ChartDrillDown:maxRows", + "ui/ChartDrillDown:target", + "ui/ChartDrillDown:title", "ui/ChartInteraction:brush", "ui/ChartInteraction:tooltips", "ui/ChartSeries:color", From 7f92b1f4dc06c573ca120f82975204b8304218b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 11:55:36 +0000 Subject: [PATCH 3/3] docs(spec): name the renderer-gap issue (#5236) in the JSDoc and ledger row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both spots described the add-tab button's missing click handler as "filed separately" without the number, which is the shape AGENTS.md #13 warns about — a decision nobody can find. Now the reader of either surface can reach the evidence. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9 --- docs/audits/2026-07-unknown-key-strictness-ledger.md | 2 +- packages/spec/src/ui/view.zod.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index 3d8ded1b95..ae45f34f89 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -817,7 +817,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 separately. **(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 · 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) | | `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/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index f31297ea58..9c6ffa31b1 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -648,7 +648,7 @@ export const UserFilterFieldSchema = lazySchema(() => strictObject({ * * ⚠️ Scope of the promotion: `allowAddTab` declares that the tab bar RENDERS an * add-tab affordance. The button objectui renders today carries no click - * handler, so it is presentational — filed against the renderer, and + * handler, so it is presentational — filed against the renderer as #5236, and * deliberately NOT written into the `.describe()`, because a contract that * promises "end users can add presets" would be advertising a capability the * runtime does not deliver (PD#10).