Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .changeset/view-subblock-strictness-batch18.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
'@objectstack/spec': major
---

**View sub-blocks now reject unknown keys instead of dropping them (#4001 批 18).**

Fifteen object shapes in `ui/view.zod.ts` were still zod's default `.strip`: a key
the schema did not declare was discarded and the parse still succeeded, so the view
rendered without whatever the key was meant to configure — no error, no warning,
`tsc` green. They are now closed, and the rejection names the surface, echoes the
offending key, and suggests the right one.

Closed shapes: `ViewDataSchema`'s four provider arms (`object` / `api` / `value` /
`schema`), `UserFilterField.options`, `GanttQuickFilter.options`,
`GanttConfig.tooltipFields`, `ListView.conditionalFormatting` / `.emptyState`, the `keyField` block on a form field, `FormView.subforms`, and all
four arms of `FormView.submitBehavior`.

**Migration — the spellings that used to be silently dropped and now raise:**

| You wrote | Where | Write instead |
|---|---|---|
| `object` | `form.subforms[]` | `childObject` |
| `objectName` | `data: { provider: 'object' }` | `object` |
| `delay` / `delayMS` | `submitBehavior: { kind: 'redirect' }` | `delayMs` |
| `visibleWhen` / `when` | `list.conditionalFormatting[]` | `condition` |
| `description` / `text` | `list.emptyState` | `message` |
| `count` | a user-filter option | nothing — counts are computed; set `showCount: true` on the filter field |
| `action` / `button` | `list.emptyState` | configure the `addRecord` block instead |

`submitBehavior` is now a discriminated union on the `kind` literal it already
required. No accepted input changes shape; the rejection improves — a plain union
reported `invalid_union` with one sub-error per arm, and the useful message did not
survive to the CLI (#5014).

**Not changed, deliberately:** `GanttConfig` / `TreeConfig` stay open at the parent
(`.passthrough()`) so renderer-ahead knobs keep reaching plugin-gantt / plugin-tree —
only the nested `tooltipFields` entry closed. `ListView.sort` stays open too — the console
stamps a UI row `id` into it (`.strip()` on a wire member does NOT recurse, so a
closed nested block 422s a console PUT regardless). `UserFiltersSchema`,
`ViewItemSchema` and the private `FormFieldBase` also stay open, each for a measured reason recorded
in the schema's own JSDoc, in `view-strictness-batch18.test.ts`, and in the `ui/` row
of `docs/audits/2026-07-unknown-key-strictness-ledger.md`.
24 changes: 17 additions & 7 deletions docs/audits/2026-07-unknown-key-strictness-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -755,12 +755,12 @@ it the same way: the decision is also written beside the schema and pinned in a
test (`flow.test.ts`, `etl.test.ts`), because a row in a table is not where the
next person to open that file will look.

#### `ui/` — 90 strip of 198
#### `ui/` — 75 strip of 198

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

**Authorable strip in `ui/`: 50 of 90** (was 123 of 123 when the ruling was
written). Recomputed from the surviving rows at 批 16, not decremented:
29+20+9+2+7+5+4+4+4+3+1+1+1 = 90, of which 40 are the two no-parse classes, so
the authorable half is `component` 29 + `view` 20 + `app` 1 = 50. `app.zod.ts`'s
**批 18 is the ninth instance.** It computed 84 against a tree where 批 16's
rows still existed (`widget` still `authorable` at 9, `i18n` still 6) — right
against its own branch, wrong against the merge, which is **75**: a number
neither side wrote down. Git conflicted three regions here (header, the
`view`/`widget` row pair, and this paragraph) and every row from both sides was
kept before the arithmetic was redone from them. Worth naming because 批 16 and
批 18 moved the same two numbers for OPPOSITE reasons — 批 16 by reclassifying 14
sites it did not touch, 批 18 by closing 15 it did — and the merged subtotal is
not reachable by applying either delta to the other's base.

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