diff --git a/.changeset/view-subblock-strictness-batch18.md b/.changeset/view-subblock-strictness-batch18.md new file mode 100644 index 0000000000..af7c3565b4 --- /dev/null +++ b/.changeset/view-subblock-strictness-batch18.md @@ -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`. diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index 61a4d367bb..a8de812bca 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -755,12 +755,12 @@ it the same way: the decision is also written beside the schema and pinned in a test (`flow.test.ts`, `etl.test.ts`), because a row in a table is not where the next person to open that file will look. -#### `ui/` — 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 (``) 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 | @@ -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 diff --git a/packages/spec/src/ui/view-strictness-batch18.test.ts b/packages/spec/src/ui/view-strictness-batch18.test.ts new file mode 100644 index 0000000000..1e596cd5f5 --- /dev/null +++ b/packages/spec/src/ui/view-strictness-batch18.test.ts @@ -0,0 +1,364 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #4001 批 18 — `ui/view.zod.ts` sub-block strictness. + * + * 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. + * + * 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 + * `docs/audits/2026-07-unknown-key-strictness-ledger.md`). + * + * What is pinned, and why each needs its own assertion: + * + * 1. THE DOOR. `.strict()` is a property of a PARSE — a strict schema nobody + * parses gates nothing (#4583). So the doors are asserted directly, not + * inferred from the schema's posture. + * 2. EVERY closed site at its OWN path. Strictness does not recurse (批 13's + * `responsive` finding), so a closed parent proves nothing about a nested + * block. Each is probed where it lives. + * 3. The CURATION. Every alias/guidance entry is anchored to a named sibling + * contract; these assertions are what stop a later reader deleting one as + * 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. + * 5. The real union ERROR BEHAVIOUR (#5014) — pinned honestly, including the + * part that does not reach the author today. + */ + +import { describe, it, expect } from 'vitest'; + +import { + ViewDataSchema, + UserFilterFieldSchema, + UserFiltersSchema, + ObjectUserFiltersSchema, + GanttQuickFilterSchema, + GanttConfigSchema, + ListViewSchema, + FormViewSchema, + ViewItemSchema, + ViewMetadataSchema, + defineViewItem, +} from './view.zod'; +import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; + +/** Reject `value` through `schema` and return its issues as a searchable string. */ +function reject(schema: { safeParse: (v: unknown) => { success: boolean; error?: unknown } }, value: unknown): string { + const r = schema.safeParse(value); + expect(r.success, `expected REJECTION, got a successful parse of ${JSON.stringify(value)}`).toBe(false); + return JSON.stringify((r.error as { issues?: unknown })?.issues ?? r.error ?? []); +} + +/** Parse `value` and fail loudly (with the issues) if it does not succeed. */ +function accept(schema: { safeParse: (v: unknown) => { success: boolean; error?: unknown; data?: unknown } }, value: unknown): unknown { + const r = schema.safeParse(value); + expect(r.success, `expected ACCEPTANCE, got ${JSON.stringify((r.error as { issues?: unknown })?.issues ?? '')}`).toBe(true); + return r.data; +} + +const LIST_BASE = { columns: ['name'] }; +const FORM_BASE = { type: 'simple', sections: [{ fields: ['name'] }] }; +/** A form whose one section carries `field` as an object, so nested field blocks are reachable. */ +const formWithField = (field: unknown) => ({ type: 'simple', sections: [{ fields: [field] }] }); + +// =========================================================================== +// 1. The doors — a parse must exist, or none of the rest means anything +// =========================================================================== +describe('#4001 批 18 — the doors these shapes are reachable through', () => { + it('the `view` metadata type resolves to a registered schema (the save-time 422 door)', () => { + expect(getMetadataTypeSchema('view')).toBeDefined(); + }); + + it('`defineViewItem()` is a real parse door — it throws on a malformed config', () => { + expect(() => + defineViewItem({ + name: 'crm_lead.pipeline', + object: 'crm_lead', + viewKind: 'list', + config: { type: 'grid', columns: 'not-an-array' }, + } as never), + ).toThrow(); + }); + + it('controls parse — these tests fail closed, they do not reject everything', () => { + accept(ListViewSchema, LIST_BASE); + accept(FormViewSchema, FORM_BASE); + accept(ViewDataSchema, { provider: 'object', object: 'crm_lead' }); + }); +}); + +// =========================================================================== +// 2. Every closed site, at its own path +// =========================================================================== +describe('#4001 批 18 — closed sites reject unknown keys where they live', () => { + describe('ViewDataSchema — all four provider arms', () => { + it.each([ + ['object', { provider: 'object', object: 'crm_lead' }], + ['api', { provider: 'api' }], + ['value', { provider: 'value', items: [] }], + ['schema', { provider: 'schema', schemaId: 'report' }], + ])('the `%s` arm rejects an undeclared key', (_arm, base) => { + accept(ViewDataSchema, base); + expect(reject(ViewDataSchema, { ...base, notADataKey: 1 })).toContain('notADataKey'); + }); + + it('is reached through `ListViewSchema.data` and `FormViewSchema.data`, not only standalone', () => { + expect(reject(ListViewSchema, { ...LIST_BASE, data: { provider: 'object', object: 'x', notADataKey: 1 } })) + .toContain('notADataKey'); + expect(reject(FormViewSchema, { ...FORM_BASE, data: { provider: 'object', object: 'x', notADataKey: 1 } })) + .toContain('notADataKey'); + }); + }); + + it('UserFilterFieldSchema.options — the nested option entry, inside an already-strict parent', () => { + accept(UserFilterFieldSchema, { field: 'stage', options: [{ value: 'won', label: 'Won' }] }); + expect(reject(UserFilterFieldSchema, { field: 'stage', options: [{ value: 'won', label: 'Won', notAnOptionKey: 1 }] })) + .toContain('notAnOptionKey'); + }); + + it('GanttQuickFilterSchema.options — the object arm of the string|object union', () => { + accept(GanttQuickFilterSchema, { field: 'status', options: ['todo', { value: 'done', label: 'Done' }] }); + expect(reject(GanttQuickFilterSchema, { field: 'status', options: [{ value: 'done', notAnOptionKey: 1 }] })) + .toContain('notAnOptionKey'); + }); + + it('GanttConfigSchema.tooltipFields — a CLOSED entry inside a deliberately OPEN parent', () => { + const gantt = { startDateField: 's', endDateField: 'e', titleField: 't' }; + // The parent is `.passthrough()` on purpose (renderer-ahead knobs reach + // plugin-gantt). That openness must NOT leak into the entry. + const parsed = accept(GanttConfigSchema, { ...gantt, someRendererAheadKnob: true }) as Record; + expect(parsed.someRendererAheadKnob, 'the parent stays open — this is the 批 18 non-goal').toBe(true); + expect(reject(GanttConfigSchema, { ...gantt, tooltipFields: [{ field: 'owner', notATooltipKey: 1 }] })) + .toContain('notATooltipKey'); + }); + + it.each([ + ['conditionalFormatting', { conditionalFormatting: [{ condition: 'true', style: {}, notAFormatKey: 1 }] }], + ['emptyState', { emptyState: { title: 'None', notAnEmptyStateKey: 1 } }], + ])('ListViewSchema.%s rejects an undeclared key', (_block, patch) => { + const bad = Object.values(patch)[0]; + const key = Object.keys(Array.isArray(bad) ? bad[0] : (bad as object)).find((k) => k.startsWith('notA'))!; + expect(reject(ListViewSchema, { ...LIST_BASE, ...patch })).toContain(key); + }); + + it('FormFieldBaseSchema.keyField — nested inside a shape that was ALREADY strict', () => { + // The enclosing form field closed under ADR-0089 D3a; this block did not, + // because strictness is per object and not per subtree. + accept(FormViewSchema, formWithField({ field: 'entries', keyField: { field: 'name' } })); + expect(reject(FormViewSchema, formWithField({ field: 'entries', keyField: { field: 'name', notAKeyFieldKey: 1 } }))) + .toContain('notAKeyFieldKey'); + }); + + it('FormViewSchema.subforms rejects an undeclared key', () => { + accept(FormViewSchema, { ...FORM_BASE, subforms: [{ childObject: 'crm_line' }] }); + expect(reject(FormViewSchema, { ...FORM_BASE, subforms: [{ childObject: 'crm_line', notASubformKey: 1 }] })) + .toContain('notASubformKey'); + }); + + it.each([ + ['thank-you', { kind: 'thank-you', title: 'Thanks' }], + ['redirect', { kind: 'redirect', url: '/done' }], + ['continue', { kind: 'continue' }], + ['next-record', { kind: 'next-record' }], + ])('FormViewSchema.submitBehavior `%s` arm rejects an undeclared key', (_kind, base) => { + accept(FormViewSchema, { ...FORM_BASE, submitBehavior: base }); + expect(reject(FormViewSchema, { ...FORM_BASE, submitBehavior: { ...base, notASubmitKey: 1 } })) + .toContain('notASubmitKey'); + }); +}); + +// =========================================================================== +// 3. Curation — the entries that make a rejection fixable +// =========================================================================== +describe('#4001 批 18 — the rejection carries a usable prescription', () => { + it('`object` → `childObject` on a subform — the word every neighbouring block uses', () => { + expect(reject(FormViewSchema, { ...FORM_BASE, subforms: [{ childObject: 'x', object: 'crm_line' }] })) + .toContain('`object` → `childObject`'); + }); + + it('`objectName` → `object` on the `object` data source — the query surface spelling', () => { + expect(reject(ViewDataSchema, { provider: 'object', object: 'x', objectName: 'crm_lead' })) + .toContain('`objectName` → `object`'); + }); + + it('a bare `name` on the `object` data source is NOT aliased — finding 7 discipline', () => { + // `name` is a real key on the view ITEM, so an author writing it here may + // have meant the view's name. A confidently wrong prescription is worse + // than none; the key is still named and rejected, just not redirected. + const msg = reject(ViewDataSchema, { provider: 'object', object: 'x', name: 'my_view' }); + expect(msg).toContain('name'); + expect(msg).not.toContain('`name` → `object`'); + }); + + it('`delay` → `delayMs` on a redirect — the unit lives in the key name', () => { + expect(reject(FormViewSchema, { ...FORM_BASE, submitBehavior: { kind: 'redirect', url: '/x', delay: 500 } })) + .toContain('`delay` → `delayMs`'); + }); + + it('`visibleWhen` → `condition` on a formatting rule — the ADR-0089 spelling, borrowed', () => { + expect(reject(ListViewSchema, { ...LIST_BASE, conditionalFormatting: [{ condition: 'true', style: {}, visibleWhen: 'x' }] })) + .toContain('`visibleWhen` → `condition`'); + }); + + it('an option `count` gets a wrong-layer prescription, not a rename', () => { + // Measured against objectui `plugin-list/src/UserFilters.tsx`: `count` is + // COMPUTED from a data snapshot every render, so an authored one is + // overwritten before it is read. Pointing at `showCount` on the FIELD is + // the capability the author actually wanted. + const msg = reject(UserFilterFieldSchema, { field: 'stage', options: [{ value: 'won', label: 'Won', count: 3 }] }); + expect(msg).toContain('showCount'); + }); + + it('an empty-state `action` is pointed at `addRecord`, a real block on the same view', () => { + expect(reject(ListViewSchema, { ...LIST_BASE, emptyState: { action: {} } })).toContain('addRecord'); + }); + + 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'); + }); +}); + +// =========================================================================== +// 4. Union error behaviour — pinned honestly, including what does NOT arrive +// =========================================================================== +describe('#4001 批 18 — union error behaviour (#5014), pinned as it really is', () => { + it('submitBehavior discriminates on `kind`, so ONE member reports and the prescription survives', () => { + // This is why the block is `z.discriminatedUnion` and not `z.union`. With a + // plain union of four strict members the error is `invalid_union` carrying + // four sub-errors — and #5014 measured that the renderers flatten those to + // a bare "Invalid input", so the prescription never reaches the author. + const r = FormViewSchema.safeParse({ ...FORM_BASE, submitBehavior: { kind: 'redirect', url: '/x', delay: 1 } }); + expect(r.success).toBe(false); + // The load-bearing assertion is the issue CODE at the top level. A plain + // `z.union` reports `invalid_union` and buries the prescription in + // `issue.errors[]`, which #4971/#5014 measured the consumers dropping; + // discriminating surfaces `unrecognized_keys` directly, at the key's own + // path, where every renderer already prints it. + const issues = r.error?.issues ?? []; + expect(issues.map((i) => i.code)).toEqual(['unrecognized_keys']); + expect(issues[0]?.path).toEqual(['submitBehavior']); + expect(JSON.stringify(issues)).toContain('`delay` → `delayMs`'); + }); + + it('ViewMetadataSchema is a top-level union — its unknown-key report is still an `invalid_union`', () => { + // Pinned as a FACT about today, not as an endorsement. The container member + // is strict, so a container body with a stray key is rejected — but it + // arrives wrapped in `invalid_union`, which is exactly the shape #5014 + // reports the CLI/renderers flatten. When #5014 lands, this assertion is + // expected to go red and should be updated to the improved behaviour. + const r = ViewMetadataSchema.safeParse({ list: { type: 'grid', columns: ['name'] }, notAContainerKey: 1 }); + expect(r.success).toBe(false); + expect(JSON.stringify(r.error?.issues ?? [])).toContain('invalid_union'); + }); +}); + +// =========================================================================== +// 5. The four 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 + // ViewItem record carries `viewKind` AND `config`, so the merged body lands + // on THIS member — the flattened members are excluded by their + // `config: z.undefined()` guard. Closed, pinning a saved view would 422. + const record = { + name: 'crm_lead.pipeline', + object: 'crm_lead', + viewKind: 'list' as const, + config: { type: 'grid', columns: ['name'] }, + }; + expect(ViewItemSchema.safeParse({ ...record, isPinned: true, sortOrder: 3 }).success).toBe(true); + expect(ViewMetadataSchema.safeParse({ ...record, isPinned: true, sortOrder: 3 }).success).toBe(true); + }); + + it('…and the aux keys really do land on member 1, not on a flattened member', () => { + // If this ever starts matching a flattened member instead, the reasoning + // above is stale — the flattened members pin `config` to undefined. + const withConfig = { + name: 'crm_lead.pipeline', + object: 'crm_lead', + viewKind: 'list' as const, + config: { type: 'grid', columns: 'not-an-array' }, + }; + expect( + ViewMetadataSchema.safeParse(withConfig).success, + 'a broken record must NOT be rescued by a lenient flattened member', + ).toBe(false); + }); + + it('ListViewSchema.sort stays open: the console stamps a UI row `id` into it', () => { + // Batch 18 CLOSED this (with `direction → order`, the #4721 alias for the + // identical tuple) and the full suite caught it: `view-metadata-schema.test.ts` + // pins `sort: [{ id, field, order }]` as the exact body a console column-sort + // PUT persists, and objectui stamps that `id` per row + // (`components/src/custom/sort-builder.tsx:68`, `:94` — `crypto.randomUUID()`). + // `id` was deliberately NOT declared to silence the rejection: it is a React + // list key, and declaring it would put a UI artifact on the authorable + // surface and teach an AI author to emit one. + expect(ListViewSchema.safeParse({ ...LIST_BASE, sort: [{ id: 'uuid', field: 'name', order: 'asc' }] }).success).toBe(true); + }); + + it('…and the mechanism that made it a regression: `.strip()` does NOT recurse', () => { + // This is the load-bearing fact for every nested block in this file, and it + // is the opposite of what the union's comment implies. `ViewMetadataSchema` + // rescues Studio's round-trip keys by making its flattened members + // `.strip()` — but that re-opens the TOP level only. A nested block closed + // inside `ListViewSchema` is still reached through that member, so a + // console-stamped key inside it becomes a 422 no matter what the member does. + const overlay = { type: 'grid', columns: ['name'], name: 'o.default', viewKind: 'list', object: 'o' }; + // top level: an unknown aux key rides along, because the member strips. + expect(ViewMetadataSchema.safeParse({ ...overlay, someStudioAuxKey: 1 }).success).toBe(true); + // nested: a CLOSED sub-block still rejects through that same member. + expect(ViewMetadataSchema.safeParse({ ...overlay, emptyState: { title: 'x', notAnEmptyStateKey: 1 } }).success).toBe(false); + }); + + it('FormFieldBaseSchema stays a bare z.object: its ONE consumer already `.strict()`s it', () => { + // The ledger reads this site as `strip` because it counts the base; the + // base is not a door. `FormFieldSchema` = base.extend({fields}).strict(), + // and it carries the ADR-0089 `strictVisibilityError` map that a + // `strictObject` conversion would have to re-express. + expect(reject(FormViewSchema, formWithField({ field: 'name', notAFieldKey: 1 }))) + .toContain('notAFieldKey'); + }); + + it('the ADR-0089 visibility pair still resolves through its own error map', () => { + // The reason the base was NOT converted — proving the map is still wired. + const msg = reject(FormViewSchema, formWithField({ field: 'name', visibility: 'x' })); + expect(msg).toContain('visib'); + }); +}); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index f5db4d1b1f..564c68b539 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -89,16 +89,46 @@ export type { HttpMethodType } from '../shared/http.zod'; * 3. 'value': Static Data - Hardcoded data array */ export const ViewDataSchema = lazySchema(() => z.discriminatedUnion('provider', [ - z.object({ + strictObject({ + surface: 'this `object` data source', + history: VIEW_HISTORY, + // `objectName` is the canonical spelling on the QUERY surface + // (`data/query.zod.ts`); the view data source names it `object`, and an + // author moving between the two writes the neighbouring word rather than a + // typo edit distance could reach. + // NOT aliased: a bare `name`. It is a real key on the view ITEM + // (`ViewItemSchema.name`), so an author who wrote it here may have meant + // the view's name, not the object's — and this campaign's own finding 7 is + // that a confidently wrong prescription is worse than none. + aliases: { objectName: 'object' }, + }, { provider: z.literal('object'), object: z.string().describe('Target object name'), }), - z.object({ + strictObject({ + surface: 'this `api` data source', + history: VIEW_HISTORY, + // The HTTP verbs are the request BLOCKS' business (`read`/`write` each hold + // an `HttpRequestSchema`), so an author who put the whole request inline is + // pointed at the block that owns it rather than at a near-miss key. + guidance: { + url: 'Set the URL inside the request block: `read: { url, method }` (or `write: { … }`) — the data source itself declares only `read` / `write`.', + method: 'Set the method inside the request block: `read: { url, method }` (or `write: { … }`).', + fetch: 'Use `read` for the fetch request and `write` for the submit request.', + submit: 'Use `write` for the submit request (and `read` for the fetch request).', + }, + }, { provider: z.literal('api'), read: HttpRequestSchema.optional().describe('Configuration for fetching data'), write: HttpRequestSchema.optional().describe('Configuration for submitting data (for forms/editable tables)'), }), - z.object({ + strictObject({ + surface: 'this `value` data source', + history: VIEW_HISTORY, + // `data`/`rows`/`records` are the words the surrounding surfaces use for a + // row set; on this provider the static array is `items`. + aliases: { data: 'items', rows: 'items', records: 'items', values: 'items' }, + }, { provider: z.literal('value'), items: z.array(z.unknown()).describe('Static data array'), }), @@ -108,7 +138,14 @@ export const ViewDataSchema = lazySchema(() => z.discriminatedUnion('provider', * ObjectQL object. Powers the metadata editor, action input dialogs, * and any Form that is not bound to a CRUD object. */ - z.object({ + strictObject({ + surface: 'this `schema` data source', + history: VIEW_HISTORY, + // `schemaId` and `schema` are one key apart in prose but not in edit + // distance, and the pair is genuinely confusable: one NAMES a schema the + // server resolves, the other INLINES it. + aliases: { type: 'schemaId', metadataType: 'schemaId', jsonSchema: 'schema' }, + }, { provider: z.literal('schema'), /** Schema identifier (e.g. metadata type name "report"). Resolved at runtime against /meta entries. */ schemaId: z.string().describe('Schema identifier — typically the metadata type name'), @@ -511,7 +548,19 @@ export const UserFilterFieldSchema = lazySchema(() => strictObject({ label: I18nLabelSchema.optional().describe('Display label override (defaults to the field label)'), type: z.enum(['select', 'multi-select', 'boolean', 'date-range', 'text']).optional() .describe('Filter control type. Omit to infer from the field definition'), - options: z.array(z.object({ + options: z.array(strictObject({ + surface: 'this user filter option', + history: VIEW_HISTORY, + // Measured against the renderer that consumes these entries + // (objectui `plugin-list/src/UserFilters.tsx` — `ResolvedOption`): it + // declares exactly `label` / `value` / `color` plus a `count` it COMPUTES + // from a data snapshot on every render. `count` is therefore not an + // authoring key — an authored one is overwritten before it is read, which + // is why it gets a wrong-layer prescription instead of being declared. + guidance: { + count: 'Per-option record counts are computed from the data, not authored. Set `showCount: true` on the filter FIELD to display them.', + }, + }, { value: z.union([z.string(), z.number(), z.boolean()]).describe('Option value'), label: I18nLabelSchema.describe('Option label'), color: z.string().optional().describe('Option color token/hex'), @@ -533,20 +582,47 @@ export const UserFilterFieldSchema = lazySchema(() => strictObject({ * @see Airtable Interface → "User filters" panel (Elements: tabs / dropdowns) */ /** - * Deliberately left STRIP by #4001, pending its own verification. + * Still STRIP after #4001 批 18 — the verification 批 6e asked for was DONE, + * and it turned up a blocker that is not a strictness question. * - * `tabs` and `showAllRecords` are valid on a PAGE's user filters and not on an - * object list view's (ADR-0047), and `object-list-view.test.ts` asserts they are - * dropped here rather than rejected. That is the "correct on a neighbouring - * surface" shape this campaign usually answers with a `guidance` entry — so the - * likely right end state is a rejection saying "tabs are page-only" rather than - * a silent drop. + * ## What 批 6e asked, and the answer * - * Not done here because it is a behaviour change with a real consumer question - * behind it: something may pass a page-shaped userFilters block through this - * schema deliberately, relying on the strip to narrow it. The campaign's own - * rule is verify-then-enforce, and this batch did not verify it. Left as the one - * open shape in this file, named rather than quietly skipped. + * 批 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 + * + * 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. + * + * That gap is 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({ // `toggle` is DEPRECATED (ADR-0047 §3.4a): it overlaps `tabs` (presets) and @@ -648,7 +724,14 @@ export const GanttQuickFilterSchema = lazySchema(() => strictObject({ label: z.string().optional().describe('Trigger label (falls back to the field label)'), options: z.array(z.union([ z.string(), - z.object({ + strictObject({ + surface: 'this gantt quick-filter option', + history: VIEW_HISTORY, + // Sibling contract: the user-filter option entry two blocks up spells the + // display text `label` and the stored value `value`. Gantt's entry is the + // same pair minus `color`, so the same near-misses apply. + aliases: { text: 'label', title: 'label', name: 'label', key: 'value', id: 'value' }, + }, { value: z.union([z.string(), z.number()]), label: z.string().optional(), }), @@ -688,7 +771,17 @@ export const GanttConfigSchema = lazySchema(() => strictObject({ // Hover tooltip + quick filters. tooltipFields: z.array(z.union([ z.string(), - z.object({ field: z.string(), label: z.string().optional() }), + strictObject({ + surface: 'this gantt tooltip field', + history: VIEW_HISTORY, + // ⚠️ The PARENT (`GanttConfigSchema`) is deliberately `.passthrough()` so + // renderer-ahead knobs reach plugin-gantt. That openness is the parent's + // and does NOT recurse: this entry is a closed `{ field, label }` pair, + // which is exactly the shape objectui's `GanttView` tooltip resolver + // reads. Closing the entry inside an open parent is the nested hole 批 13 + // found on `responsive` — strictness is per object, not per subtree. + aliases: { name: 'field', fieldName: 'field', text: 'label', title: 'label' }, + }, { field: z.string(), label: z.string().optional() }), ])).optional().describe('Fields to surface in the hover tooltip, in display order'), quickFilters: z.array(GanttQuickFilterSchema).optional().describe('Multi-select filter dropdowns rendered above the chart'), autoZoomToFilter: z.boolean().optional().describe('When true (default), filtering zooms the range to the filtered tasks'), @@ -829,6 +922,35 @@ export const ListViewSchema = lazySchema(() => strictObject({ * renderer in objectui#2601 — kept covered by a live fixture). Removal will * go through its own deprecation cycle; do not drop it here. */ + /** + * ⚠️ [#4001 批 18] Deliberately still STRIP — reverted after the closed + * version broke a live console path, which is the finding rather than a + * setback. + * + * This batch closed it (with `direction → order`, the #4721 alias for the + * identical tuple — `{ field, direction: 'desc' }` parsed to + * `{ field, order: 'asc' }`, a silently REVERSED sort). The full suite then + * failed one case: `view-metadata-schema.test.ts` pins + * `sort: [{ id, field, order }]` as *"the exact shape normalizeViewMetadata + * persists on a console column-sort PUT"*, and `id` is a UI row identity + * objectui stamps per row (`components/src/custom/sort-builder.tsx:68`, + * `:94` — `crypto.randomUUID()`), persisted verbatim because `saveMetaItem` + * stores the original body. + * + * The mechanism is worth stating, because it governs every nested block in + * this file and is NOT what the union's comment implies: `ViewMetadataSchema` + * rescues Studio's round-trip keys with `.strip()` on its flattened members — + * but **`.strip()` does not recurse** any more than `.strict()` does. It + * re-opens the TOP level only, so a nested block closed here is still reached + * through that member and a console-stamped key inside it becomes a 422. + * + * `id` was NOT declared to make the rejection go away. It is a React list key, + * not protocol: declaring it would put a UI artifact on the authorable surface + * and tell an AI author to generate one. The real end state is the same + * authoring/wire split filed as #5074, applied one level down — until then + * this shape stays open rather than half-closed against the platform's own + * writes. + */ sort: z.union([ z.string(), //Legacy "field desc" z.array(z.object({ @@ -909,7 +1031,20 @@ export const ListViewSchema = lazySchema(() => strictObject({ virtualScroll: z.boolean().optional().describe('Enable virtual scrolling for large datasets'), /** Conditional Formatting */ - conditionalFormatting: z.array(z.object({ + conditionalFormatting: z.array(strictObject({ + surface: 'this conditional formatting rule', + history: VIEW_HISTORY, + // `visibleWhen` is the ADR-0089 spelling for a predicate on view/page, so + // an author borrowing it here is using a neighbouring surface's correct + // word — the `visibleWhen → visible` category #3746 named, not a typo. + aliases: { when: 'condition', expression: 'condition', visibleWhen: 'condition', rule: 'condition', styles: 'style', css: 'style' }, + // `rowColor` is a real, DIFFERENT capability on the same view; pointing a + // colour-only author at the block that already does it beats making them + // hand-write a style map. + guidance: { + color: 'Row colouring by field value has its own block — see `rowColor` on this list view. To set a CSS colour from a predicate, put it in `style`: `{ condition, style: { color: "#b91c1c" } }`.', + }, + }, { condition: ExpressionInputSchema.describe('Predicate (CEL) to evaluate.'), style: z.record(z.string(), z.string()).describe('CSS styles to apply when condition is true'), })).optional().describe('Conditional formatting rules for list rows'), @@ -939,7 +1074,19 @@ export const ListViewSchema = lazySchema(() => strictObject({ allowPrinting: z.boolean().optional().describe('Allow users to print the view'), /** Empty State */ - emptyState: z.object({ + emptyState: strictObject({ + surface: 'this empty state', + history: VIEW_HISTORY, + // `description`/`text`/`subtitle` are the words the neighbouring empty-state + // vocabularies use for the secondary line; here it is `message`. + aliases: { description: 'message', text: 'message', subtitle: 'message', heading: 'title', label: 'title', image: 'icon' }, + // The add-record entry point is a real, separate block on this same view — + // an author wiring a CTA into the empty state is reaching for it. + guidance: { + action: 'The empty state renders text only. Configure the "add record" entry point in the `addRecord` block on this list view.', + button: 'The empty state renders text only. Configure the "add record" entry point in the `addRecord` block on this list view.', + }, + }, { title: I18nLabelSchema.optional(), message: I18nLabelSchema.optional(), icon: z.string().optional(), @@ -978,6 +1125,27 @@ export const ListViewSchema = lazySchema(() => strictObject({ * factory would otherwise have moved its construction back to module load, which * is the allocation `lazySchema` exists to defer. */ +/** + * [#4001 批 18] Deliberately NOT converted to `strictObject` — and the ledger + * row for this site is a measurement artifact, not open surface. + * + * Two independent reasons, both verified rather than assumed: + * + * 1. **The posture here was never the live one.** This base is module-private + * and has exactly ONE consumer, {@link FormFieldSchema}, which applies + * `.strict()` after extending it (ADR-0089 D3a). An unknown form-field key + * is already rejected at the only door; the ledger reads `strip` because it + * counts the BASE, and the base is not a door. + * 2. **It already carries a bespoke error map.** The `{ error: + * strictVisibilityError }` below is the ADR-0089 map that resolves the + * `visibleWhen` / `visibility` pair. Converting would mean re-expressing + * that map as `guidance` and re-proving the `.transform()` — a refactor of + * working, tested behaviour rather than a strictness change. Same call the + * note on {@link FormSectionSchema} records, for the same family of shape. + * + * The nested `keyField` block below IS converted: strictness does not recurse, + * so a closed parent said nothing about it. + */ const FormFieldBaseSchema = lazySchema(() => z.object({ /** Field name (snake_case) */ field: z.string().describe('Field name (snake_case)'), @@ -1035,7 +1203,15 @@ const FormFieldBaseSchema = lazySchema(() => z.object({ * * See ADR-0007 (record form field type). */ - keyField: z.object({ + keyField: strictObject({ + surface: 'this record key field', + history: VIEW_HISTORY, + // The enclosing form field IS strict (ADR-0089 D3a `.strict()` on + // `FormFieldSchema`), but strictness does not recurse — this nested block + // kept dropping keys silently inside a closed parent, the same nested hole + // 批 13 measured on `responsive`. + aliases: { key: 'field', name: 'field', pattern: 'regex', placeHolder: 'placeholder', help: 'helpText', hint: 'helpText' }, + }, { field: z.string().default('name').describe('Property name that holds the key inside each item (defaults to "name")'), label: I18nLabelSchema.optional().describe('Display label for the key column'), placeholder: I18nLabelSchema.optional().describe('Placeholder when entering a new key'), @@ -1272,7 +1448,18 @@ export const FormViewSchema = lazySchema(() => strictObject({ * derived from the child object's metadata (override via * `relationshipField` / `columns`). */ - subforms: z.array(z.object({ + subforms: z.array(strictObject({ + surface: 'this subform', + history: VIEW_HISTORY, + // `object` is the word every OTHER block on this surface uses for an object + // name (`ViewDataSchema.object`, `ViewItemSchema.object`), so writing it + // here is an author being consistent — not making a typo. + aliases: { + object: 'childObject', childObjectName: 'childObject', child: 'childObject', + foreignKey: 'relationshipField', relationField: 'relationshipField', parentField: 'relationshipField', + fields: 'columns', label: 'title', sumField: 'amountField', rollupField: 'totalField', + }, + }, { childObject: z.string().describe('Child object whose records are entered inline'), relationshipField: z.string().optional().describe('FK on the child pointing back to the parent (auto-detected when omitted)'), columns: z.array(z.any()).optional().describe('Editable grid columns (derived from the child object when omitted)'), @@ -1303,19 +1490,54 @@ export const FormViewSchema = lazySchema(() => strictObject({ * - `continue` — reset the form so another response can be entered * - `next-record` — advance to the next record (internal queues only) */ - submitBehavior: z.union([ - z.object({ + // ⚠️ `discriminatedUnion`, not `union` — deliberately, and it is the closing + // that forces the choice. A plain `z.union` of four STRICT members reports a + // bad key as `invalid_union` carrying four sub-errors, one per member, and + // #5014 measured that the renderers flatten those to a bare "Invalid input" — + // so the prescription this campaign exists to deliver would never reach the + // author. Discriminating on the `kind` literal that is already there picks + // the ONE intended member and reports its message verbatim. No input changes + // shape: every member already required its own `kind` literal. + submitBehavior: z.discriminatedUnion('kind', [ + strictObject({ + surface: 'this `thank-you` submit behavior', + history: VIEW_HISTORY, + aliases: { heading: 'title', text: 'message', body: 'message', description: 'message' }, + }, { kind: z.literal('thank-you'), title: z.string().optional(), message: z.string().optional(), }), - z.object({ + strictObject({ + surface: 'this `redirect` submit behavior', + history: VIEW_HISTORY, + // `delay` / `delayMS` are one keystroke and one capital from `delayMs`; + // the unit is in the name, so a bare `delay` is genuinely ambiguous and + // gets pointed at the spelled-out key rather than silently dropped. + aliases: { delay: 'delayMs', delayMS: 'delayMs', timeout: 'delayMs', to: 'url', href: 'url', target: 'url' }, + }, { kind: z.literal('redirect'), url: z.string(), delayMs: z.number().int().min(0).optional(), }), - z.object({ kind: z.literal('continue') }), - z.object({ kind: z.literal('next-record') }), + strictObject({ + surface: 'this `continue` submit behavior', + history: VIEW_HISTORY, + // `continue` resets the form and takes no options; an author configuring + // confirmation text wanted the `thank-you` kind instead. + guidance: { + title: 'The `continue` behavior takes no options — it just resets the form. For a confirmation panel use `{ kind: "thank-you", title, message }`.', + message: 'The `continue` behavior takes no options — it just resets the form. For a confirmation panel use `{ kind: "thank-you", title, message }`.', + }, + }, { kind: z.literal('continue') }), + strictObject({ + surface: 'this `next-record` submit behavior', + history: VIEW_HISTORY, + guidance: { + title: 'The `next-record` behavior takes no options — it advances to the next record. For a confirmation panel use `{ kind: "thank-you", title, message }`.', + message: 'The `next-record` behavior takes no options — it advances to the next record. For a confirmation panel use `{ kind: "thank-you", title, message }`.', + }, + }, { kind: z.literal('next-record') }), ]).optional().describe('Post-submit behavior'), /** @@ -1649,6 +1871,39 @@ function viewItemBaseShape() { * }); * ``` */ +/** + * [#4001 批 18] Both arms stay STRIP — measured `wire`, not unfinished work. + * + * This shape looks purely authorable ({@link defineViewItem} parses it, and + * objectui's create form validates its build output against it), which is why + * the ledger carried it as `authorable (p)`. It is also the FIRST member of + * {@link ViewMetadataSchema}, the schema `saveMetaItem` validates every + * persisted `view` body against — and that second role is a wire role. + * + * Traced end to end rather than inferred. objectui's "pin this view" control + * calls `dataSource.updateView(object, id, { isPinned })` + * (`app-shell/src/views/ObjectView.tsx:882`), and `updateView` + * (`data-objectstack/src/index.ts:2801`) GETs the stored item and PUTs + * `{ ...current, ...partial }`. For a standalone ViewItem record `current` + * carries `viewKind` AND `config`, so the merged body matches THIS member — + * the flattened-overlay members are excluded by their `config: z.undefined()` + * guard — and it arrives carrying `isPinned`, which this shape does not + * declare. Today it is stripped from the discarded parse result and the save + * succeeds. Closed, pinning a saved view would 422. + * + * That is the same "auxiliary Studio round-trip keys ride along" contract the + * two flattened members are explicitly `.strip()` for, reaching one member + * further than the block comment below realised. It is finding 16's + * `.extend()`/union trap in its most expensive form: the strictness of a union + * member is decided by a consumer none of this file's authoring doors mention. + * + * ⚠️ Closing this needs a DESIGN decision, not a posture flip: the authoring + * door (`defineViewItem`, Studio's create form) genuinely wants strict, and the + * metadata door genuinely needs the aux keys through. Splitting them — a strict + * authoring schema plus a `.strip()`-reopened wire member, exactly how + * `ListViewSchema` / `FormViewSchema` are already handled below — is one shape; + * leaving one lenient schema is another. Filed rather than guessed. + */ export const ViewItemSchema = lazySchema(() => z.discriminatedUnion('viewKind', [ z.object({ @@ -1714,9 +1969,22 @@ export function defineViewItem(config: z.input): ViewItem // record/container can never be rescued by this lenient branch. // // Auxiliary Studio round-trip keys (`isPinned`, `sortOrder`, …) ride along on -// every shape: all four members strip-parse (no `.strict()`), so an unknown -// top-level key never 422s — matching the "persist the payload verbatim" -// contract in `saveMetaItem` (it validates but stores the original item). +// the shapes Studio actually round-trips, matching the "persist the payload +// verbatim" contract in `saveMetaItem` (it validates but stores the original +// item). ⚠️ [#4001 批 18] The line that used to stand here said "all four +// members strip-parse (no `.strict()`)". That was true when it was written and +// is now false in one direction and load-bearing in the other — measured: +// +// • member 2 (the container) IS strict. `ViewSchema` was closed by an earlier +// batch, so `{ list: …, isPinned: true }` 422s. Not a regression: nothing +// sends it. `updateView` unwraps a container to its inner list config +// (`if (current?.list) current = current.list`) before merging, so a +// container body never reaches this union carrying an aux key. +// • members 1, 3 and 4 must keep stripping, and only 3 and 4 say so in code. +// Member 1 is the one `updateView` hits for a standalone ViewItem record — +// see the note on `ViewItemSchema`. +// +// Anyone closing a member here must re-run that trace, not re-read this comment. /** * Optional identity + structural-guard fields layered onto the two "flattened diff --git a/packages/spec/variant-docs.json b/packages/spec/variant-docs.json index 3668f85425..b21dfcef77 100644 --- a/packages/spec/variant-docs.json +++ b/packages/spec/variant-docs.json @@ -35,7 +35,9 @@ { "key": "type:action|component|dashboard|group|object|page|report|separator|url", "label": "app navigation item", - "docs": ["content/docs/ui/apps.mdx"], + "docs": [ + "content/docs/ui/apps.mdx" + ], "note": "The gate's founding case: apps.mdx claimed eight types and omitted `separator` (#4165)." }, { @@ -58,18 +60,23 @@ { "key": "language:expression|js", "label": "hook body language", - "docs": ["content/docs/automation/hook-bodies.mdx"] + "docs": [ + "content/docs/automation/hook-bodies.mdx" + ] }, { "key": "kind:file|http|object", "label": "knowledge source kind", - "docs": ["content/docs/protocol/knowledge.mdx"] + "docs": [ + "content/docs/protocol/knowledge.mdx" + ] }, - { "key": "type:inline|npm|remote", "label": "widget implementation", - "docs": ["content/docs/protocol/objectui/widget-contract.mdx"], + "docs": [ + "content/docs/protocol/objectui/widget-contract.mdx" + ], "note": "Was exempt as generated-reference-only until the #4001 audit: the page's `Widget Source` section documents all three variants, but in YAML examples, which the coverage matcher could not see. The exemption was recording a gate blind-spot as a doc gap." }, { @@ -81,13 +88,17 @@ { "key": "type:api-key|basic|bearer|none|oauth2", "label": "connector authentication", - "docs": ["content/docs/automation/connectors.mdx"], + "docs": [ + "content/docs/automation/connectors.mdx" + ], "note": "The runtime auth shape (ConnectorAuthConfigSchema) — five variants including the enterprise-tier `oauth2`. Was exempt generated-reference-only while no hand-written connector page existed; #4289 wrote the guide and bound it, per the old exemption's own instruction." }, { "key": "type:api-key|basic|bearer|none", "label": "connector auth (environment-artifact projection)", - "docs": ["content/docs/automation/connectors.mdx"], + "docs": [ + "content/docs/automation/connectors.mdx" + ], "note": "The declarative connector-instance auth shape (ADR-0097), reached via EnvironmentArtifactSchema.metadata.connectors[].auth: `credentialRef` references only, and `oauth2` deliberately absent (enterprise tier, ADR-0015) — the guide states that absence explicitly so authors don't read it as an omission." }, { @@ -149,6 +160,14 @@ "label": "migration changeset operation", "exempt": "not-authorable", "reason": "Emitted by the diff engine, not hand-written." + }, + { + "key": "kind:continue|next-record|redirect|thank-you", + "label": "form submit behavior", + "docs": [ + "content/docs/ui/forms.mdx" + ], + "note": "Newly VISIBLE to this gate at #4001 批 18, not newly authorable: the four variants were always authored on `FormView.submitBehavior`, but the shape was a plain `z.union`, which this walk cannot recognise as discriminated. Batch 18 made it `z.discriminatedUnion('kind')` so a bad key inside one arm reports `unrecognized_keys` at the key's own path instead of an `invalid_union` whose prescription the renderers flatten away (#5014) — and that turned an already-authorable vocabulary into a governed one. GOVERNED rather than exempt: ui/forms.mdx names all four (line 20 lists them together, and the `thank-you` and `redirect` shapes have worked examples)." } ] }