diff --git a/.changeset/gantt-tree-config-close-passthrough.md b/.changeset/gantt-tree-config-close-passthrough.md new file mode 100644 index 0000000000..c1305ed428 --- /dev/null +++ b/.changeset/gantt-tree-config-close-passthrough.md @@ -0,0 +1,55 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec)!: `GanttConfigSchema` / `TreeConfigSchema` refuse undeclared keys — both `.passthrough()` windows are closed and the ten gantt members plugin-gantt read through the window are declared (#15469) + + + +**BREAKING** accept-set narrowing on two published authorable config blocks — +`ListView.gantt` (`GanttConfigSchema`) and `ListView.tree` (`TreeConfigSchema`) +in `@objectstack/spec/ui`, reached through every view door (`defineView`, +`objects[].listViews`, the `view` metadata type): an UNDECLARED key inside +either block is now **refused** at parse with the `strictObject` named error +(`unrecognized_keys`; surface named, key echoed, closest declared key +suggested), where it used to pass through silently. Shipped as `minor` under +the repo's launch-window convention for breaking changes. Maintainer ruling +2026-09-05 on #15469 (director decision batch #41 item 2, verbatim 「同意」): +option A for both sites. + +Both blocks were `strictObject(…).passthrough()` — the campaign's own helper +applied and immediately undone, so `colourField` on a gantt block parsed green +and rendered an uncoloured bar while the same typo on a calendar or timeline +block got a named refusal. One `strictObject` applied and then undone is two +contracts on one surface (Prime Directive #12); the renderer-ahead window it +kept open is shut, and a renderer knob is declared in the spec before it is +read. + +**Newly declared on `GanttConfigSchema`** — all optional, types measured from +objectui's `GanttConfigExtensionFields` (`@object-ui/types/zod`) at pin +`a472b07`, each with a describe saying what plugin-gantt does with it: + +- `borderColorField: string` — field carrying a per-task alert stroke color +- `lockField: string` — field marking a row view-only (truthy = locked) +- `objectField: string` — field carrying the row's own object API name (mixed-object trees) +- `summaryExtent: 'children' | 'self'` — how a summary bar's span is computed +- `defaultCollapsedDepth: integer ≥ 0` — auto-collapse nodes at or below this depth +- `dependencyTypes: boolean` — whether the store persists dependency link types +- `timeZone: string` — IANA business time zone the calendar renders in +- `exportFileName: string` — base name for exported PNG / PDF files +- `interactions: { move?, resize?, progress?, link? : boolean }` — per-interaction switches (closed sub-object) +- `timeSegments: { dayStart?: string, bands: [{ key?, label, start, end, color? }], showMidnight?: boolean }` — shift segmentation for the day-mode timeline (closed sub-objects) + +**`TreeConfigSchema` declares nothing new.** plugin-tree's `getTreeConfig` +(objectui `a472b07`) reads exactly the four keys already declared — +`parentField`, `labelField`, `fields`, `defaultExpandedDepth` — from the `tree` +block, so the close refuses only what no renderer ever read. + +**Who is affected (measured, objectstack `f7db8f4fd`):** zero gantt or tree +blocks under `examples/**`, `content/docs/**`, `skills/**` or any package +fixture author one of the ten keys or any undeclared key; objectui's own gantt +fixtures author the ten and keep parsing because the keys are now declared. A +block carrying a key outside the declared set — a misspelling such as +`colourField`, or a renderer knob authored ahead of its declaration — is refused +on upgrade with the key named; fix the spelling, or declare the knob in the spec +first. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index a1914be5d7..f234773931 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1629,12 +1629,12 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | | **kanban** | `{ groupByField: string; summarizeField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string }` | optional | Calendar configuration — applies when the view renders as a calendar layout | -| **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … } & Record` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | +| **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | | **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | | **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout | -| **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer } & Record` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | +| **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **pageName** | `string` | optional | Published page this view mounts — required when `type: 'page'`, and refused on every other view type. Rendering is delegated to the existing page renderer; the page keeps its own `assignedProfiles` audience. | | **description** | `string \| Record` | optional | View description for documentation/tooltips | | **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration | @@ -1714,12 +1714,12 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | | **kanban** | `{ groupByField: string; summarizeField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string }` | optional | Calendar configuration — applies when the view renders as a calendar layout | -| **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … } & Record` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | +| **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | | **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | | **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout | -| **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer } & Record` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | +| **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **pageName** | `string` | optional | Published page this view mounts — required when `type: 'page'`, and refused on every other view type. Rendering is delegated to the existing page renderer; the page keeps its own `assignedProfiles` audience. | | **description** | `string \| Record` | optional | View description for documentation/tooltips | | **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index ea105ef57e..48418a28a6 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -376,12 +376,12 @@ const result = ApiMethod.parse(data); | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | | **kanban** | `{ groupByField: string; summarizeField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string }` | optional | Calendar configuration — applies when the view renders as a calendar layout | -| **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … } & Record` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | +| **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | | **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | | **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout | -| **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer } & Record` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | +| **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **pageName** | `string` | optional | Published page this view mounts — required when `type: 'page'`, and refused on every other view type. Rendering is delegated to the existing page renderer; the page keeps its own `assignedProfiles` audience. | | **description** | `string \| Record` | optional | View description for documentation/tooltips | | **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 1dd3275c86..da2b0cd7e6 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -573,6 +573,16 @@ Gallery/card view configuration | **quickFilters** | `{ field: string; label?: string; options?: (string \| object)[] }[]` | optional | Multi-select filter dropdowns rendered above the chart | | **autoZoomToFilter** | `boolean` | optional | When true (default), filtering zooms the range to the filtered tasks | | **viewMode** | `Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional | Timeline granularity — one column per day/week/month/quarter/year (also the resource-view column granularity; renderer default 'day') | +| **borderColorField** | `string` | optional | Field carrying a per-task alert stroke color — any CSS color or semantic palette name (red, orange, …): the bar keeps its fill and gains an outline plus halo in that color (e.g. red for overdue, orange for due-soon; typically a server-computed alert field). Empty or null means no stroke | +| **lockField** | `string` | optional | Field marking a row view-only (truthy means locked): a locked bar cannot be dragged or resized, its progress cannot be dragged, no dependency can be drawn from it and its inline-edit and edit/delete menu entries are hidden — clicking it (open drawer, jump) still works. Independent of the global readOnly; freezes individual levels while siblings stay editable | +| **objectField** | `string` | optional | Field carrying the row's OWN object API name, for mixed-object trees (an api provider composing parent-object rows with child-object rows): the detail drawer and its full-page link follow each row's real object instead of the view's bound object. Empty or missing falls back to the bound object | +| **summaryExtent** | `Enum<'children' \| 'self'>` | optional | How a summary bar's span is computed. 'children' (renderer default) rolls the bar up from its children — min start, max end, duration-weighted progress — and ignores the record's own dates; 'self' renders the record's OWN start, end and progress and falls back to rollup only for records without dates (use it when the parent's schedule is authoritative, e.g. a shift plan whose work-order children are locked history) | +| **defaultCollapsedDepth** | `integer` | optional | Auto-collapse tree nodes at or below this 0-indexed depth on first render (roots are depth 0): every node at that depth or deeper that has children starts folded; the user can still expand them. Omit to start fully expanded | +| **dependencyTypes** | `boolean` | optional | Whether the backing store persists dependency link TYPES (fs, ss, ff, sf); renderer default true. Set false when dependencies are bare predecessor ids: the link menu hides the type switcher (a switch would be silently reverted on refetch) and drag-created links are always finish-to-start | +| **timeZone** | `string` | optional | Business time zone, an IANA name such as 'Asia/Shanghai': the chart's calendar — shift bands, day columns, snapping, the today line, date labels — renders in this zone's wall time for every viewer instead of the browser's zone; persisted data stays real instants. An invalid name falls back to the browser zone with a console warning | +| **exportFileName** | `string` | optional | Base name for exported PNG and PDF files (e.g. the view's display label — the host's view schema often reaches the renderer stripped of label); falls back to the object schema label, then the object API name. A timestamp suffix is always appended | +| **interactions** | `{ move?: boolean; resize?: boolean; progress?: boolean; link?: boolean }` | optional | Per-interaction switches, each defaulting to true: allow bar moves but pin durations (resize: false), or keep the dependency UI read-only (link: false). They only narrow what readOnly and row locks already allow | +| **timeSegments** | `{ dayStart?: string; bands: object[]; showMidnight?: boolean }` | optional | Shift segmentation for the day-mode timeline: splits each shift-day (starting at dayStart) into the configured bands — a two-tier header (date over band), per-band tints and drag/resize snapping to band boundaries. No shift concept is hardcoded; bands are pure config. Off when omitted | ### Nested Shape: `GanttConfig.quickFilters[number]` @@ -582,6 +592,23 @@ Gallery/card view configuration | **label** | `string` | optional | Trigger label (falls back to the field label) | | **options** | `(string \| { value: string \| number; label?: string })[]` | optional | Explicit option override for fixed enums | +### Nested Shape: `GanttConfig.interactions` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **move** | `boolean` | optional | Bar and subtree dragging along the timeline (default true) | +| **resize** | `boolean` | optional | Edge resize grips that change a duration (default true) | +| **progress** | `boolean` | optional | The progress drag handle (default true) | +| **link** | `boolean` | optional | Dependency UI — the drag-to-link dots and the create/delete menu entries (default true) | + +### Nested Shape: `GanttConfig.timeSegments` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **dayStart** | `string` | optional | Clock time the shift-day begins, 'HH:mm' (24h); the day column starts here and runs 24h. Renderer default '00:00' (calendar day); for an 08:00 handover set '08:00' | +| **bands** | `{ key?: string; label: string; start: string; end: string; … }[]` | ✅ | Ordered bands covering the 24h shift-day, beginning at dayStart | +| **showMidnight** | `boolean` | optional | Draw the dashed calendar-midnight (00:00) cue inside cross-midnight bands; renderer default true | + --- @@ -773,12 +800,12 @@ Map view configuration | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | | **kanban** | `{ groupByField: string; summarizeField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string }` | optional | Calendar configuration — applies when the view renders as a calendar layout | -| **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … } & Record` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | +| **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | | **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | | **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout | -| **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer } & Record` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | +| **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **pageName** | `string` | optional | Published page this view mounts — required when `type: 'page'`, and refused on every other view type. Rendering is delegated to the existing page renderer; the page keeps its own `assignedProfiles` audience. | | **description** | `string \| Record` | optional | View description for documentation/tooltips | | **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration | @@ -940,6 +967,16 @@ View filter rule | **quickFilters** | `{ field: string; label?: string; options?: (string \| object)[] }[]` | optional | Multi-select filter dropdowns rendered above the chart | | **autoZoomToFilter** | `boolean` | optional | When true (default), filtering zooms the range to the filtered tasks | | **viewMode** | `Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional | Timeline granularity — one column per day/week/month/quarter/year (also the resource-view column granularity; renderer default 'day') | +| **borderColorField** | `string` | optional | Field carrying a per-task alert stroke color — any CSS color or semantic palette name (red, orange, …): the bar keeps its fill and gains an outline plus halo in that color (e.g. red for overdue, orange for due-soon; typically a server-computed alert field). Empty or null means no stroke | +| **lockField** | `string` | optional | Field marking a row view-only (truthy means locked): a locked bar cannot be dragged or resized, its progress cannot be dragged, no dependency can be drawn from it and its inline-edit and edit/delete menu entries are hidden — clicking it (open drawer, jump) still works. Independent of the global readOnly; freezes individual levels while siblings stay editable | +| **objectField** | `string` | optional | Field carrying the row's OWN object API name, for mixed-object trees (an api provider composing parent-object rows with child-object rows): the detail drawer and its full-page link follow each row's real object instead of the view's bound object. Empty or missing falls back to the bound object | +| **summaryExtent** | `Enum<'children' \| 'self'>` | optional | How a summary bar's span is computed. 'children' (renderer default) rolls the bar up from its children — min start, max end, duration-weighted progress — and ignores the record's own dates; 'self' renders the record's OWN start, end and progress and falls back to rollup only for records without dates (use it when the parent's schedule is authoritative, e.g. a shift plan whose work-order children are locked history) | +| **defaultCollapsedDepth** | `integer` | optional | Auto-collapse tree nodes at or below this 0-indexed depth on first render (roots are depth 0): every node at that depth or deeper that has children starts folded; the user can still expand them. Omit to start fully expanded | +| **dependencyTypes** | `boolean` | optional | Whether the backing store persists dependency link TYPES (fs, ss, ff, sf); renderer default true. Set false when dependencies are bare predecessor ids: the link menu hides the type switcher (a switch would be silently reverted on refetch) and drag-created links are always finish-to-start | +| **timeZone** | `string` | optional | Business time zone, an IANA name such as 'Asia/Shanghai': the chart's calendar — shift bands, day columns, snapping, the today line, date labels — renders in this zone's wall time for every viewer instead of the browser's zone; persisted data stays real instants. An invalid name falls back to the browser zone with a console warning | +| **exportFileName** | `string` | optional | Base name for exported PNG and PDF files (e.g. the view's display label — the host's view schema often reaches the renderer stripped of label); falls back to the object schema label, then the object API name. A timestamp suffix is always appended | +| **interactions** | `{ move?: boolean; resize?: boolean; progress?: boolean; link?: boolean }` | optional | Per-interaction switches, each defaulting to true: allow bar moves but pin durations (resize: false), or keep the dependency UI read-only (link: false). They only narrow what readOnly and row locks already allow | +| **timeSegments** | `{ dayStart?: string; bands: object[]; showMidnight?: boolean }` | optional | Shift segmentation for the day-mode timeline: splits each shift-day (starting at dayStart) into the configured bands — a two-tier header (date over band), per-band tints and drag/resize snapping to band boundaries. No shift concept is hardcoded; bands are pure config. Off when omitted | ### Nested Shape: `ListView.gallery` @@ -1168,12 +1205,12 @@ Tab configuration for multi-tab view interface | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | | **kanban** | `{ groupByField: string; summarizeField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string }` | optional | Calendar configuration — applies when the view renders as a calendar layout | -| **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … } & Record` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | +| **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | | **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | | **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout | -| **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer } & Record` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | +| **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **pageName** | `string` | optional | Published page this view mounts — required when `type: 'page'`, and refused on every other view type. Rendering is delegated to the existing page renderer; the page keeps its own `assignedProfiles` audience. | | **description** | `string \| Record` | optional | View description for documentation/tooltips | | **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration | @@ -1326,6 +1363,16 @@ View filter rule | **quickFilters** | `{ field: string; label?: string; options?: (string \| object)[] }[]` | optional | Multi-select filter dropdowns rendered above the chart | | **autoZoomToFilter** | `boolean` | optional | When true (default), filtering zooms the range to the filtered tasks | | **viewMode** | `Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional | Timeline granularity — one column per day/week/month/quarter/year (also the resource-view column granularity; renderer default 'day') | +| **borderColorField** | `string` | optional | Field carrying a per-task alert stroke color — any CSS color or semantic palette name (red, orange, …): the bar keeps its fill and gains an outline plus halo in that color (e.g. red for overdue, orange for due-soon; typically a server-computed alert field). Empty or null means no stroke | +| **lockField** | `string` | optional | Field marking a row view-only (truthy means locked): a locked bar cannot be dragged or resized, its progress cannot be dragged, no dependency can be drawn from it and its inline-edit and edit/delete menu entries are hidden — clicking it (open drawer, jump) still works. Independent of the global readOnly; freezes individual levels while siblings stay editable | +| **objectField** | `string` | optional | Field carrying the row's OWN object API name, for mixed-object trees (an api provider composing parent-object rows with child-object rows): the detail drawer and its full-page link follow each row's real object instead of the view's bound object. Empty or missing falls back to the bound object | +| **summaryExtent** | `Enum<'children' \| 'self'>` | optional | How a summary bar's span is computed. 'children' (renderer default) rolls the bar up from its children — min start, max end, duration-weighted progress — and ignores the record's own dates; 'self' renders the record's OWN start, end and progress and falls back to rollup only for records without dates (use it when the parent's schedule is authoritative, e.g. a shift plan whose work-order children are locked history) | +| **defaultCollapsedDepth** | `integer` | optional | Auto-collapse tree nodes at or below this 0-indexed depth on first render (roots are depth 0): every node at that depth or deeper that has children starts folded; the user can still expand them. Omit to start fully expanded | +| **dependencyTypes** | `boolean` | optional | Whether the backing store persists dependency link TYPES (fs, ss, ff, sf); renderer default true. Set false when dependencies are bare predecessor ids: the link menu hides the type switcher (a switch would be silently reverted on refetch) and drag-created links are always finish-to-start | +| **timeZone** | `string` | optional | Business time zone, an IANA name such as 'Asia/Shanghai': the chart's calendar — shift bands, day columns, snapping, the today line, date labels — renders in this zone's wall time for every viewer instead of the browser's zone; persisted data stays real instants. An invalid name falls back to the browser zone with a console warning | +| **exportFileName** | `string` | optional | Base name for exported PNG and PDF files (e.g. the view's display label — the host's view schema often reaches the renderer stripped of label); falls back to the object schema label, then the object API name. A timestamp suffix is always appended | +| **interactions** | `{ move?: boolean; resize?: boolean; progress?: boolean; link?: boolean }` | optional | Per-interaction switches, each defaulting to true: allow bar moves but pin durations (resize: false), or keep the dependency UI read-only (link: false). They only narrow what readOnly and row locks already allow | +| **timeSegments** | `{ dayStart?: string; bands: object[]; showMidnight?: boolean }` | optional | Shift segmentation for the day-mode timeline: splits each shift-day (starting at dayStart) into the configured bands — a two-tier header (date over band), per-band tints and drag/resize snapping to band boundaries. No shift concept is hardcoded; bands are pure config. Off when omitted | ### Nested Shape: `ObjectListView.gallery` @@ -1754,12 +1801,12 @@ Tab configuration for multi-tab view interface | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | | **kanban** | `{ groupByField: string; summarizeField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string }` | optional | Calendar configuration — applies when the view renders as a calendar layout | -| **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … } & Record` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | +| **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | | **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | | **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout | -| **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer } & Record` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | +| **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **pageName** | `string` | optional | Published page this view mounts — required when `type: 'page'`, and refused on every other view type. Rendering is delegated to the existing page renderer; the page keeps its own `assignedProfiles` audience. | | **description** | `string \| Record` | optional | View description for documentation/tooltips | | **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration | @@ -1839,12 +1886,12 @@ Tab configuration for multi-tab view interface | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | | **kanban** | `{ groupByField: string; summarizeField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string }` | optional | Calendar configuration — applies when the view renders as a calendar layout | -| **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … } & Record` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | +| **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | | **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | | **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout | -| **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer } & Record` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | +| **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **pageName** | `string` | optional | Published page this view mounts — required when `type: 'page'`, and refused on every other view type. Rendering is delegated to the existing page renderer; the page keeps its own `assignedProfiles` audience. | | **description** | `string \| Record` | optional | View description for documentation/tooltips | | **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration | @@ -2080,12 +2127,12 @@ This schema accepts one of the following structures: | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | | **kanban** | `{ groupByField: string; summarizeField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string }` | optional | Calendar configuration — applies when the view renders as a calendar layout | -| **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … } & Record` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | +| **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | | **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | | **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout | -| **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer } & Record` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | +| **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **pageName** | `string` | optional | Published page this view mounts — required when `type: 'page'`, and refused on every other view type. Rendering is delegated to the existing page renderer; the page keeps its own `assignedProfiles` audience. | | **description** | `string \| Record` | optional | View description for documentation/tooltips | | **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration | @@ -2256,12 +2303,12 @@ This schema accepts one of the following structures: | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | | **kanban** | `{ groupByField: string; summarizeField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string }` | optional | Calendar configuration — applies when the view renders as a calendar layout | -| **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … } & Record` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | +| **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | | **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | | **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout | -| **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer } & Record` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | +| **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **pageName** | `string` | optional | Published page this view mounts — required when `type: 'page'`, and refused on every other view type. Rendering is delegated to the existing page renderer; the page keeps its own `assignedProfiles` audience. | | **description** | `string \| Record` | optional | View description for documentation/tooltips | | **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration | 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 1716a37d5f..54ea4ec0f9 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 | 441 | +| Object sites in them | 444 | | Still-open (strip) sites | 124 | | Files carrying at least one | 22 | @@ -44,12 +44,12 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `ui/` | 169 | 157 | 5 | 0 | 7 | +| `ui/` | 172 | 162 | 3 | 0 | 7 | | `data/` | 157 | 76 | 1 | 0 | 80 | | `automation/` | 68 | 43 | 0 | 1 | 24 | | `security/` | 20 | 7 | 0 | 0 | 13 | | `studio/` | 27 | 27 | 0 | 0 | 0 | -| **total** | **441** | **310** | **6** | **1** | **124** | +| **total** | **444** | **315** | **4** | **1** | **124** | ## File-level triage — site counts @@ -74,9 +74,9 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `report.zod.ts` | 3 | | `responsive.zod.ts` | 1 | | `sharing.zod.ts` | 1 | -| `view.zod.ts` | 58 | +| `view.zod.ts` | 61 | | `widget.zod.ts` | 1 | -| **total** | **169** | +| **total** | **172** | ### `data/` — sites @@ -155,15 +155,15 @@ over it is here. ### `ui/` — open -**7 strip of 169**, in 4 file(s). +**7 strip of 172**, in 4 file(s). | File | Strip | Sites | |---|---|---| | `action-params.zod.ts` | 1 | 1 | | `app.zod.ts` | 1 | 18 | -| `view.zod.ts` | 4 | 58 | +| `view.zod.ts` | 4 | 61 | | `widget.zod.ts` | 1 | 1 | -| **total** | **7** | **169** | +| **total** | **7** | **172** | | Bucket | Sites | |---|---| diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index 16eff59a5e..29f7d4708d 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -937,7 +937,7 @@ next person to open that file will look. | File | Class | Batch | |---|---|---| -| `view.zod.ts` | mixed · 1 authorable, 3 wire | **15 of 20 closed at #4001 批 18**, a sixteenth (`UserFiltersSchema`) at **#5073** once its protocol blocker was adjudicated, a seventeenth — `ViewFilterRuleSchema`, closed by an EARLIER wave — reopened at **#5114**, and then the file's last authoring debt cleared at **#5074**, which closed `ViewItemSchema` (×2 arms), `ListView.sort` AND `ViewFilterRuleSchema` in one structural change. **The strip count went 5 → 3, and the arithmetic is the finding, not the number: FOUR sites closed and TWO were ADDED** — the two arms of the new `ViewItemWireSchema`, which are strip BY DESIGN. That is why this row's Class cell is now a split (`1 authorable, 2 wire`) rather than a smaller `authorable` count: the wire contract that used to live on "the member nobody closed" now has a name, and this map measures posture, not intent. Closed: `ViewDataSchema`'s four provider arms, `UserFilterField.options`, `GanttQuickFilter.options`, `GanttConfig.tooltipFields`, `ListView.conditionalFormatting` / `.emptyState`, `FormFieldBase.keyField`, `FormView.subforms`, and `submitBehavior`'s four arms. Reachability was measured, not assumed: a BFS from all 24 metadata-type roots plus `ObjectStackSchema` resolves every one `root-graph`, with `ViewSchema`/`FormViewSchema`/`ViewItemSchema`/`PageSchema` as positive controls and 批 13's no-door shapes UNREACHABLE **in the same run** — and the instrument had to be fixed first: `lazySchema` returns a Proxy, but a carrier writes `X.optional()`, which RESOLVES it, so the closure holds the real instance and comparing the Proxy alone false-negatived `ViewDataSchema` (caught by cross-checking its two literal carrier keys, not by trusting the reading). ⚠️ **Re-checked against #5056**: every 批 18 target is `root-graph` by **identity**, so **none** of the fifteen rests on the `derived-clone` bridge that 批 16 found can mark a dead shape reachable. The one `derived-clone` verdict in the run is `ListViewSchema` — a positive CONTROL, not a target, and independently identity-reachable via `ObjectListViewSchema`. Every closed shape also has a literal carrier key in this file and a named parse door (`defineView` / `defineViewItem` / the `view` metadata-type schema / objectui's `GanttConfigSchema.safeParse` at `plugin-gantt/src/ObjectGantt.tsx:408`) — the strong-evidence class #5056 leaves standing. ⚠️ **`ListView.sort` was closed, REVERTED, and closed again at #5074 — the round trip is the file's most useful finding.** It carried `direction → order`, the #4721 alias for the identical tuple (`{field, direction:'desc'}` parsed to `{field, order:'asc'}` — a silently REVERSED sort). The full suite then failed one case: `view-metadata-schema.test.ts` pins `sort: [{ id, field, order }]` as the exact body a console column-sort PUT persists, and objectui stamps that `id` per row (`components/src/custom/sort-builder.tsx:68`/`:94`, `crypto.randomUUID()`). **The mechanism governs every nested block in this file and is the opposite of what the union's own comment implies: `.strip()` does NOT recurse.** `ViewMetadataSchema` rescues Studio's round-trip keys by making its flattened members `.strip()`, but that re-opens the TOP level only — a nested block closed inside `ListViewSchema` is still reached through that member, so a console-stamped key inside it becomes a 422 regardless. `id` was deliberately NOT declared to silence it: it is a React list key, and declaring it would put a UI artifact on the authorable surface and tell an AI author to emit one. **#5074 supplied the missing half and the shape is now CLOSED**: the write door removes the declared decoration vocabulary (`VIEW_CONSOLE_ROW_DECORATIONS` / `stripViewConsoleDecorations`, the mirror of `stripReadDecorations`) BEFORE the union runs, so the opening is recursive-effective where a member-level `.strip()` can never be, and the authoring surface never grew the key. The `direction → order` alias came back with it. Curation on what DID close is anchored to named siblings: an option `count` gets a wrong-layer pointer to `showCount` because objectui COMPUTES it per render; and a bare `name` on the `object` data source is deliberately NOT aliased — it is a real key on the view ITEM, so a rename would be finding 7 again. `submitBehavior` became a `discriminatedUnion` on the `kind` literal it already required: as a plain union of four strict members the rejection is an `invalid_union` whose prescription #5014 measured the renderers flattening away. ⚠️ **`GanttConfigSchema` / `TreeConfigSchema` are `strictObject(…).passthrough()`** — open at the parent by design, and this ledger's own counter used to read them as `strict`, because `postureOf` returned early on the `strictObject` idiom instead of walking the chain. **Fixed at #5072**: the idiom now seeds the initial posture and the chain always runs, so the two read `passthrough` and the directory's strict count drops by 2. The strip count was never affected — neither posture is strip — so this row's numbers do not move. **`UserFiltersSchema` is CLOSED as of #5073, and it is the one site in this file whose blocker was never a strictness question.** Closing it would have 422'd `allowAddTab` — a key objectui's renderer reads (`plugin-list/src/UserFilters.tsx:182`/`:742`) and the spec never declared; because `saveMetaItem` validates but persists the ORIGINAL body, the stripped key still reached the renderer, so the capability WORKED and closing would have removed it rather than making a silent failure loud. 批 18 stopped and filed rather than guessing, and the maintainer adjudicated **promote, then close, in one PR** (2026-08-04): `allowAddTab` is now DECLARED here, so the capability is discoverable from the contract (JSON Schema / Studio SchemaForm / an AI author) instead of living in one React file, and the shape closes behind it with no intermediate state. The rejected option was `SANCTIONED_LOCAL` in objectui, which would have made spec and objectui two sources of truth for one contract — the fork #2231's derive-by-reference exists to prevent (PD#12) — and would have taught authors to delete a working key with a rejection that was itself "correct" (finding 7). Two details the close is worth remembering for. **(a)** The promotion is scoped to what the renderer really does: the add-tab button objectui renders carries no click handler, so `allowAddTab` declares that the affordance RENDERS and deliberately says nothing about creating presets — a `.describe()` promising more would be PD#10's advertise-what-you-don't-deliver, and the renderer gap is filed as **#5236**. **(b)** The 批 6e reliance question resolved exactly as predicted — `ObjectUserFiltersSchema` is `.omit()`ed off this base and `.omit()` inherits posture, so the pin flipped from "drops" to "rejects", which is wanted (the CLI lint `validate-list-view-mode.ts` was already reporting these) — but inheriting the posture also inherits the base's ERROR MAP, whose `knownKeys` were read from the base shape and therefore still listed the omitted keys. Measured on the flip: `tab` was answered *"Did you mean `tab` → `tabs`?"*, steering the author at the one key that surface refuses — finding 7 produced by the fix for finding 7. So the object variant now carries its own map built over the OMITTED shape (the shape still derived by `.omit()`, so #2231 holds), with `guidance` pointing all three page-only keys at `listViews`. **⚠️ #5074 — the authoring/wire SPLIT, and the row's headline.** `ViewItemSchema` wore two contracts: the authoring gate (`defineViewItem`, objectui's view-create form, which validates `createBuildBody`'s output against the real spec schema) and member 1 of `ViewMetadataSchema`, the union `saveMetaItem` validates every persisted `view` body against. The wire role was measured, not inferred — objectui's pin control PUTs `{...storedItem, isPinned}` (`ObjectView.tsx:882` → `data-objectstack/src/index.ts:2801`); a stored ViewItem record carries `viewKind` AND `config`, so the merged body lands on member 1 (the flattened members are excluded by their `config: z.undefined()` guard) and closing the one schema would have 422'd pinning a saved view. The maintainer ruled **split** (2026-08-04), and the two-axis reasoning is worth keeping: `defineViewItem({name, object, viewKind, confg: {…}})` — one letter — used to strip the typo and hand back a ViewItem with **no view configuration at all**, parsed clean, which is #1535's `workflows: [...]` replayed on the file's densest authoring surface. `ViewItemSchema` is now `strictObject` on both arms; `ViewItemWireSchema` is the `.strip()` wire variant, built from the SAME `viewItemArmShape()` (derive-by-reference, #2231 — a `discriminatedUnion` cannot be `.extend()`ed, so sharing the shape factory is what keeps one contract from becoming two transcriptions), and `isPinned`/`sortOrder` are DECLARED on it — an explicit home, instead of surviving because nobody closed the member. **The scope addendum's hard requirement was recursive-effective openness, and that is the part a posture flip could not deliver.** `.strip()` re-opens a member's TOP level only, so the two console-decorated NESTED blocks (`ListView.sort[].id`, `ViewFilterRule.id`) were still reached at full strictness through it. The route taken is the addendum's second sanctioned one: a declared decoration vocabulary stripped before validation, at the wire door, reaching every carrier at every depth — including ones added later, which a hand-maintained parallel wire tree would not. It is deliberately NOT a second schema tree (PD#12's fork) and deliberately NOT a declared `id` (批 18 Q1's two-axis rejection: a React list key on the authoring surface teaches AI authors to emit UUIDs). Two landmines were named in the ruling and both are pinned in `view-authoring-wire-split.test.ts` §5: `z.toJSONSchema()` must still emit a four-member `anyOf` (the `/api/v1/meta/types/view` endpoint feeds Studio's SchemaForm from it — it does; a pipe converts to its output side, asserted in BOTH io directions), and the `lazySchema` Proxy's ADR-0089 D3a crash (`Cannot set properties of undefined (setting 'ref')`) must not recur under a pipe-rooted lazy schema — it does not, and each new schema is converted directly rather than only through its parent. **One real hazard the change surfaced, fixed in the same PR:** a `z.preprocess` at a registered root put TWO gate walkers into the exact blind spot #4488 had already found and fixed in `check-liveness.mts` — `metadata-authoring-lint.ts` and `metadata-form-zod-reconciliation.test.ts` both unwrapped a pipe via `def.in`, which for a preprocess is the TRANSFORM, so each reported `view` as *not key-bearing* and silently stopped covering it. Caught by their own coverage assertions (`lintables.length >= 1`, `root schema is not key-bearing`), which is precisely what those assertions exist for; both now prefer whichever side is not the transform. **A gate going quiet is worse than a gate failing** — and the pattern will recur on the next preprocess-rooted registration, so it is recorded here rather than only in the diff. **Still open, one site, measured:** `FormFieldBaseSchema` — a module-private BASE whose sole consumer already applies `.strict()` plus the ADR-0089 visibility error map (`strictVisibilityError` when this was measured; folded into the shared template as `VISIBILITY_STRICT_OPTIONS` + `strictObjectError` at #6619, deliberately WITHOUT closing the base — the site keeps its literal `z.object(` spelling so this instrument keeps counting it); the door is closed, the ledger counts the base. The two remaining strip sites beyond it are `ViewItemWireSchema`'s arms, which are `wire` by design and are not debt. `ViewFilterRuleSchema` — **the same wire contamination, one block over, and it was already LIVE on `main`** (#5114): closed by an earlier wave, while objectui's filter builder stamps `id: crypto.randomUUID()` on every row it writes (`components/src/custom/filter-builder.tsx:228`, re-stamped on read-back at `plugin-view/src/config/view-config-utils.ts:146`/`:160`), and `saveMetaItem` persists the AUTHORED body verbatim — so saving a filter from the console 422'd, on all three paths including the flattened overlay that is the body actually PUT. Reopened as a p1 hotfix; `id` deliberately NOT declared, for the reason given for `sort` above. **That reopen was explicitly PROVISIONAL — "pending #5074" — and #5074 retired it rather than leaving it standing: the shape is CLOSED again, by the same decoration strip that closed `sort`, so the authoring gate rejects `id` by name while the console's own three paths still parse.** Its pin file now asserts the split per door, and the direction is the INVERTED one worth flagging to the next reader: probes 1/3 and 2/3 were GREEN before #5074 and are RED after (that IS the close), while 3/3 — the body the console actually PUTs — is green on BOTH sides and must stay so; a file that only asserted "the console body parses" would have passed unchanged through a change that quietly declared `id` as authorable. Two details worth keeping: the overlay path's rejection surfaces as `invalid_union` / *"Invalid input"* — the #5014 flattening, so the key that caused it is not in the message the author sees, which is why this sat on `main` unnoticed; and the reopening was verified in BOTH directions (re-close it and 7 assertions in `view-filter-rule-wire-id.test.ts` go red, while that file's two mechanism CONTROLS — top-level aux key rides, nested `emptyState` still rejects — stay green either way, which is what makes them controls). #5074's scope addendum named this site; the gate it was waiting on — a wire opening that REACHES a nested block — landed with it. Each verdict is recorded in three places (schema JSDoc + `view-strictness-batch18.test.ts` / `view-filter-rule-wire-id.test.ts` + this row). **#9933 added the file's fourth strip site, and it is `wire` by design, not debt: `ViewColumnStateSchema`** — the inner shape of the runtime-only `columnState` personalization overlay key (per-user column order/widths the console grid persists; maintainer-accepted ruling of 2026-08-19 on objectui#5233, mirroring objectui's `gridNonAuthorKeys` disposition). It is deliberately NOT `.strict()`: the console owns the internals of this per-user state, so a future console-written inner key must not 422 against an older server (`saveMetaItem` stores the original body verbatim either way). It is module-private, carried only by the wire faces (`flattenedViewOverlayFields` on the two overlay members, `viewItemWireFields` on the ViewItem wire member), and the AUTHORING doors reject the carrier key by name with a runtime-only prescription (`VIEW_ITEM_SURFACE.guidance` + `ListViewSchema`'s `guidance`) — pinned both ways in `view-metadata-schema.test.ts` §#9933 | +| `view.zod.ts` | mixed · 1 authorable, 3 wire | **15 of 20 closed at #4001 批 18**, a sixteenth (`UserFiltersSchema`) at **#5073** once its protocol blocker was adjudicated, a seventeenth — `ViewFilterRuleSchema`, closed by an EARLIER wave — reopened at **#5114**, and then the file's last authoring debt cleared at **#5074**, which closed `ViewItemSchema` (×2 arms), `ListView.sort` AND `ViewFilterRuleSchema` in one structural change. **The strip count went 5 → 3, and the arithmetic is the finding, not the number: FOUR sites closed and TWO were ADDED** — the two arms of the new `ViewItemWireSchema`, which are strip BY DESIGN. That is why this row's Class cell is now a split (`1 authorable, 2 wire`) rather than a smaller `authorable` count: the wire contract that used to live on "the member nobody closed" now has a name, and this map measures posture, not intent. Closed: `ViewDataSchema`'s four provider arms, `UserFilterField.options`, `GanttQuickFilter.options`, `GanttConfig.tooltipFields`, `ListView.conditionalFormatting` / `.emptyState`, `FormFieldBase.keyField`, `FormView.subforms`, and `submitBehavior`'s four arms. Reachability was measured, not assumed: a BFS from all 24 metadata-type roots plus `ObjectStackSchema` resolves every one `root-graph`, with `ViewSchema`/`FormViewSchema`/`ViewItemSchema`/`PageSchema` as positive controls and 批 13's no-door shapes UNREACHABLE **in the same run** — and the instrument had to be fixed first: `lazySchema` returns a Proxy, but a carrier writes `X.optional()`, which RESOLVES it, so the closure holds the real instance and comparing the Proxy alone false-negatived `ViewDataSchema` (caught by cross-checking its two literal carrier keys, not by trusting the reading). ⚠️ **Re-checked against #5056**: every 批 18 target is `root-graph` by **identity**, so **none** of the fifteen rests on the `derived-clone` bridge that 批 16 found can mark a dead shape reachable. The one `derived-clone` verdict in the run is `ListViewSchema` — a positive CONTROL, not a target, and independently identity-reachable via `ObjectListViewSchema`. Every closed shape also has a literal carrier key in this file and a named parse door (`defineView` / `defineViewItem` / the `view` metadata-type schema / objectui's `GanttConfigSchema.safeParse` at `plugin-gantt/src/ObjectGantt.tsx:408`) — the strong-evidence class #5056 leaves standing. ⚠️ **`ListView.sort` was closed, REVERTED, and closed again at #5074 — the round trip is the file's most useful finding.** It carried `direction → order`, the #4721 alias for the identical tuple (`{field, direction:'desc'}` parsed to `{field, order:'asc'}` — a silently REVERSED sort). The full suite then failed one case: `view-metadata-schema.test.ts` pins `sort: [{ id, field, order }]` as the exact body a console column-sort PUT persists, and objectui stamps that `id` per row (`components/src/custom/sort-builder.tsx:68`/`:94`, `crypto.randomUUID()`). **The mechanism governs every nested block in this file and is the opposite of what the union's own comment implies: `.strip()` does NOT recurse.** `ViewMetadataSchema` rescues Studio's round-trip keys by making its flattened members `.strip()`, but that re-opens the TOP level only — a nested block closed inside `ListViewSchema` is still reached through that member, so a console-stamped key inside it becomes a 422 regardless. `id` was deliberately NOT declared to silence it: it is a React list key, and declaring it would put a UI artifact on the authorable surface and tell an AI author to emit one. **#5074 supplied the missing half and the shape is now CLOSED**: the write door removes the declared decoration vocabulary (`VIEW_CONSOLE_ROW_DECORATIONS` / `stripViewConsoleDecorations`, the mirror of `stripReadDecorations`) BEFORE the union runs, so the opening is recursive-effective where a member-level `.strip()` can never be, and the authoring surface never grew the key. The `direction → order` alias came back with it. Curation on what DID close is anchored to named siblings: an option `count` gets a wrong-layer pointer to `showCount` because objectui COMPUTES it per render; and a bare `name` on the `object` data source is deliberately NOT aliased — it is a real key on the view ITEM, so a rename would be finding 7 again. `submitBehavior` became a `discriminatedUnion` on the `kind` literal it already required: as a plain union of four strict members the rejection is an `invalid_union` whose prescription #5014 measured the renderers flattening away. ⚠️ **`GanttConfigSchema` / `TreeConfigSchema` are `strictObject(…).passthrough()`** — open at the parent by design, and this ledger's own counter used to read them as `strict`, because `postureOf` returned early on the `strictObject` idiom instead of walking the chain. **Fixed at #5072**: the idiom now seeds the initial posture and the chain always runs, so the two read `passthrough` and the directory's strict count drops by 2. The strip count was never affected — neither posture is strip — so this row's numbers do not move. **`UserFiltersSchema` is CLOSED as of #5073, and it is the one site in this file whose blocker was never a strictness question.** Closing it would have 422'd `allowAddTab` — a key objectui's renderer reads (`plugin-list/src/UserFilters.tsx:182`/`:742`) and the spec never declared; because `saveMetaItem` validates but persists the ORIGINAL body, the stripped key still reached the renderer, so the capability WORKED and closing would have removed it rather than making a silent failure loud. 批 18 stopped and filed rather than guessing, and the maintainer adjudicated **promote, then close, in one PR** (2026-08-04): `allowAddTab` is now DECLARED here, so the capability is discoverable from the contract (JSON Schema / Studio SchemaForm / an AI author) instead of living in one React file, and the shape closes behind it with no intermediate state. The rejected option was `SANCTIONED_LOCAL` in objectui, which would have made spec and objectui two sources of truth for one contract — the fork #2231's derive-by-reference exists to prevent (PD#12) — and would have taught authors to delete a working key with a rejection that was itself "correct" (finding 7). Two details the close is worth remembering for. **(a)** The promotion is scoped to what the renderer really does: the add-tab button objectui renders carries no click handler, so `allowAddTab` declares that the affordance RENDERS and deliberately says nothing about creating presets — a `.describe()` promising more would be PD#10's advertise-what-you-don't-deliver, and the renderer gap is filed as **#5236**. **(b)** The 批 6e reliance question resolved exactly as predicted — `ObjectUserFiltersSchema` is `.omit()`ed off this base and `.omit()` inherits posture, so the pin flipped from "drops" to "rejects", which is wanted (the CLI lint `validate-list-view-mode.ts` was already reporting these) — but inheriting the posture also inherits the base's ERROR MAP, whose `knownKeys` were read from the base shape and therefore still listed the omitted keys. Measured on the flip: `tab` was answered *"Did you mean `tab` → `tabs`?"*, steering the author at the one key that surface refuses — finding 7 produced by the fix for finding 7. So the object variant now carries its own map built over the OMITTED shape (the shape still derived by `.omit()`, so #2231 holds), with `guidance` pointing all three page-only keys at `listViews`. **⚠️ #5074 — the authoring/wire SPLIT, and the row's headline.** `ViewItemSchema` wore two contracts: the authoring gate (`defineViewItem`, objectui's view-create form, which validates `createBuildBody`'s output against the real spec schema) and member 1 of `ViewMetadataSchema`, the union `saveMetaItem` validates every persisted `view` body against. The wire role was measured, not inferred — objectui's pin control PUTs `{...storedItem, isPinned}` (`ObjectView.tsx:882` → `data-objectstack/src/index.ts:2801`); a stored ViewItem record carries `viewKind` AND `config`, so the merged body lands on member 1 (the flattened members are excluded by their `config: z.undefined()` guard) and closing the one schema would have 422'd pinning a saved view. The maintainer ruled **split** (2026-08-04), and the two-axis reasoning is worth keeping: `defineViewItem({name, object, viewKind, confg: {…}})` — one letter — used to strip the typo and hand back a ViewItem with **no view configuration at all**, parsed clean, which is #1535's `workflows: [...]` replayed on the file's densest authoring surface. `ViewItemSchema` is now `strictObject` on both arms; `ViewItemWireSchema` is the `.strip()` wire variant, built from the SAME `viewItemArmShape()` (derive-by-reference, #2231 — a `discriminatedUnion` cannot be `.extend()`ed, so sharing the shape factory is what keeps one contract from becoming two transcriptions), and `isPinned`/`sortOrder` are DECLARED on it — an explicit home, instead of surviving because nobody closed the member. **The scope addendum's hard requirement was recursive-effective openness, and that is the part a posture flip could not deliver.** `.strip()` re-opens a member's TOP level only, so the two console-decorated NESTED blocks (`ListView.sort[].id`, `ViewFilterRule.id`) were still reached at full strictness through it. The route taken is the addendum's second sanctioned one: a declared decoration vocabulary stripped before validation, at the wire door, reaching every carrier at every depth — including ones added later, which a hand-maintained parallel wire tree would not. It is deliberately NOT a second schema tree (PD#12's fork) and deliberately NOT a declared `id` (批 18 Q1's two-axis rejection: a React list key on the authoring surface teaches AI authors to emit UUIDs). Two landmines were named in the ruling and both are pinned in `view-authoring-wire-split.test.ts` §5: `z.toJSONSchema()` must still emit a four-member `anyOf` (the `/api/v1/meta/types/view` endpoint feeds Studio's SchemaForm from it — it does; a pipe converts to its output side, asserted in BOTH io directions), and the `lazySchema` Proxy's ADR-0089 D3a crash (`Cannot set properties of undefined (setting 'ref')`) must not recur under a pipe-rooted lazy schema — it does not, and each new schema is converted directly rather than only through its parent. **One real hazard the change surfaced, fixed in the same PR:** a `z.preprocess` at a registered root put TWO gate walkers into the exact blind spot #4488 had already found and fixed in `check-liveness.mts` — `metadata-authoring-lint.ts` and `metadata-form-zod-reconciliation.test.ts` both unwrapped a pipe via `def.in`, which for a preprocess is the TRANSFORM, so each reported `view` as *not key-bearing* and silently stopped covering it. Caught by their own coverage assertions (`lintables.length >= 1`, `root schema is not key-bearing`), which is precisely what those assertions exist for; both now prefer whichever side is not the transform. **A gate going quiet is worse than a gate failing** — and the pattern will recur on the next preprocess-rooted registration, so it is recorded here rather than only in the diff. **Still open, one site, measured:** `FormFieldBaseSchema` — a module-private BASE whose sole consumer already applies `.strict()` plus the ADR-0089 visibility error map (`strictVisibilityError` when this was measured; folded into the shared template as `VISIBILITY_STRICT_OPTIONS` + `strictObjectError` at #6619, deliberately WITHOUT closing the base — the site keeps its literal `z.object(` spelling so this instrument keeps counting it); the door is closed, the ledger counts the base. The two remaining strip sites beyond it are `ViewItemWireSchema`'s arms, which are `wire` by design and are not debt. `ViewFilterRuleSchema` — **the same wire contamination, one block over, and it was already LIVE on `main`** (#5114): closed by an earlier wave, while objectui's filter builder stamps `id: crypto.randomUUID()` on every row it writes (`components/src/custom/filter-builder.tsx:228`, re-stamped on read-back at `plugin-view/src/config/view-config-utils.ts:146`/`:160`), and `saveMetaItem` persists the AUTHORED body verbatim — so saving a filter from the console 422'd, on all three paths including the flattened overlay that is the body actually PUT. Reopened as a p1 hotfix; `id` deliberately NOT declared, for the reason given for `sort` above. **That reopen was explicitly PROVISIONAL — "pending #5074" — and #5074 retired it rather than leaving it standing: the shape is CLOSED again, by the same decoration strip that closed `sort`, so the authoring gate rejects `id` by name while the console's own three paths still parse.** Its pin file now asserts the split per door, and the direction is the INVERTED one worth flagging to the next reader: probes 1/3 and 2/3 were GREEN before #5074 and are RED after (that IS the close), while 3/3 — the body the console actually PUTs — is green on BOTH sides and must stay so; a file that only asserted "the console body parses" would have passed unchanged through a change that quietly declared `id` as authorable. Two details worth keeping: the overlay path's rejection surfaces as `invalid_union` / *"Invalid input"* — the #5014 flattening, so the key that caused it is not in the message the author sees, which is why this sat on `main` unnoticed; and the reopening was verified in BOTH directions (re-close it and 7 assertions in `view-filter-rule-wire-id.test.ts` go red, while that file's two mechanism CONTROLS — top-level aux key rides, nested `emptyState` still rejects — stay green either way, which is what makes them controls). #5074's scope addendum named this site; the gate it was waiting on — a wire opening that REACHES a nested block — landed with it. Each verdict is recorded in three places (schema JSDoc + `view-strictness-batch18.test.ts` / `view-filter-rule-wire-id.test.ts` + this row). **#9933 added the file's fourth strip site, and it is `wire` by design, not debt: `ViewColumnStateSchema`** — the inner shape of the runtime-only `columnState` personalization overlay key (per-user column order/widths the console grid persists; maintainer-accepted ruling of 2026-08-19 on objectui#5233, mirroring objectui's `gridNonAuthorKeys` disposition). It is deliberately NOT `.strict()`: the console owns the internals of this per-user state, so a future console-written inner key must not 422 against an older server (`saveMetaItem` stores the original body verbatim either way). It is module-private, carried only by the wire faces (`flattenedViewOverlayFields` on the two overlay members, `viewItemWireFields` on the ViewItem wire member), and the AUTHORING doors reject the carrier key by name with a runtime-only prescription (`VIEW_ITEM_SURFACE.guidance` + `ListViewSchema`'s `guidance`) — pinned both ways in `view-metadata-schema.test.ts` §#9933 ⚠️ **#15469 (2026-09-05) closed the file's two `strictObject(…).passthrough()` sites — `GanttConfigSchema` and `TreeConfigSchema` now read `strict`; `ui/` passthrough 5 → 3, strict 157 → 162 (the two closures plus three NEW nested strict sites the declaration adds — `interactions`, `timeSegments` and its band entry — so this file's site count is 58 → 61), this file's strip count unchanged at 4.** Maintainer ruling A (director comment on the card, decision batch #41 item 2, verbatim 「同意」): both windows close. The ten keys plugin-gantt read through the gantt window (`borderColorField`, `lockField`, `objectField`, `summaryExtent`, `defaultCollapsedDepth`, `dependencyTypes`, `timeZone`, `exportFileName`, `interactions`, `timeSegments` — types from objectui's `GanttConfigExtensionFields` at pin `a472b07`) are DECLARED with a describe each; plugin-tree's `getTreeConfig` was measured to read exactly the four keys already declared from the `tree` block, so the tree close declares nothing and the ruled fallback (a dated close) was moot. **Triage's open question, answered by reading the gate:** a `.passthrough()` site is NOT invisible to `check:strictness-ledger` — `postureOf` (`scripts/lib/strictness-ledger.ts`) walks the chain since #5072 and the counts artifact's posture table records it as `passthrough` — but it is not DEBT the ratchet retires either: the remaining-strip map, the bucket subtotals and the reverse pin all count `strip` sites only, and `passthrough` is a non-strip posture, so an open-by-passthrough site never enters a row's strip count, never needs a `Class` verdict, and never moves the ratchet when it is closed or reopened. Its only ledger trace is the per-directory posture column and the row prose — which is why this closure is written here and not left to the numbers. Pinned in `view-gantt-tree-config-closed-15469.test.ts` (the card's five-schema probe with both controls; every declared key accepted at its type and refused at a wrong one; `ListView.gantt.colourField` refused through `ObjectStackDefinitionSchema`) and in `scripts/strictness-ledger.test.ts` (both sites read `strict`, with a mutated-copy red control) | | `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 was **#5055**, and it is ANSWERED: eight of the nine sites were REMOVED (the whole widget-registration vocabulary). The row does not disappear, because the NINTH — `FieldWidgetPropsSchema` — was deliberately KEPT: it is a React props contract rather than authorable metadata, it never appeared in the authorable surface at all, and objectui PR #3289 gave it a live compile-time consumer. ⛔ **Do not close it and do not finish this file** — this is the fourth row in the ledger parked at a deliberate floor (after `flow` 批 11, `etl` 批 12 and `i18n` above), and the reverse pin fires on ZERO either way, so only this cell separates "parked" from "unfinished". See the triage row above, including why the campaign's own BFS said otherwise first (**#5056**) | | `app.zod.ts` | covered | **批 19 ran the check and it came back NEGATIVE — no posture change; the `Class` was held at `verify` pending #5249 and is now `covered`, the verdict that ruling created (see below).** `BaseNavItemSchema`. The instruction here was to confirm the members' strictness was not already covering it before touching; it is, and the premise this row carried was wrong twice. (1) **The members do not `.extend()` the base — they spread `...BaseNavItemSchema.shape`.** That is a different mechanism, and the difference is the whole of finding 16: `.extend()` clones INHERIT the base's posture (which is how closing two `view` authoring schemas silently closed the Studio round-trip overlay), while a `...shape` spread copies the per-key schemas into a FRESH `z.object` whose posture is its own. Measured in both directions rather than read off the source, because *"closing the base closes the members"* and *"closing the base is a no-op"* are opposite claims: `strictBase.extend({…})` rejects an unknown key, `z.object({...strictBase.shape})` accepts it, `z.object({...openBase.shape}).strict()` rejects it. (2) **All nine branches already apply their own `.strict()`** with the curated `navItemUnknownKeyError` — asserted per branch through the real door (`AppSchema.navigation`, a `discriminatedUnion` on `type`), with a positive control (every base-contributed key, incl. `requiresService` which no branch declares itself, is ACCEPTED) and a negative control (an undeclared key is REJECTED) in the same run. The base is also module-private and has zero `.parse()` anywhere, so `.strict()` here would be a property of a parse that does not exist. Closing it is therefore a guaranteed no-op, and #4583 is explicit that a no-op closure is not neutral. ⚠️ **The open question was the VOCABULARY, not the measurement** — which is why 批 19 left the cell alone, since it is machine-read and a guess here would be published as a confident subtotal. The two-axis table above resolved carrier-absent + parse-absent to `no door`, whose prescribed follow-up is ADR-0049 retirement — and that prescription is *destructive* here: the vocabulary is fully ALIVE and fully GATED at nine consumers, so retiring the base would delete nine branches' shared keys. `no gate` is wrong for the mirror reason (the gate exists, at the members). `authorable` is the `FormFieldBaseSchema` precedent one row over in `view.zod.ts` — but that base really is `.extend()`ed, so closing it WOULD change behaviour, and calling this one `authorable` invites exactly the later sweep that "finishes the job" on a shape nothing parses. ✅ **RESOLVED at #5249 (maintainer ruling 2026-08-06, option A): the vocabulary grew a ninth verdict, `covered`, and this row is its first and — as of the sweep below — its ONLY instance.** The ruling took the same route 批 15 took for `no gate` rather than rounding to the nearest wrong answer, on the ground that the cell's readers are later agents and a verdict naming the wrong ACTION is amplified by whoever acts on it. The re-review the ruling required was run over all **197** strip sites in the five triaged directories, not just this file, and it is mechanical rather than a reading: `covered` requires the keys to reach consumers by `...X.shape` SPREAD (a spread lands them in a fresh `z.object` with its own posture, so the base is inert), whereas `.extend()`/`.merge()`/`.omit()` inherit posture and keep the base a real door. Exactly **one** of the 197 sites spreads — this one, into eight of the nine branches (`SeparatorNavItemSchema` declares its own two keys and spreads nothing, and is `.strict()` all the same). The three other module-private strip bases all resolve elsewhere and stay put: `view.zod.ts`'s `FormFieldBaseSchema` is `.extend()`ed at `:1475` → posture inherits → a real door → stays `authorable`; `query.zod.ts`'s `BaseQuerySchema` is `.extend()`ed at `:485` into `QuerySchema` → same → stays `open`; `component.zod.ts`'s `EmptyProps` is used as a VALUE under eleven `ComponentPropsMap` carrier keys → carrier present → not carrier-absent at all. The remaining ~50 sites are inline nested literals under a property, so they carry a carrier by construction and cannot be `covered`. Recorded in three places (the `BaseNavItemSchema` JSDoc + `app-strictness-batch19.test.ts` + this row); the pin includes a guard that fails if any branch ever stops rejecting unknown keys, which is the one change that would make this verdict need re-taking | | `action-params.zod.ts` | wire | **out of scope** — `ActionSessionSchema`, the action-body `ctx.session` the runtime hands a body (#5697). Tolerant on purpose, same disposition as `data/hook.zod.ts`'s `HookContextSchema`. What this surface needed was never a closed door but a gate that RUNS: its consistency with the real producer is pinned in `packages/runtime/src/action-session-shape-contract.test.ts`, which asserts that a non-strict parse of the built object returns it UNCHANGED — so a key the builder starts producing without declaring it here is stripped, and the pin goes red | diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 7b90554c15..e1b72d7a80 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -489,17 +489,27 @@ "ui/GanttConfig:autoZoomToFilter", "ui/GanttConfig:baselineEndField", "ui/GanttConfig:baselineStartField", + "ui/GanttConfig:borderColorField", "ui/GanttConfig:capacity", "ui/GanttConfig:colorField", + "ui/GanttConfig:defaultCollapsedDepth", "ui/GanttConfig:dependenciesField", + "ui/GanttConfig:dependencyTypes", "ui/GanttConfig:effortField", "ui/GanttConfig:endDateField", + "ui/GanttConfig:exportFileName", "ui/GanttConfig:groupByField", + "ui/GanttConfig:interactions", + "ui/GanttConfig:lockField", + "ui/GanttConfig:objectField", "ui/GanttConfig:parentField", "ui/GanttConfig:progressField", "ui/GanttConfig:quickFilters", "ui/GanttConfig:resourceView", "ui/GanttConfig:startDateField", + "ui/GanttConfig:summaryExtent", + "ui/GanttConfig:timeSegments", + "ui/GanttConfig:timeZone", "ui/GanttConfig:titleField", "ui/GanttConfig:tooltipFields", "ui/GanttConfig:typeField", diff --git a/packages/spec/scripts/lib/strictness-ledger.ts b/packages/spec/scripts/lib/strictness-ledger.ts index 4bc409da84..32fb95032e 100644 --- a/packages/spec/scripts/lib/strictness-ledger.ts +++ b/packages/spec/scripts/lib/strictness-ledger.ts @@ -62,11 +62,14 @@ function idiomOf(call: ts.CallExpression): string | null { * `z.object(` ever walked the chain. So `strictObject(…).passthrough()` — a shape * that is **open at runtime**, deliberately — was recorded as `strict`. * - * Two sites in the repo, both in `ui/view.zod.ts` (`GanttConfigSchema`, - * `TreeConfigSchema`), both open on purpose so renderer-ahead config knobs reach - * `plugin-gantt` / `plugin-tree` without waiting on a spec release. Measured, not - * inferred: `GanttConfigSchema.parse({ …required, lockField: 'x' })` returns - * `lockField` untouched. + * Two sites in the repo when this was written, both in `ui/view.zod.ts` + * (`GanttConfigSchema`, `TreeConfigSchema`), both open on purpose so + * renderer-ahead config knobs reached `plugin-gantt` / `plugin-tree` without + * waiting on a spec release. Measured, not inferred: `GanttConfigSchema.parse({ + * …required, lockField: 'x' })` returned `lockField` untouched. Both were CLOSED + * at #15469 (maintainer ruling A, 2026-09-05): they now read `strict`, the + * file's passthrough set is empty, and the walk's red control moved into a + * mutated copy of the file in `strictness-ledger.test.ts`. * * Two things made it worth fixing rather than rounding away. It **inflated the * strict count**, which is the number the campaign schedules against; and, the diff --git a/packages/spec/scripts/strictness-ledger.test.ts b/packages/spec/scripts/strictness-ledger.test.ts index ae0036ed9f..96540359d0 100644 --- a/packages/spec/scripts/strictness-ledger.test.ts +++ b/packages/spec/scripts/strictness-ledger.test.ts @@ -316,31 +316,43 @@ describe('posture reading, with a red control for each', () => { * differ — a test that only asserted `passthrough` would still pass if the * reader started calling everything passthrough. */ - it('does not stop at the strictObject( idiom — .passthrough() on it wins (#5072)', () => { + it('does not stop at the strictObject( idiom — .passthrough() on it wins (#5072); both real sites closed at #15469', () => { const view = analyzeSites(at('ui/view.zod.ts')); + // The two sites #5072 was written for are CLOSED as of #15469 (maintainer + // ruling A: both `.passthrough()` calls removed, the gantt window's ten + // renderer-read keys declared, the tree window's read set measured empty). + // Still pinned BY NAME: a reopen has to come through here and say so. for (const name of ['GanttConfigSchema', 'TreeConfigSchema']) { const site = view.find((s) => s.name === name); expect(site, `${name} is not a site any more — re-point this test, do not delete it`).toBeDefined(); - // The idiom is still the helper; only the READING of the chain changed. expect(site?.idiom).toBe('strictObject'); - expect(site?.posture, `${name} must read as passthrough — it is open at runtime`).toBe('passthrough'); + expect(site?.posture, `${name} must read as strict — closed at #15469`).toBe('strict'); } + // The #5072 reading itself, on a MUTATED copy of the live file: chain + // `.passthrough()` back onto the gantt site and its reading flips. Without + // this red control the `strict` assertions above are satisfied by a reader + // that has gone back to short-circuiting on the idiom — the exact defect. + const red = mutate( + 'ui/view.zod.ts', + 'configs give, and a renderer knob is declared HERE before it is read.\n}));', + 'configs give, and a renderer knob is declared HERE before it is read.\n}).passthrough());', + ).find((s) => s.name === 'GanttConfigSchema'); + expect(red?.idiom).toBe('strictObject'); + expect(red?.posture, 'the walk past the idiom must still run').toBe('passthrough'); + // The control, in the same file and the same idiom: a bare `strictObject(` - // with nothing chained on is still strict. Without this the assertion above + // with nothing chained on is strict. Without this the assertion above // is satisfied by a reader that has simply stopped distinguishing. const plain = view.find((s) => s.name === 'GanttQuickFilterSchema'); expect(plain?.idiom).toBe('strictObject'); expect(plain?.posture).toBe('strict'); - // Exactly two, and exactly these two. Pinned as a SET rather than a count so - // a future batch adding a deliberate `.passthrough()` has to come through - // here and say so, instead of quietly widening a number. - expect(view.filter((s) => s.posture === 'passthrough').map((s) => s.name)).toEqual([ - 'GanttConfigSchema', - 'TreeConfigSchema', - ]); + // The file's passthrough set is EMPTY as of #15469. Pinned as a SET rather + // than a count so a future batch adding a deliberate `.passthrough()` has + // to come through here and say so, instead of quietly widening a number. + expect(view.filter((s) => s.posture === 'passthrough').map((s) => s.name)).toEqual([]); // 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 diff --git a/packages/spec/src/ui/view-gantt-tree-config-closed-15469.test.ts b/packages/spec/src/ui/view-gantt-tree-config-closed-15469.test.ts new file mode 100644 index 0000000000..0b53e14f8a --- /dev/null +++ b/packages/spec/src/ui/view-gantt-tree-config-closed-15469.test.ts @@ -0,0 +1,267 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +/** + * #15469 — `GanttConfigSchema` and `TreeConfigSchema` are CLOSED against + * unknown keys. + * + * Both blocks used to be `strictObject(…).passthrough()`: the campaign's own + * helper applied and immediately undone, so a mistyped gantt key (`colourField`) + * parsed green and rendered an uncoloured bar while the same typo on a calendar + * or timeline block got a named refusal. Maintainer ruling A (2026-09-05): both + * windows close, the ten keys objectui's plugin-gantt read through the gantt + * window are declared at the types the renderer reads, and plugin-tree's read + * set — measured at objectui pin a472b07 — was already fully declared. + * + * Three things are pinned, each in the direction that would go silent again: + * + * 1. The card's probe, as measured: the five view config blocks parsed with + * their required members plus one undeclared key, with two in-process + * controls proving the mechanism works in this zod. The refusal is asserted + * on its ENVELOPE — `unrecognized_keys`, the surface named, the key echoed, + * the closest declared key suggested — not on `success === false`, which a + * different failure would satisfy. + * 2. Every newly declared gantt key is accepted at its measured type and kept + * in the parse output, and refused at a wrong type ON ITS OWN PATH, so a + * declaration that quietly reverted to `z.unknown()` would show. + * 3. The doors: a `ListView` and an `ObjectStackDefinition` carrying a gantt + * block with a typo are refused AT `gantt`, naming the key — the closure + * must reach an author through the schemas they actually parse with. + */ +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; + +import { + CalendarConfigSchema, + GanttConfigSchema, + ListMapConfigSchema, + ListViewSchema, + TimelineConfigSchema, + TreeConfigSchema, +} from './view.zod'; +import { ObjectStackDefinitionSchema } from '../stack.zod'; + +type Issue = { code: string; path: PropertyKey[]; message: string; keys?: string[] }; + +/** Refuse `value` through `schema` and return its issues, typed for the envelope assertions below. */ +function refuse(schema: { safeParse: (v: unknown) => { success: boolean; error?: unknown } }, value: unknown): Issue[] { + const r = schema.safeParse(value); + expect(r.success, `expected REFUSAL, got a successful parse of ${JSON.stringify(value)}`).toBe(false); + return ((r.error as { issues?: Issue[] })?.issues ?? []) as Issue[]; +} + +/** Parse `value` and fail with the issues if it does not succeed. */ +function accept(schema: { safeParse: (v: unknown) => { success: boolean; error?: unknown; data?: unknown } }, value: unknown): T { + const r = schema.safeParse(value); + expect(r.success, `expected ACCEPTANCE, got ${JSON.stringify((r.error as { issues?: unknown })?.issues ?? '')}`).toBe(true); + return r.data as T; +} + +/** The one `unrecognized_keys` issue at `path`, asserted to name the surface and echo every key. */ +function unknownKeyIssue(issues: Issue[], path: PropertyKey[], surface: string, keys: string[]): Issue { + const hit = issues.find((i) => i.code === 'unrecognized_keys' && JSON.stringify(i.path) === JSON.stringify(path)); + expect(hit, `no unrecognized_keys issue at ${JSON.stringify(path)} in ${JSON.stringify(issues)}`).toBeDefined(); + expect(hit!.message).toContain(`Unrecognized key(s) on ${surface}`); + for (const k of keys) { + expect(hit!.keys, `the refused key must be echoed in \`keys\``).toContain(k); + expect(hit!.message).toContain(`\`${k}\``); + } + return hit!; +} + +const GANTT_REQUIRED = { startDateField: 'start_date', endDateField: 'end_date', titleField: 'name' }; + +// =========================================================================== +// 1. The card's probe — five blocks, one undeclared key, two lit controls +// =========================================================================== +describe('the card\'s probe: every view config block refuses `bogus_key_xyz` (#15469)', () => { + it('CONTROLS — the mechanism works in this zod, in this process', () => { + // Two spellings of a closed object, so "the sibling schemas refuse" below + // is measured against a lit instrument rather than assumed. + expect(z.object({ a: z.string() }).strict().safeParse({ a: 'x', bogus_key_xyz: 1 }).success).toBe(false); + expect(z.strictObject({ a: z.string() }).safeParse({ a: 'x', bogus_key_xyz: 1 }).success).toBe(false); + }); + + it.each([ + ['gantt', GanttConfigSchema, GANTT_REQUIRED, 'this gantt configuration'], + ['tree', TreeConfigSchema, {}, 'this tree configuration'], + ['calendar', CalendarConfigSchema, { startDateField: 'starts_at' }, 'this calendar configuration'], + ['timeline', TimelineConfigSchema, { startDateField: 'starts_at', titleField: 'name' }, 'this timeline configuration'], + ['map', ListMapConfigSchema, {}, 'this map configuration'], + ] as const)('%s refuses an undeclared key on its named surface, echoing the key', (_name, schema, required, surface) => { + // The required members alone parse — so the refusal below is the KEY, not + // a missing member (timeline's own `titleField` requirement is the case + // that would otherwise mask it). + accept(schema, required); + const issues = refuse(schema, { ...required, bogus_key_xyz: 1 }); + unknownKeyIssue(issues, [], surface, ['bogus_key_xyz']); + expect(issues.filter((i) => i.code !== 'unrecognized_keys'), 'the ONLY issue is the unknown key').toHaveLength(0); + }); + + it('gantt — the strictObject error suggests the closest declared key (the #14471 typo)', () => { + const [issue] = refuse(GanttConfigSchema, { ...GANTT_REQUIRED, colourField: 'status' }); + expect(issue.message).toContain('`colourField`'); + expect(issue.message).toMatch(/Did you mean .*`colourField` → `colorField`/); + }); + + it('tree — the same suggestion on the other closed block', () => { + const [issue] = refuse(TreeConfigSchema, { labelFeild: 'name' }); + expect(issue.message).toContain('`labelFeild`'); + expect(issue.message).toMatch(/Did you mean .*`labelFeild` → `labelField`/); + }); + + it('tree — the four keys plugin-tree reads all parse; nothing else was ever read, so nothing else is declared', () => { + const parsed = accept>(TreeConfigSchema, { + parentField: 'parent_id', labelField: 'name', fields: ['code', 'owner'], defaultExpandedDepth: 1, + }); + expect(Object.keys(parsed).sort()).toEqual(['defaultExpandedDepth', 'fields', 'labelField', 'parentField']); + expect(Object.keys(TreeConfigSchema.shape).sort()).toEqual(['defaultExpandedDepth', 'fields', 'labelField', 'parentField']); + }); +}); + +// =========================================================================== +// 2. The ten declared gantt keys — accepted at type, refused at a wrong one +// =========================================================================== +const FULL_TEN = { + borderColorField: 'alert_color', + lockField: 'is_locked', + objectField: 'row_object', + summaryExtent: 'self', + defaultCollapsedDepth: 2, + dependencyTypes: false, + timeZone: 'Asia/Shanghai', + exportFileName: 'Shift plan', + interactions: { move: true, resize: false, progress: true, link: false }, + timeSegments: { + dayStart: '08:00', + bands: [ + { key: 'day', label: 'Day shift', start: '08:00', end: '20:00', color: '#fde68a' }, + { key: 'night', label: 'Night shift', start: '20:00', end: '08:00' }, + ], + showMidnight: false, + }, +} as const; + +describe('the ten keys plugin-gantt read through the window are DECLARED (#15469)', () => { + it('the shape carries exactly the ten, beside the nineteen it already had', () => { + const keys = Object.keys(GanttConfigSchema.shape); + for (const k of Object.keys(FULL_TEN)) expect(keys, `${k} must be a declared member`).toContain(k); + // objectui's `FLAT_GANTT_CONFIG_KEYS` reads this `.shape` — the count is + // what its coverage pin compiles against, so it is pinned here as a number. + expect(keys).toHaveLength(29); + }); + + it.each(Object.entries(FULL_TEN))('accepts `%s` at its measured type and keeps it in the output', (key, value) => { + const parsed = accept>(GanttConfigSchema, { ...GANTT_REQUIRED, [key]: value }); + expect(parsed[key]).toEqual(value); + }); + + it('accepts all ten together through the ListView door', () => { + const parsed = accept<{ gantt: Record }>(ListViewSchema, { + type: 'gantt', columns: ['name'], gantt: { ...GANTT_REQUIRED, ...FULL_TEN }, + }); + expect(parsed.gantt).toMatchObject(FULL_TEN); + }); + + it.each([ + ['borderColorField', 1, ['borderColorField']], + ['lockField', true, ['lockField']], + ['objectField', ['task'], ['objectField']], + ['summaryExtent', 'both', ['summaryExtent']], + ['defaultCollapsedDepth', 'two', ['defaultCollapsedDepth']], + ['defaultCollapsedDepth', -1, ['defaultCollapsedDepth']], + ['defaultCollapsedDepth', 1.5, ['defaultCollapsedDepth']], + ['dependencyTypes', 'yes', ['dependencyTypes']], + ['timeZone', 8, ['timeZone']], + ['exportFileName', { base: 'x' }, ['exportFileName']], + ['interactions', { move: 'no' }, ['interactions', 'move']], + ['interactions', true, ['interactions']], + ['timeSegments', { dayStart: '08:00' }, ['timeSegments', 'bands']], + ['timeSegments', { bands: [{ start: '08:00', end: '20:00' }] }, ['timeSegments', 'bands', 0, 'label']], + ['timeSegments', { bands: [{ label: 'Day', start: 8, end: '20:00' }] }, ['timeSegments', 'bands', 0, 'start']], + ] as const)('refuses `%s` = %j at its own path', (key, value, path) => { + const issues = refuse(GanttConfigSchema, { ...GANTT_REQUIRED, [key]: value }); + expect(issues.map((i) => JSON.stringify(i.path)), `expected an issue at ${JSON.stringify(path)}`) + .toContain(JSON.stringify(path)); + }); + + it('summaryExtent — the wrong value is refused naming both members', () => { + const [issue] = refuse(GanttConfigSchema, { ...GANTT_REQUIRED, summaryExtent: 'both' }); + expect(issue.code).toBe('invalid_value'); + expect(issue.message).toContain('children'); + expect(issue.message).toContain('self'); + }); + + it('the nested blocks are closed too — strictness is per object, and each nested surface is named', () => { + // A loose `z.object` here would be the second de-facto contract the ruling + // closes: the sub-object would accept anything again, one level down. + unknownKeyIssue( + refuse(GanttConfigSchema, { ...GANTT_REQUIRED, interactions: { move: true, drag: false } }), + ['interactions'], 'this gantt interactions block', ['drag'], + ); + unknownKeyIssue( + refuse(GanttConfigSchema, { ...GANTT_REQUIRED, timeSegments: { bands: [], shifts: [] } }), + ['timeSegments'], 'this gantt time-segments block', ['shifts'], + ); + unknownKeyIssue( + refuse(GanttConfigSchema, { ...GANTT_REQUIRED, timeSegments: { bands: [{ label: 'Day', start: '08:00', end: '20:00', from: '08:00' }] } }), + ['timeSegments', 'bands', 0], 'this gantt shift band', ['from'], + ); + }); + + it('the curated aliases steer the semantic near-misses at the key the renderer reads', () => { + const drag = unknownKeyIssue( + refuse(GanttConfigSchema, { ...GANTT_REQUIRED, interactions: { drag: false } }), + ['interactions'], 'this gantt interactions block', ['drag'], + ); + expect(drag.message).toContain('`drag` → `move`'); + const from = unknownKeyIssue( + refuse(GanttConfigSchema, { ...GANTT_REQUIRED, timeSegments: { bands: [{ label: 'Day', from: '08:00', to: '20:00' }] } }), + ['timeSegments', 'bands', 0], 'this gantt shift band', ['from', 'to'], + ); + expect(from.message).toContain('`from` → `start`'); + expect(from.message).toContain('`to` → `end`'); + }); +}); + +// =========================================================================== +// 3. The doors — the closure reaches an author through the schemas they parse with +// =========================================================================== +describe('a gantt typo is refused AT `gantt` through the real doors (#15469)', () => { + const typoView = { type: 'gantt', columns: ['name'], gantt: { ...GANTT_REQUIRED, colourField: 'status' } }; + + it('ListViewSchema — refused at [gantt], naming the key and suggesting colorField', () => { + const issues = refuse(ListViewSchema, typoView); + const issue = unknownKeyIssue(issues, ['gantt'], 'this gantt configuration', ['colourField']); + expect(issue.message).toContain('`colourField` → `colorField`'); + // The positive control: the same view with the key spelled right parses. + accept(ListViewSchema, { ...typoView, gantt: { ...GANTT_REQUIRED, colorField: 'status' } }); + }); + + it('ListViewSchema — a tree typo is refused at [tree] the same way', () => { + const issues = refuse(ListViewSchema, { type: 'tree', columns: ['name'], tree: { labelFeild: 'name' } }); + const issue = unknownKeyIssue(issues, ['tree'], 'this tree configuration', ['labelFeild']); + expect(issue.message).toContain('`labelFeild` → `labelField`'); + accept(ListViewSchema, { type: 'tree', columns: ['name'], tree: { labelField: 'name' } }); + }); + + it('ObjectStackDefinitionSchema — refused at objects[0].listViews.schedule.gantt through the ADR-0047 door', () => { + const stack = (gantt: Record) => ({ + manifest: { id: 'com.test.gantt', name: 'test', version: '1.0.0', type: 'app' }, + objects: [{ + name: 'task', + fields: { + name: { type: 'text' }, + start_date: { type: 'date' }, + end_date: { type: 'date' }, + status: { type: 'text' }, + }, + listViews: { schedule: { type: 'gantt', columns: ['name'], gantt } }, + }], + }); + // Positive control first — a well-formed stack with all ten keys parses, + // so the refusal below is the typo and not the fixture. + accept(ObjectStackDefinitionSchema, stack({ ...GANTT_REQUIRED, ...FULL_TEN })); + const issues = refuse(ObjectStackDefinitionSchema, stack({ ...GANTT_REQUIRED, colourField: 'status' })); + const issue = unknownKeyIssue(issues, ['objects', 0, 'listViews', 'schedule', 'gantt'], 'this gantt configuration', ['colourField']); + expect(issue.message).toContain('`colourField` → `colorField`'); + }); +}); diff --git a/packages/spec/src/ui/view-strictness-batch18.test.ts b/packages/spec/src/ui/view-strictness-batch18.test.ts index 4a6911f305..8090b4ab28 100644 --- a/packages/spec/src/ui/view-strictness-batch18.test.ts +++ b/packages/spec/src/ui/view-strictness-batch18.test.ts @@ -146,12 +146,14 @@ describe('#4001 批 18 — closed sites reject unknown keys where they live', () .toContain('notAnOptionKey'); }); - it('GanttConfigSchema.tooltipFields — a CLOSED entry inside a deliberately OPEN parent', () => { + it('GanttConfigSchema.tooltipFields — a CLOSED entry, and since #15469 a CLOSED parent too', () => { 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); + // 批 18 closed this ENTRY while the parent stayed `.passthrough()` (its + // stated non-goal: renderer-ahead knobs reached plugin-gantt through the + // parent). #15469 closed the parent (maintainer ruling A), so both + // readings are pinned here and a reopen of either is loud. + expect(reject(GanttConfigSchema, { ...gantt, someRendererAheadKnob: true })) + .toContain('someRendererAheadKnob'); expect(reject(GanttConfigSchema, { ...gantt, tooltipFields: [{ field: 'owner', notATooltipKey: 1 }] })) .toContain('notATooltipKey'); }); diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index 4c1310c9f9..f58ae77cce 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -264,18 +264,26 @@ describe('GanttConfigSchema', () => { expect(GanttConfigSchema.parse(config)).toMatchObject({ parentField: 'parent_id', typeField: 'row_type' }); }); - it('should passthrough unknown renderer fields ahead of this schema', () => { + it('declares the members plugin-gantt used to read through `.passthrough()` — and refuses an undeclared one (#15469)', () => { const config = { startDateField: 'start_date', endDateField: 'end_date', titleField: 'name', - // Newer renderer knobs not yet declared here (e.g. objectui plugin-gantt). + // Formerly renderer-ahead knobs, now declared (objectui `GanttConfigExtensionFields`). lockField: 'is_locked', defaultCollapsedDepth: 2, }; expect(() => GanttConfigSchema.parse(config)).not.toThrow(); expect(GanttConfigSchema.parse(config)).toMatchObject({ lockField: 'is_locked', defaultCollapsedDepth: 2 }); + // The window is closed: a key no renderer reads is refused BY NAME, on the + // named surface — the same error calendar / timeline / map already give. + const r = GanttConfigSchema.safeParse({ ...config, someRendererAheadKnob: true }); + expect(r.success).toBe(false); + const issues = JSON.stringify(r.error?.issues); + expect(issues).toContain('unrecognized_keys'); + expect(issues).toContain('someRendererAheadKnob'); + expect(issues).toContain('this gantt configuration'); }); // #9463 — `viewMode`, the spec half of objectui#5074's ruled both-branches @@ -311,8 +319,8 @@ describe('GanttConfigSchema', () => { it('refuses an out-of-vocabulary granularity, naming the members', () => { // 'hour' is a real granularity on the TIMELINE block's `scale` but not // in the gantt renderer's VIEW_MODES — exactly the near-miss an author - // would try. Declared-key values are judged even inside this - // deliberately-passthrough parent. + // would try. Declared-key values are judged by their own schema (and + // since #15469 the parent refuses undeclared keys as well). const r = GanttConfigSchema.safeParse({ ...base, viewMode: 'hour' }); expect(r.success).toBe(false); const issues = JSON.stringify(r.error?.issues); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 5d8f1dd3a3..19f7fafeff 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -1334,6 +1334,19 @@ export const GanttQuickFilterSchema = lazySchema(() => strictObject({ * Beyond the core timeline fields, the renderer supports a two-level * parent/child hierarchy (parentField/typeField), planned-vs-actual baselines, * dynamic grouping, a resource/workload view, hover tooltips and quick filters. + * + * CLOSED against unknown keys (#15469). This block used to end in + * `.passthrough()` so renderer-ahead knobs could reach objectui's plugin-gantt + * without a spec release. That window is shut: a `strictObject` applied and + * then undone is two contracts on one surface (Prime Directive #12), and a + * mistyped key (`colourField`) parsed green and rendered an uncoloured bar — + * the exact silent class the calendar / timeline / map blocks already refuse. + * The ten members the renderer read through the window + * (`GANTT_CONFIG_EXTENSION_KEYS` in objectui `plugin-gantt/src/ObjectGantt.tsx`; + * types from `GanttConfigExtensionFields` in `@object-ui/types/zod`, measured + * at objectui pin `a472b07`) are declared below under "objectui-lifted + * members", each at the type the renderer reads. A renderer knob now needs + * its declaration here FIRST. */ export const GanttConfigSchema = lazySchema(() => strictObject({ surface: 'this gantt configuration', @@ -1364,12 +1377,12 @@ export const GanttConfigSchema = lazySchema(() => strictObject({ 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. + // This entry is a closed `{ field, label }` pair — exactly the shape + // objectui's `GanttView` tooltip resolver reads. 批 18 closed it while + // the PARENT was still `.passthrough()`, because strictness is per + // object, not per subtree (the nested hole 批 13 found on `responsive`); + // #15469 closed the parent too, so the whole gantt block is now one + // posture. 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'), @@ -1383,11 +1396,53 @@ export const GanttConfigSchema = lazySchema(() => strictObject({ // materialized default would read as an explicit author choice and defeat // that seeding (same reasoning as the map block's defaultless `zoom`/`center`). viewMode: z.enum(['day', 'week', 'month', 'quarter', 'year']).optional().describe("Timeline granularity — one column per day/week/month/quarter/year (also the resource-view column granularity; renderer default 'day')"), -// Forward-compatible: the gantt renderer (objectui plugin-gantt) keeps adding -// config knobs (e.g. lockField / defaultCollapsedDepth) ahead of this schema. -// Passthrough lets those extra fields reach the renderer instead of being -// stripped here, so a renderer release no longer has to wait on a spec release. -}).passthrough()); + // ── objectui-lifted members (#15469) ────────────────────────────────────── + // The ten keys plugin-gantt read through the former `.passthrough()` window, + // declared at the types objectui's `GanttConfigExtensionFields` carries (pin + // a472b07) so the two authoring faces derive from ONE vocabulary. All + // optional; each describe says what the renderer does with the key. + borderColorField: z.string().optional().describe('Field carrying a per-task alert stroke color — any CSS color or semantic palette name (red, orange, …): the bar keeps its fill and gains an outline plus halo in that color (e.g. red for overdue, orange for due-soon; typically a server-computed alert field). Empty or null means no stroke'), + lockField: z.string().optional().describe('Field marking a row view-only (truthy means locked): a locked bar cannot be dragged or resized, its progress cannot be dragged, no dependency can be drawn from it and its inline-edit and edit/delete menu entries are hidden — clicking it (open drawer, jump) still works. Independent of the global readOnly; freezes individual levels while siblings stay editable'), + objectField: z.string().optional().describe("Field carrying the row's OWN object API name, for mixed-object trees (an api provider composing parent-object rows with child-object rows): the detail drawer and its full-page link follow each row's real object instead of the view's bound object. Empty or missing falls back to the bound object"), + summaryExtent: z.enum(['children', 'self']).optional().describe("How a summary bar's span is computed. 'children' (renderer default) rolls the bar up from its children — min start, max end, duration-weighted progress — and ignores the record's own dates; 'self' renders the record's OWN start, end and progress and falls back to rollup only for records without dates (use it when the parent's schedule is authoritative, e.g. a shift plan whose work-order children are locked history)"), + defaultCollapsedDepth: z.number().int().min(0).optional().describe('Auto-collapse tree nodes at or below this 0-indexed depth on first render (roots are depth 0): every node at that depth or deeper that has children starts folded; the user can still expand them. Omit to start fully expanded'), + dependencyTypes: z.boolean().optional().describe('Whether the backing store persists dependency link TYPES (fs, ss, ff, sf); renderer default true. Set false when dependencies are bare predecessor ids: the link menu hides the type switcher (a switch would be silently reverted on refetch) and drag-created links are always finish-to-start'), + timeZone: z.string().optional().describe("Business time zone, an IANA name such as 'Asia/Shanghai': the chart's calendar — shift bands, day columns, snapping, the today line, date labels — renders in this zone's wall time for every viewer instead of the browser's zone; persisted data stays real instants. An invalid name falls back to the browser zone with a console warning"), + exportFileName: z.string().optional().describe("Base name for exported PNG and PDF files (e.g. the view's display label — the host's view schema often reaches the renderer stripped of label); falls back to the object schema label, then the object API name. A timestamp suffix is always appended"), + interactions: strictObject({ + surface: 'this gantt interactions block', + history: VIEW_HISTORY, + aliases: { drag: 'move', dependencies: 'link', links: 'link' }, + }, { + move: z.boolean().optional().describe('Bar and subtree dragging along the timeline (default true)'), + resize: z.boolean().optional().describe('Edge resize grips that change a duration (default true)'), + progress: z.boolean().optional().describe('The progress drag handle (default true)'), + link: z.boolean().optional().describe('Dependency UI — the drag-to-link dots and the create/delete menu entries (default true)'), + }).optional().describe('Per-interaction switches, each defaulting to true: allow bar moves but pin durations (resize: false), or keep the dependency UI read-only (link: false). They only narrow what readOnly and row locks already allow'), + timeSegments: strictObject({ + surface: 'this gantt time-segments block', + history: VIEW_HISTORY, + aliases: { segments: 'bands', shifts: 'bands' }, + }, { + dayStart: z.string().optional().describe("Clock time the shift-day begins, 'HH:mm' (24h); the day column starts here and runs 24h. Renderer default '00:00' (calendar day); for an 08:00 handover set '08:00'"), + bands: z.array(strictObject({ + surface: 'this gantt shift band', + history: VIEW_HISTORY, + aliases: { name: 'label', title: 'label', from: 'start', to: 'end' }, + }, { + key: z.string().optional().describe("Stable band id (e.g. 'day', 'night'); the renderer defaults to band plus the index"), + label: z.string().describe('Display label for the band header (e.g. Day shift, Night shift)'), + start: z.string().describe("Band start, 'HH:mm' (24h)"), + end: z.string().describe("Band end, 'HH:mm'; when end is not after start the band crosses midnight"), + color: z.string().optional().describe('Accent color (any CSS color) for the column tint'), + })).describe('Ordered bands covering the 24h shift-day, beginning at dayStart'), + showMidnight: z.boolean().optional().describe('Draw the dashed calendar-midnight (00:00) cue inside cross-midnight bands; renderer default true'), + }).optional().describe('Shift segmentation for the day-mode timeline: splits each shift-day (starting at dayStart) into the configured bands — a two-tier header (date over band), per-band tints and drag/resize snapping to band boundaries. No shift concept is hardcoded; bands are pure config. Off when omitted'), +// Closed at #15469 (maintainer ruling A on the card, 2026-09-05): the +// renderer-ahead `.passthrough()` window that used to end this block is shut — +// an undeclared key is refused with the same named error the sibling view +// configs give, and a renderer knob is declared HERE before it is read. +})); /** * Tree (tree-grid) Settings @@ -1406,8 +1461,12 @@ export const TreeConfigSchema = lazySchema(() => strictObject({ labelField: z.string().optional().describe('Field rendered indented in the first column (defaults to "name")'), fields: z.array(z.string()).optional().describe('Additional fields rendered as flat columns alongside the label'), defaultExpandedDepth: z.number().int().min(0).optional().describe('Initial expansion depth (0 = roots only; omit = expand all)'), -// Forward-compatible: let renderer-ahead config knobs reach plugin-tree. -}).passthrough()); +// Closed at #15469 (same ruling as the gantt block above): the renderer-ahead +// `.passthrough()` window is shut. Measured before closing, at objectui pin +// a472b07: plugin-tree's `getTreeConfig` reads exactly the four keys declared +// here from the `tree` block — the undeclared read set was EMPTY, so the close +// declares nothing new and refuses only what no renderer ever read. +})); /** * Map View Settings @@ -1428,11 +1487,12 @@ export const TreeConfigSchema = lazySchema(() => strictObject({ * (same collision, same resolution as `ListChartConfigSchema` vs the * `chart.zod.ts` `ChartConfigSchema`). * - * Closed (strict), unlike the gantt/tree blocks: their passthrough exists - * because those renderers keep adding config knobs ahead of the spec, while - * the map renderer's read set is itself closed — it validates `schema.map` - * against a local zod schema with exactly these keys, so an extra key here - * would be dropped there. Strict means a misspelling is a loud parse error + * Closed (strict) from the start: the map renderer's read set is itself closed + * — it validates `schema.map` against a local zod schema with exactly these + * keys, so an extra key here would be dropped there. The gantt / tree blocks + * above used to be this file's two `.passthrough()` exceptions (renderer-ahead + * knobs); #15469 closed both, so every view config block here now refuses an + * unknown key the same way. Strict means a misspelling is a loud parse error * instead of a marker silently falling back to default field names. */ export const ListMapConfigSchema = lazySchema(() => strictObject({