diff --git a/.changeset/ui-widget-i18n-family-retired.md b/.changeset/ui-widget-i18n-family-retired.md
new file mode 100644
index 0000000000..730af41a9a
--- /dev/null
+++ b/.changeset/ui-widget-i18n-family-retired.md
@@ -0,0 +1,142 @@
+---
+"@objectstack/spec": major
+---
+
+refactor(spec)!: retire the widget-registration vocabulary and five doorless i18n shapes — and KEEP `FieldWidgetProps`, which has a live consumer (#5055)
+
+`@objectstack/spec/ui` published two vocabularies nothing in the protocol carried.
+Both are removed — **10 emitted defs, 26 exported names** — and the generated
+`references/ui/widget` page with them.
+
+| file | removed |
+|---|---|
+| `ui/widget.zod.ts` | `WidgetManifest`, `WidgetLifecycle`, `WidgetEvent`, `WidgetProperty`, `WidgetSource` (its `npm` / `remote` / `inline` union) |
+| `ui/i18n.zod.ts` | `I18nObject`, `PluralRule`, `NumberFormat`, `DateFormat`, `LocaleConfig` |
+
+There was no carrier key for any of them. No schema declared a key whose value was
+a widget manifest or a locale config, so no metadata document could reach these
+shapes and nothing ever parsed one.
+
+Three measurements, each re-run on `origin/main` immediately before the removal,
+each with its controls passing in the same run:
+
+1. **Static** — nothing under `packages/spec/src` imported `widget.zod` at all,
+ and every live import of `i18n.zod` names `I18nLabelSchema` or
+ `AriaPropsSchema` (both kept). `field.widget` is a `z.string()` naming a
+ component the **renderer** has registered; it has never referenced
+ `WidgetManifest`.
+2. **Graph** — a BFS over the in-memory Zod graph from all 24 metadata-type roots
+ plus `defineStack`'s `ObjectStackSchema` reached **none** of them, while
+ `PageSchema` and `ObjectListViewSchema` resolved `direct` in the same run and a
+ synthetic carrier flipped every one of them. So "unreachable" was a fact about
+ the graph, not a broken walker.
+3. **Call sites** — zero `.parse()` / `.safeParse()` in objectstack, objectui or
+ cloud outside these files' own unit tests. objectui's widget registry has
+ always carried its own runtime manifest (`RuntimeWidgetManifest` /
+ `RuntimeWidgetSource` in `@object-ui/types`, objectui#3161 / #4115), which
+ models different keys and never derived from these.
+
+Business ruling (2026-08-06; window moved from protocol 18 to 17 on 2026-08-07):
+widget **registration** belongs to the renderer, not to the protocol — the
+protocol's job is the props contract a widget implements. Localisation is already
+delivered the other way: `I18nLabelSchema` documents that translation keys are
+generated by the framework at registration time and translations live in
+translation files, and the live translation surface is `system/translation.zod.ts`,
+which uses none of these shapes.
+
+FROM → TO:
+
+| removed | what to do instead |
+|---|---|
+| `WidgetManifest` / `WidgetLifecycle` / `WidgetEvent` / `WidgetProperty` / `WidgetSource` | nothing to author — name a widget with the string `field.widget` (or the view's `widget` override) and register the component with the renderer. In objectui that is `@object-ui/core`'s `WidgetRegistry` and `RuntimeWidgetManifest`. |
+| `I18nObject` | write the default-language string on `label` / `description`; the framework generates the translation key. Translations go in translation files (`system/translation.zod.ts`). |
+| `PluralRule` | not a protocol surface — plural forms live in the translation files your i18n runtime reads. |
+| `NumberFormat` / `DateFormat` / `LocaleConfig` | nothing to author — no formatter ever read one. Use `Intl.NumberFormat` / `Intl.DateTimeFormat` directly, as `packages/formula`'s template engine already does. |
+
+**No metadata document needs editing.** A stack that parsed before parses
+byte-for-byte the same after: none of these was writable in the first place, and
+`field.widget: my_picker` is untouched. The break is a TypeScript one — every
+removed name is `TS2305` on `@objectstack/spec` and `@objectstack/spec/ui` after
+upgrade.
+
+## One of the nine widget sites is deliberately KEPT
+
+`FieldWidgetProps` / `FieldWidgetPropsSchema` / `FieldWidgetPropsParsed` **stay**,
+and the reason is worth reading, because the issue that scheduled this batch
+listed the site for removal on evidence that had been overtaken one day earlier.
+
+- It is a **React props contract**, not authorable metadata. It never appeared in
+ `authorable-surface/` or `json-schema.manifest/` at all — its `onChange` is a
+ `z.function()`, so no JSON Schema is emitted — so ADR-0049's question about a
+ declared-but-unenforced *authorable key* never applied to it. Having no
+ `.parse()` is its design, not its defect: a props contract is enforced by `tsc`
+ in the repo that implements it.
+- It has a **live cross-repo consumer**. objectui PR #3289 (merged 2026-08-03)
+ renamed `@object-ui/fields`' validation slot from `errorMessage` onto this
+ contract's `error` with no alias, made the form renderer produce it, and pinned
+ the result in `packages/fields/src/__tests__/spec-symbol-batch7.test.ts` against
+ `import type { FieldWidgetProps } from '@objectstack/spec/ui'` — deliberately,
+ so that "the day the spec stops exporting `FieldWidgetProps`, this file stops
+ compiling and the rename's reason is up for re-triage". Re-verified on objectui
+ `origin/main` 2026-08-07.
+
+`AriaPropsSchema` and `I18nLabelSchema` are likewise untouched. `AriaProps` is the
+one **real door** in `i18n.zod.ts` — carried as `aria:` on ~30 live shapes under
+six metadata-type roots and closed by #4001 批 16.
+
+## ⚠️ objectui needs a companion PR in the same window
+
+Two objectui surfaces respond to this removal **by design**, not by accident:
+
+- `packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts` asserts the spec
+ *still owns* `WidgetManifest` and `WidgetSource`, with the comment "if the spec
+ RETIRES one of these, the local dialect can take the natural name back… a
+ workaround should not outlive its reason (objectui#3169)". That assertion is
+ meant to go red exactly here.
+- `packages/types/src/widget.ts`'s "Renamed off the spec's `WidgetManifest` /
+ `WidgetSource` name" docblocks now point at names that no longer exist.
+
+Neither is collateral damage — both are the tripwire firing as specified. The
+objectui side is tracked separately; this repo cannot land it.
+
+The retirement kit:
+
+- **No `retiredKey()` tombstone, deliberately** — route 3 of the retirement
+ playbook ("nothing parses it → neither"), as used by #4988 (the ui/
+ interaction-config family), #4834 / PR #4878 (kernel plugin-runtime family) and
+ #4938 / PR #5293 (`HttpServerConfig`). A tombstone is a message to whoever
+ writes the key; with no carrier key there is no shape for one to sit on and no
+ author who could ever receive it.
+- **No ADR-0087 D2 conversion**, for the same reason: there is no source to
+ rewrite, because the keys were unwritable. The registered record is the D3
+ `SemanticMigration` `ui-widget-i18n-family-retired`, with the protocol-17 step's
+ rationale extended, plus the ten `RETIRED_DEFS_BY_MAJOR` entries the #4725
+ manifest-deletion gate reads.
+- **`WidgetManifest.performance`'s tombstone is subsumed, not deleted in
+ isolation** — the #4657/#4834 shape. It goes with the shape that carried it,
+ which is strictly stronger: there is no longer a manifest to author the key
+ into, so the prescription an author needs is no longer "delete this key".
+- **Whole-file deletion was rejected per file, not assumed.** Unlike #4988, both
+ files here keep a live occupant, so this is a shape retirement and the files
+ stay. That is asserted, not just intended.
+- Baselines updated deliberately: `json-schema.manifest/ui.json` (−10, the #2978
+ ratchet fires first and demands each deletion), `authorable-surface/ui.json`
+ (−65, adjudicated by the #4650 gate's path 3 "def no longer emitted by this
+ build"), `api-surface/ui.json` (−26). Reference docs, `references/ui/meta.json`,
+ the skill reference indexes and the strictness-ledger counts regenerated — the
+ `no door` bucket goes 14 → 1.
+- `packages/spec/variant-docs.json`'s `type:inline|npm|remote` entry is deleted
+ with the discriminated union it described. A ledger row whose union has left the
+ source is the #5552 failure mode; `pnpm check:variant-docs` is the gate.
+- **Pins are bidirectional.** `ui/widget-i18n-retirement.test.ts` asserts absence
+ across every public entry by resolved symbol identity *and* the survival of the
+ three shapes a too-wide sweep would take — all three of which live in the two
+ files being emptied. It also pins the exact `error` slot objectui#3289 depends
+ on, so a change that would silently break that repo goes red in this one first.
+- The #5056 clone-overlap regression fixture was rebuilt rather than re-pointed:
+ `door-reachability.testkit.test.ts` constructs the same 2-of-19 shared-leaf
+ shape locally, so the instrument's measured bound survives its subject.
+
+No runtime behaviour changes. That impossibility is the reason for the removal.
+
+
diff --git a/content/docs/getting-started/quick-reference.mdx b/content/docs/getting-started/quick-reference.mdx
index 70bca65853..30633e6d1d 100644
--- a/content/docs/getting-started/quick-reference.mdx
+++ b/content/docs/getting-started/quick-reference.mdx
@@ -52,7 +52,7 @@ Presentation layer - views, forms, dashboards, and themes.
| **[Component](/docs/references/ui/component)** | `component.zod.ts` | PageComponent variants | Reusable UI components |
| **[Chart](/docs/references/ui/chart)** | `chart.zod.ts` | Chart, ChartType | Chart definitions |
| **[Theme](/docs/references/ui/theme)** | `theme.zod.ts` | Theme, ColorPalette | Theming and branding |
-| **[Widget](/docs/references/ui/widget)** | `widget.zod.ts` | WidgetManifest | Custom widget definitions |
+| **[Widget Contract](/docs/protocol/objectui/widget-contract)** | `widget.zod.ts` | FieldWidgetProps | Props a custom field widget receives |
## Kernel Protocol (17 schemas)
diff --git a/content/docs/protocol/objectui/concept.mdx b/content/docs/protocol/objectui/concept.mdx
index 43f1f72ac2..7c849dcd61 100644
--- a/content/docs/protocol/objectui/concept.mdx
+++ b/content/docs/protocol/objectui/concept.mdx
@@ -703,7 +703,7 @@ function renderField(field: FieldDefinition) {
### For Architects
- [ObjectQL Integration](/docs/protocol/objectql) - How ObjectUI leverages ObjectQL schemas
-- [Widget Reference](/docs/references/ui/widget) - Widget contract for building renderers
+- [Widget Contract](/docs/protocol/objectui/widget-contract) - The props a custom field widget receives
- [Sharing & Permissions](/docs/references/ui/sharing) - Field-level and view-level access
### For Developers
diff --git a/content/docs/protocol/objectui/index.mdx b/content/docs/protocol/objectui/index.mdx
index ea80e8e3ab..80c92a45fe 100644
--- a/content/docs/protocol/objectui/index.mdx
+++ b/content/docs/protocol/objectui/index.mdx
@@ -546,7 +546,7 @@ mobile_renderer:
Building a renderer for ObjectUI?
- [Component Reference](/docs/references/ui/component) - Widget contract and standard props
-- [Widget Reference](/docs/references/ui/widget) - Dashboard widget schema
+- [Dashboard Reference](/docs/references/ui/dashboard) - Dashboard widget schema
- [Widget Contract](/docs/protocol/objectui/widget-contract) - Standard props and events
### For Users
diff --git a/content/docs/protocol/objectui/widget-contract.mdx b/content/docs/protocol/objectui/widget-contract.mdx
index d7a1ed0998..33c9530e29 100644
--- a/content/docs/protocol/objectui/widget-contract.mdx
+++ b/content/docs/protocol/objectui/widget-contract.mdx
@@ -8,10 +8,17 @@ import { Component, Zap, Code, Check, AlertCircle, Globe } from 'lucide-react';
The **Widget Contract** defines the standard interface that custom ObjectUI field widgets implement. This contract ensures consistency across renderers and lets custom widgets integrate predictably with the rest of the UI system.
-The contract has two halves, both defined in `packages/spec/src/ui/widget.zod.ts`:
+The contract is **`FieldWidgetProps`** (`packages/spec/src/ui/widget.zod.ts`) — the props
+every field widget receives at render time. Implement it and your widget drops into any
+ObjectStack form.
-- **`FieldWidgetProps`** — the props every field widget receives at render time.
-- **`WidgetManifest`** — the static declaration that registers a custom widget (its supported field types, lifecycle hooks, events, configurable properties, and how its code is loaded).
+
+ The protocol declares what a widget *receives*, not how it is *registered*.
+ Registration belongs to the renderer, and metadata reaches it by naming a widget
+ with a string — see [Registering a Widget](#registering-a-widget). The
+ `WidgetManifest` family that used to sit beside this contract was removed in
+ `@objectstack/spec` 17.0.0 (#5055, ADR-0049): no schema anywhere ever carried it.
+
## Philosophy: Props Down, Events Up
@@ -176,163 +183,53 @@ fields:
widget: rating
```
-## Widget Manifest
-
-A custom widget is registered through a **Widget Manifest** (`WidgetManifestSchema`). The manifest declares the widget's identity, the field types it supports, its lifecycle and events, its configurable properties, and how its code is loaded.
-
-```typescript
-interface WidgetManifest {
- name: string; // snake_case identifier
- label: string; // Display name
- description?: string;
- version?: string; // semver
- author?: string;
- icon?: string;
- fieldTypes?: string[]; // Supported field types, e.g. ['date', 'datetime']
- category?: 'input' | 'display' | 'picker' | 'editor' | 'custom'; // default: 'custom'
-
- lifecycle?: WidgetLifecycle; // Lifecycle hooks
- events?: WidgetEvent[]; // Custom events emitted
- properties?: WidgetProperty[]; // Configurable properties
- implementation?: WidgetSource; // How to load the widget code
-
- dependencies?: { name: string; version?: string; url?: string }[];
- aria?: AriaProps; // ARIA accessibility attributes
-}
-```
-
-**Example:**
-```yaml
-name: custom_date_picker
-label: Custom Date Picker
-version: 1.0.0
-author: Acme Inc
-fieldTypes: [date, datetime]
-category: picker
-implementation:
- type: npm
- packageName: '@acme/custom-date-picker'
- version: 1.0.0
-```
-
-### Widget Source
-
-`implementation` is a discriminated union on `type` that tells the host how to load the widget code:
-
-```yaml
-# NPM package
-implementation:
- type: npm
- packageName: '@acme/widgets'
- version: latest
- exportName: DatePicker # optional named export
-
-# Module Federation remote
-implementation:
- type: remote
- url: https://cdn.example.com/remoteEntry.js
- moduleName: ./DatePicker
- scope: acme_widgets
-
-# Inline code
-implementation:
- type: inline
- code: |
- return value ? new Date(value).toLocaleDateString() : '';
-```
-
-## Lifecycle Hooks
+## Registering a Widget
-Widget lifecycle hooks are defined on the manifest as **code-body strings** (not function references). They follow `WidgetLifecycleSchema`:
-
-{/* os:check */}
-```typescript
-interface WidgetLifecycle {
- onMount?: string; // Initialization when the widget mounts
- onUpdate?: string; // Runs when props change (receives prevProps)
- onUnmount?: string; // Cleanup before the widget unmounts
- onValidate?: string; // Custom validation; return an error message or null
- onFocus?: string; // Runs on focus
- onBlur?: string; // Runs on blur
- onError?: string; // Error handling
-}
-```
-
-**Example:**
-```yaml
-lifecycle:
- onMount: "initializeDatePicker(); loadOptions();"
- onUpdate: "if (prevProps.value !== props.value) { updateDisplay() }"
- onValidate: "return value && value.length >= 10 ? null : 'Minimum 10 characters'"
- onUnmount: "destroyDatePicker(); cancelPendingRequests();"
-```
+A field widget is named by a **string**, not by a metadata document. In a view, the
+field's `type` auto-infers a widget and an explicit `widget` name overrides that
+inference; the name resolves against the widgets the **renderer** has registered.
+That is the whole authorable surface, and it is the one shown under
+[Overriding the Inferred Widget](#overriding-the-inferred-widget) above.
-## Custom Events
+Registration itself belongs to the renderer. In objectui, `@object-ui/core`'s
+`WidgetRegistry` holds the runtime manifests (`RuntimeWidgetManifest` in
+`@object-ui/types`) and decides how a widget's code is discovered and loaded. The
+protocol declares the props contract that a widget must implement; it does not
+declare the registry.
-A widget can declare custom events it emits via `WidgetEventSchema`:
-
-{/* os:check */}
-```typescript
-interface WidgetEvent {
- name: string; // lowercase, dash-separated, e.g. 'value-change'
- label?: string;
- description?: string;
- bubbles?: boolean; // default false
- cancelable?: boolean; // default false
- payload?: Record; // payload shape
-}
-```
-
-**Example:**
-```yaml
-events:
- - name: search-complete
- label: Search Complete
- bubbles: true
- payload:
- query: string
- results: object
-```
-
-## Configurable Properties
-
-A widget exposes configuration knobs through `WidgetPropertySchema`. These describe the options a builder can set when placing the widget:
-
-{/* os:check */}
-```typescript
-interface WidgetProperty {
- name: string; // camelCase
- label?: string;
- type: 'string' | 'number' | 'boolean' | 'array' | 'object' | 'function' | 'any';
- required?: boolean; // default false
- default?: unknown;
- description?: string;
- validation?: Record; // min/max, regex, enum, etc.
- category?: string; // for grouping in the builder UI
-}
-```
-
-**Example:**
-```yaml
-properties:
- - name: maxLength
- label: Maximum Length
- type: number
- required: false
- default: 100
- description: Maximum input length
- category: validation
-```
+
+ **`WidgetManifest` and its family were removed in `@objectstack/spec` 17.0.0**
+ (#5055, ADR-0049 enforce-or-remove). `WidgetManifestSchema`,
+ `WidgetLifecycleSchema`, `WidgetEventSchema`, `WidgetPropertySchema` and
+ `WidgetSourceSchema` — with the `npm` / `remote` / `inline` implementation
+ union, the `onMount` / `onValidate` lifecycle hooks and the declarable
+ `properties` and `events` — described a registration capability the platform
+ never had. **No schema anywhere carried them**: there was no key on any
+ metadata type whose value was a widget manifest, so no document could reach
+ these shapes and nothing ever parsed one. They are `TS2305` on
+ `@objectstack/spec/ui` after upgrade.
+
+ **Nothing needs rewriting.** A manifest was never writable, so no stored
+ metadata can contain one, and `field.widget: my_picker` — the key that names a
+ widget for real — is untouched. If you were building a value of one of these
+ types in your own TypeScript, delete it: the object was received by nobody.
+
+ Widget registration returns as protocol metadata only through the *enforce*
+ route of ADR-0049 — a registry and a loader first, then the vocabulary that
+ describes what they actually do.
+
## Accessibility
-A widget manifest carries ARIA metadata through the shared `AriaProps` schema (`packages/spec/src/ui/i18n.zod.ts`). The supported attributes are intentionally minimal:
+`AriaProps` (`packages/spec/src/ui/i18n.zod.ts`) is the shared ARIA shape carried
+by the live UI schemas — views, pages, page components, charts and actions all
+declare an `aria:` block. The supported attributes are intentionally minimal:
{/* os:check */}
```typescript
interface AriaProps {
ariaLabel?: string; // Accessible label for screen readers
- ariaDescribedBy?: string; // ID of an element that describes this widget
+ ariaDescribedBy?: string; // ID of an element that describes this element
role?: string; // WAI-ARIA role override
}
```
@@ -345,15 +242,24 @@ aria:
role: textbox
```
+`AriaPropsSchema` is `.strict()` (#4001), so a misspelled key is a parse failure
+carrying its own prescription rather than an accessible name that silently
+disappears. Note that a *widget* does not author this block — the shapes above do.
+What a widget contributes is the state on the control it renders
+(`aria-invalid`, `aria-required`), per [Who Renders What](#who-renders-what).
+
## Performance
-The widget manifest carries **no** performance block. Virtualization for large datasets is configured on the **view**, not on the widget: set the boolean `virtualScroll` on a list-shaped view (`ListViewSchema` in `packages/spec/src/ui/view.zod.ts`). That is the only virtual-scrolling switch objectui reads.
+There is **no** performance block anywhere in this contract. Virtualization for
+large datasets is configured on the **view**: set the boolean `virtualScroll` on a
+list-shaped view (`ListViewSchema` in `packages/spec/src/ui/view.zod.ts`). That is
+the only virtual-scrolling switch objectui reads.
-
- `widget.performance` was removed in `@objectstack/spec` 17.0.0 (#3896 audit
- close-out) — it was authorable but no renderer or runtime ever read it. The key
- is now refused at parse time, so a manifest that still carries it fails
- validation: delete it and use the view's `virtualScroll` instead.
+
+ `widget.performance` was removed at the #3896 audit close-out and its tombstone
+ was subsumed by #5055 when the manifest that carried it was itself removed — so
+ there is no longer a key to reject, because there is no longer a shape to author
+ it into. Use the view's `virtualScroll`.
## Theme
diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx
index 27c4689629..19de4e24de 100644
--- a/content/docs/references/index.mdx
+++ b/content/docs/references/index.mdx
@@ -1,6 +1,6 @@
---
title: Protocol Reference
-description: Every schema published by @objectstack/spec — 1611 schemas across 14 protocol modules
+description: Every schema published by @objectstack/spec — 1601 schemas across 14 protocol modules
---
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
@@ -32,8 +32,8 @@ counts are sums of the rows they head. Regenerate with
| [Shared Protocol](/docs/references/shared) | 8 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. |
| [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. |
| [System Protocol](/docs/references/system) | 37 | 295 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
-| [UI Protocol](/docs/references/ui) | 17 | 156 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
-| **Total** | **201** | **1611** | 14 protocol modules |
+| [UI Protocol](/docs/references/ui) | 16 | 146 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
+| **Total** | **200** | **1601** | 14 protocol modules |
---
@@ -366,7 +366,7 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a
## UI Protocol
-**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **17 pages, 156 schemas**
+**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 146 schemas**
Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer.
@@ -380,7 +380,7 @@ Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI lay
| [`component.zod.ts`](/docs/references/ui/component) | `AIChatWindowProps`, `ElementButtonProps`, `ElementFilterProps`, `ElementFormProps`, `ElementImageProps`, `ElementMetadataViewerProps`, `ElementNumberProps`, `ElementRecordPickerProps`, `ElementTextInputProps`, `ElementTextProps`, `PageAccordionProps`, `PageCardProps`, `PageContainerProps`, `PageHeaderProps`, `PageTabsProps`, `RecordActivityProps`, `RecordChatterProps`, `RecordDetailsProps`, `RecordHighlightsField`, `RecordHighlightsProps`, `RecordPathProps`, `RecordRelatedListProps` |
| [`dashboard.zod.ts`](/docs/references/ui/dashboard) | `Dashboard`, `DashboardHeader`, `DashboardHeaderAction`, `DashboardWidget`, `DashboardWidgetOptions`, `GlobalFilter`, `GlobalFilterOptionsFrom`, `WidgetActionType`, `WidgetColorVariant` |
| [`dataset.zod.ts`](/docs/references/ui/dataset) | `Dataset`, `DatasetDimension`, `DatasetMeasure`, `DerivedMeasureOp` |
-| [`i18n.zod.ts`](/docs/references/ui/i18n) | `AriaProps`, `DateFormat`, `I18nLabel`, `I18nObject`, `LocaleConfig`, `NumberFormat`, `PluralRule` |
+| [`i18n.zod.ts`](/docs/references/ui/i18n) | `AriaProps`, `I18nLabel` |
| [`notification.zod.ts`](/docs/references/ui/notification) | `NotificationPosition`, `NotificationSeverity`, `NotificationType` |
| [`page.zod.ts`](/docs/references/ui/page) | `ElementDataSource`, `InterfacePageConfig`, `Page`, `PageComponent`, `PageComponentType`, `PageRegion`, `PageType`, `PageVariable` |
| [`report.zod.ts`](/docs/references/ui/report) | `JoinedReportBlock`, `Report`, `ReportChart`, `ReportSort`, `ReportType` |
@@ -388,7 +388,6 @@ Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI lay
| [`sharing.zod.ts`](/docs/references/ui/sharing) | `SharingConfig` |
| [`theme.zod.ts`](/docs/references/ui/theme) | `BorderRadius`, `ColorPalette`, `Shadow`, `Theme`, `ThemeMode`, `Typography` |
| [`view.zod.ts`](/docs/references/ui/view) | `AddRecordConfig`, `AppearanceConfig`, `CalendarConfig`, `ColumnPrefix`, `ColumnSummary`, `ColumnSummaryConfig`, `FormButtonConfig`, `FormField`, `FormSection`, `FormView`, `GalleryConfig`, `GanttConfig`, `GanttQuickFilter`, `GroupingConfig`, `GroupingField`, `HttpMethodSubset`, `HttpRequest`, `KanbanConfig`, `ListChartConfig`, `ListColumn`, `ListView`, `NavigationConfig`, `NavigationMode`, `ObjectListView`, `ObjectUserFilters`, `PaginationConfig`, `RowColorConfig`, `RowHeight`, `SelectionConfig`, `TimelineConfig`, `TreeConfig`, `UserActionsConfig`, `UserFilterField`, `UserFilters`, `View`, `ViewData`, `ViewFilterRule`, `ViewItem`, `ViewItemName`, `ViewItemWire`, `ViewKind`, `ViewScope`, `ViewSharing`, `ViewTab`, `VisualizationType` |
-| [`widget.zod.ts`](/docs/references/ui/widget) | `WidgetEvent`, `WidgetLifecycle`, `WidgetManifest`, `WidgetProperty`, `WidgetSource` |
---
diff --git a/content/docs/references/ui/i18n.mdx b/content/docs/references/ui/i18n.mdx
index 3ddf259d97..048c0d58c0 100644
--- a/content/docs/references/ui/i18n.mdx
+++ b/content/docs/references/ui/i18n.mdx
@@ -5,18 +5,6 @@ description: I18n protocol schemas
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
-I18n Object Schema
-Structured internationalization label with translation key and parameters.
-
-@example
-```typescript
-const label: I18nObject = {
- key: 'views.task_list.label',
- defaultValue: 'Task List',
- params: { count: 5 },
-};
-```
-
**Source:** `packages/spec/src/ui/i18n.zod.ts`
@@ -24,8 +12,8 @@ const label: I18nObject = {
## TypeScript Usage
```typescript
-import { AriaPropsSchema, DateFormatSchema, I18nLabelSchema, I18nObjectSchema, LocaleConfigSchema, NumberFormatSchema, PluralRuleSchema } from '@objectstack/spec/ui';
-import type { AriaProps, DateFormat, I18nLabel, I18nObject, LocaleConfig, NumberFormat, PluralRule } from '@objectstack/spec/ui';
+import { AriaPropsSchema, I18nLabelSchema } from '@objectstack/spec/ui';
+import type { AriaProps, I18nLabel } from '@objectstack/spec/ui';
// Validate data
const result = AriaPropsSchema.parse(data);
@@ -48,89 +36,6 @@ ARIA accessibility attributes
---
-## DateFormat
-
-Date/time formatting rules
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **dateStyle** | `Enum<'full' \| 'long' \| 'medium' \| 'short'>` | optional | Date display style |
-| **timeStyle** | `Enum<'full' \| 'long' \| 'medium' \| 'short'>` | optional | Time display style |
-| **timeZone** | `string` | optional | IANA time zone (e.g., "America/New_York") |
-| **hour12** | `boolean` | optional | Use 12-hour format |
-
-
----
-
-
----
-
-## I18nObject
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **key** | `string` | ✅ | Translation key (e.g., "views.task_list.label") |
-| **defaultValue** | `string` | optional | Fallback value when translation key is not found |
-| **params** | `Record` | optional | Interpolation parameters (e.g., `{ count: 5 }`) |
-
-
----
-
-## LocaleConfig
-
-Locale configuration
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **code** | `string` | ✅ | BCP 47 language code (e.g., "en-US", "zh-CN") |
-| **fallbackChain** | `string[]` | optional | Fallback language codes in priority order (e.g., ["zh-TW", "en"]) |
-| **direction** | `Enum<'ltr' \| 'rtl'>` | ✅ | Text direction: left-to-right or right-to-left |
-| **numberFormat** | `{ style: Enum<'decimal' \| 'currency' \| 'percent' \| 'unit'>; currency?: string; unit?: string; minimumFractionDigits?: number; … }` | optional | Default number formatting rules |
-| **dateFormat** | `{ dateStyle?: Enum<'full' \| 'long' \| 'medium' \| 'short'>; timeStyle?: Enum<'full' \| 'long' \| 'medium' \| 'short'>; timeZone?: string; hour12?: boolean }` | optional | Default date/time formatting rules |
-
-
----
-
-## NumberFormat
-
-Number formatting rules
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **style** | `Enum<'decimal' \| 'currency' \| 'percent' \| 'unit'>` | ✅ | Number formatting style |
-| **currency** | `string` | optional | ISO 4217 currency code (e.g., "USD", "EUR") |
-| **unit** | `string` | optional | Unit for unit formatting (e.g., "kilometer", "liter") |
-| **minimumFractionDigits** | `number` | optional | Minimum number of fraction digits |
-| **maximumFractionDigits** | `number` | optional | Maximum number of fraction digits |
-| **useGrouping** | `boolean` | optional | Whether to use grouping separators (e.g., 1,000) |
-
-
----
-
-## PluralRule
-
-ICU plural rules for a translation key
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **key** | `string` | ✅ | Translation key |
-| **zero** | `string` | optional | Zero form (e.g., "No items") |
-| **one** | `string` | optional | Singular form (e.g., "`{count}` item") |
-| **two** | `string` | optional | Dual form (e.g., "`{count}` items" for exactly 2) |
-| **few** | `string` | optional | Few form (e.g., for 2-4 in some languages) |
-| **many** | `string` | optional | Many form (e.g., for 5+ in some languages) |
-| **other** | `string` | ✅ | Default plural form (e.g., "`{count}` items") |
-
---
diff --git a/content/docs/references/ui/index.mdx b/content/docs/references/ui/index.mdx
index e44125cc1a..5e371eab14 100644
--- a/content/docs/references/ui/index.mdx
+++ b/content/docs/references/ui/index.mdx
@@ -22,5 +22,4 @@ This section contains all protocol schemas for the ui layer of ObjectStack.
-
diff --git a/content/docs/references/ui/meta.json b/content/docs/references/ui/meta.json
index a1dff05a94..af1834543f 100644
--- a/content/docs/references/ui/meta.json
+++ b/content/docs/references/ui/meta.json
@@ -13,7 +13,6 @@
"dashboard",
"dataset",
"report",
- "widget",
"---Interaction & Layout---",
"responsive",
"theme",
diff --git a/content/docs/references/ui/widget.mdx b/content/docs/references/ui/widget.mdx
deleted file mode 100644
index 043b236c19..0000000000
--- a/content/docs/references/ui/widget.mdx
+++ /dev/null
@@ -1,176 +0,0 @@
----
-title: Widget
-description: Widget protocol schemas
----
-
-{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
-
-Widget Lifecycle Hooks Schema
-
-Defines lifecycle callbacks for custom widgets inspired by Web Components and React.
-These hooks allow widgets to perform initialization, cleanup, and respond to changes.
-
-See also: https://developer.mozilla.org/en-US/docs/Web/API/Web_components
-
-See also: https://react.dev/reference/react/Component#component-lifecycle
-
-@example
-```typescript
-const widget = {
- lifecycle: {
- onMount: "console.log('Widget mounted')",
- onUpdate: "if (prevProps.value !== props.value) { updateUI() }",
- onUnmount: "cleanup()",
- onValidate: "return value.length > 0 ? null : 'Required field'"
- }
-}
-```
-
-
-**Source:** `packages/spec/src/ui/widget.zod.ts`
-
-
-## TypeScript Usage
-
-```typescript
-import { WidgetEventSchema, WidgetLifecycleSchema, WidgetManifestSchema, WidgetPropertySchema, WidgetSourceSchema } from '@objectstack/spec/ui';
-import type { WidgetEvent, WidgetLifecycle, WidgetManifest, WidgetProperty, WidgetSource } from '@objectstack/spec/ui';
-
-// Validate data
-const result = WidgetEventSchema.parse(data);
-```
-
----
-
-## WidgetEvent
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **name** | `string` | ✅ | Event name |
-| **label** | `string` | optional | Human-readable event label |
-| **description** | `string` | optional | Event description and usage |
-| **bubbles** | `boolean` | ✅ | Whether event bubbles |
-| **cancelable** | `boolean` | ✅ | Whether event is cancelable |
-| **payload** | `Record` | optional | Event payload schema |
-
-
----
-
-## WidgetLifecycle
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **onMount** | `string` | optional | Initialization code when widget mounts |
-| **onUpdate** | `string` | optional | Code to run when props change |
-| **onUnmount** | `string` | optional | Cleanup code when widget unmounts |
-| **onValidate** | `string` | optional | Custom validation logic |
-| **onFocus** | `string` | optional | Code to run on focus |
-| **onBlur** | `string` | optional | Code to run on blur |
-| **onError** | `string` | optional | Error handling code |
-
-
----
-
-## WidgetManifest
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **name** | `string` | ✅ | Widget identifier (snake_case) |
-| **label** | `string` | ✅ | Widget display name |
-| **description** | `string` | optional | Widget description |
-| **version** | `string` | optional | Widget version (semver) |
-| **author** | `string` | optional | Widget author |
-| **icon** | `string` | optional | Widget icon |
-| **fieldTypes** | `string[]` | optional | Supported field types |
-| **category** | `Enum<'input' \| 'display' \| 'picker' \| 'editor' \| 'custom'>` | ✅ | Widget category |
-| **lifecycle** | `{ onMount?: string; onUpdate?: string; onUnmount?: string; onValidate?: string; … }` | optional | Lifecycle hooks |
-| **events** | `{ name: string; label?: string; description?: string; bubbles: boolean; … }[]` | optional | Custom events |
-| **properties** | `{ name: string; label?: string; type: Enum<'string' \| 'number' \| 'boolean' \| 'array' \| 'object' \| 'function' \| 'any'>; required: boolean; … }[]` | optional | Configuration properties |
-| **implementation** | `{ type: 'npm'; packageName: string; version: string; exportName?: string } \| { type: 'remote'; url: string; moduleName: string; scope: string } \| { type: 'inline'; code: string }` | optional | Widget implementation source |
-| **dependencies** | `{ name: string; version?: string; url?: string }[]` | optional | Widget dependencies |
-| **screenshots** | `string[]` | optional | Screenshot URLs |
-| **documentation** | `string` | optional | Documentation URL |
-| **license** | `string` | optional | License (SPDX identifier) |
-| **tags** | `string[]` | optional | Tags for categorization |
-| **aria** | `{ ariaLabel?: string; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |
-| **performance** | `never` | optional | [REMOVED] `widget.performance` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no renderer or runtime ever read it. Delete the key. Virtual scrolling is the live top-level `virtualScroll` on list-shaped views. |
-
-
----
-
-## WidgetProperty
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **name** | `string` | ✅ | Property name (camelCase) |
-| **label** | `string` | optional | Human-readable label |
-| **type** | `Enum<'string' \| 'number' \| 'boolean' \| 'array' \| 'object' \| 'function' \| 'any'>` | ✅ | TypeScript type |
-| **required** | `boolean` | ✅ | Whether property is required |
-| **default** | `any` | optional | Default value |
-| **description** | `string` | optional | Property description |
-| **validation** | `Record` | optional | Validation rules |
-| **category** | `string` | optional | Property category |
-
-
----
-
-## WidgetSource
-
-### Union Options
-
-This schema accepts one of the following structures:
-
-#### Option 1
-
-**Type:** `npm`
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **type** | `'npm'` | ✅ | |
-| **packageName** | `string` | ✅ | NPM package name |
-| **version** | `string` | ✅ | |
-| **exportName** | `string` | optional | Named export (default: default) |
-
----
-
-#### Option 2
-
-**Type:** `remote`
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **type** | `'remote'` | ✅ | |
-| **url** | `string` | ✅ | Remote entry URL (.js) |
-| **moduleName** | `string` | ✅ | Exposed module name |
-| **scope** | `string` | ✅ | Remote scope name |
-
----
-
-#### Option 3
-
-**Type:** `inline`
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **type** | `'inline'` | ✅ | |
-| **code** | `string` | ✅ | JavaScript code body |
-
----
-
-
----
-
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 8b75e0e07c..825d56f8e2 100644
--- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
+++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
@@ -21,9 +21,9 @@ regenerate.
| Measure | Value |
|---|---|
| Triaged directories | 5 |
-| Object sites in them | 457 |
-| Still-open (strip) sites | 198 |
-| Files carrying at least one | 30 |
+| Object sites in them | 444 |
+| Still-open (strip) sites | 185 |
+| Files carrying at least one | 29 |
Remaining strip sites by class:
@@ -32,7 +32,7 @@ Remaining strip sites by class:
| authorable — the ruling's forced scope | 43 |
| unresolved — needs a per-schema verdict | 33 |
| wire / open — out of forced scope | 107 |
-| no door — no carrier, ADR-0049 territory | 14 |
+| no door — no carrier, ADR-0049 territory | 1 |
| no gate — carrier live, no parse | 0 |
| covered — no carrier, no parse, guarded at every consumer | 1 |
@@ -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/` | 173 | 116 | 5 | 0 | 52 |
+| `ui/` | 160 | 116 | 5 | 0 | 39 |
| `data/` | 162 | 54 | 1 | 0 | 107 |
| `automation/` | 75 | 49 | 0 | 0 | 26 |
| `security/` | 20 | 7 | 0 | 0 | 13 |
| `studio/` | 27 | 27 | 0 | 0 | 0 |
-| **total** | **457** | **253** | **6** | **0** | **198** |
+| **total** | **444** | **253** | **6** | **0** | **185** |
## File-level triage — site counts
@@ -69,15 +69,15 @@ classify and is not listed (it becomes reportable the day it grows its first sit
| `component.zod.ts` | 31 |
| `dashboard.zod.ts` | 11 |
| `dataset.zod.ts` | 4 |
-| `i18n.zod.ts` | 6 |
+| `i18n.zod.ts` | 1 |
| `page.zod.ts` | 7 |
| `report.zod.ts` | 3 |
| `responsive.zod.ts` | 4 |
| `sharing.zod.ts` | 1 |
| `theme.zod.ts` | 6 |
| `view.zod.ts` | 53 |
-| `widget.zod.ts` | 9 |
-| **total** | **173** |
+| `widget.zod.ts` | 1 |
+| **total** | **160** |
### `data/` — sites
@@ -157,7 +157,7 @@ over it is here.
### `ui/` — open
-**52 strip of 173**, in 7 file(s).
+**39 strip of 160**, in 6 file(s).
| File | Strip | Sites |
|---|---|---|
@@ -165,17 +165,16 @@ over it is here.
| `app.zod.ts` | 1 | 18 |
| `chart.zod.ts` | 2 | 8 |
| `component.zod.ts` | 31 | 31 |
-| `i18n.zod.ts` | 5 | 6 |
| `view.zod.ts` | 3 | 53 |
-| `widget.zod.ts` | 9 | 9 |
-| **total** | **52** | **173** |
+| `widget.zod.ts` | 1 | 1 |
+| **total** | **39** | **160** |
| Bucket | Sites |
|---|---|
| authorable — the ruling's forced scope | 34 |
| unresolved — needs a per-schema verdict | 0 |
| wire / open — out of forced scope | 3 |
-| no door — no carrier, ADR-0049 territory | 14 |
+| no door — no carrier, ADR-0049 territory | 1 |
| no gate — carrier live, no parse | 0 |
| covered — no carrier, no parse, guarded at every consumer | 1 |
diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md
index 86b61f236b..c07b69ec6f 100644
--- a/docs/audits/2026-07-unknown-key-strictness-ledger.md
+++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md
@@ -651,10 +651,10 @@ sites left to be a verdict about.
| `theme.zod.ts` | authorable | **strict as of #4001 批 15** — all 14 sites. The `(p)` resolved to authorable on two doors, both measured: `stack.zod.ts` declares `themes: z.array(ThemeSchema)` (so `defineStack()` parses every theme on boot and on `objectstack build`), and `defineTheme()` parses one directly. A BFS from all 24 metadata-type roots plus `ObjectStackSchema` reaches every schema in the file, with `PageSchema`/`DashboardSchema`/`ReportSchema`/`WebhookSchema`/`StateMachineSchema` passing as positive controls and 批 13's no-door shapes failing as negative controls **in the same run**. Note what is NOT claimed: `theme` is deliberately absent from `BUILTIN_METADATA_TYPE_SCHEMAS`, so a stored theme row is not validated by the metadata REST door — the gate is the authoring one, and the file says so rather than implying reach it lacks. **The `passthrough` question was asked per BLOCK, not per file**, and the answer split: objectui's `ThemeEngine` reads `colors`/`borderRadius`/`shadows`/`typography.fontFamily` through FIXED maps (an extra key is read by nothing, ever), but spreads `fontSize`/`fontWeight`/`lineHeight`/`letterSpacing`/`duration`/`timing`/`zIndex` with `Object.entries` into `--font-size-` … — the #4909 open shape at the runtime. Closed anyway, on two measurements: `.strip` already discarded those extras before the engine saw them (so no author depends on the openness and nothing the renderer receives changes), and `customVars` is a DECLARED escape hatch that emits an arbitrary CSS custom property by name, so closing the token scales removes no capability and only removes a second, undocumented way to spell one — the way whose typos are indistinguishable from intent. Curation is measured throughout: the shadcn vocabulary (`card`→`surface`, `foreground`→`text`, `destructive`→`error`) comes from objectui's own `COLOR_TO_CSS_MAP`, which RENAMES every palette key on the way out; `md`→`base` on `fontSize` and `base`→`normal` on `fontWeight` are a same-file scale disagreement (`borderRadius`/`shadows` declare `md`, `fontSize` does not); `radius`→`base` because `base` is emitted as the bare `--radius`, the one radius variable objectui's CSS actually reads; and `easeIn`→`ease_in` because `animation.timing` is the file's single snake_case vocabulary, so the camelCase spelling is an author obeying AGENTS.md #3 rather than making a typo. The eight #3494 removals get one distinct tombstone each. ⚠️ **Two of those tombstones deliberately prescribe NO replacement slot**: `touchTarget`/`keyboardNavigation` read like they should point at `ui/touch.zod.ts`/`ui/keyboard.zod.ts`, which 批 13 measured as having no carrier at all — prescribing them would walk an author out of a loud rejection into a silent one, the ledger's finding 7. **#4988 then retired both modules outright**, so the two tombstones' refusal to name a replacement is now the only correct wording available: had they pointed at `ui/touch.zod.ts` / `ui/keyboard.zod.ts`, that prescription would today name a deleted file — finding 7 with an extra major on top. ⚠️ **Separately filed — and ANSWERED at #5021, which is why this row's site count fell 14 → 6.** 批 15 recorded that `--font-size-*`, `--font-weight-*`, `--line-height-*`, `--letter-spacing-*`, `--z-*`, `--duration-*`, `--timing-*`, `--font-heading` and `--font-mono` have ZERO first-party consumers (only the colour vars, `--radius*`, `--shadow*` and `--font-sans` are read), and refused to act on it inside a strictness batch: that is ADR-0049 liveness, not unknown keys, and the two must not be run together — strictness makes a dropped key loud, it cannot make a slot live. The refusal was correct and the separation is what made the follow-up answerable. #5021 re-measured against objectui `main` (2026-08-04) with `--font-sans`/`--radius`/`--shadow`/`--primary` as positive controls **in the same run**, the maintainer ruled RETIRE over both alternatives (wire consumers / bless as a public token surface — the latter rejected as a stability promise attached to a slot the platform's own UI ignores, the #4583 shape), and `typography.fontSize`/`.fontWeight`/`.lineHeight`/`.letterSpacing`, `typography.fontFamily.heading`/`.mono`, `animation` and `zIndex` are now `retiredKey()` tombstones prescribing `customVars`. **Note what this row's arithmetic does NOT say**: the eight sites left `ui/` from the `strict` column (120 → 112), and `strip` is unchanged at 75 — a retirement removes closed doors, so it cannot move this ratchet's open-site debt in either direction. The two campaigns stayed disjoint to the end. ⚠️ The prescription is `customVars` **because it was measured live**, not because it is the nearest-looking slot: the engine emits each entry as `--: ` verbatim, so every retired variable is reproducible byte for byte and the retirement removes no capability — the distinction from `touchTarget`/`keyboardNavigation` two sentences up, which got NO replacement precisely because theirs would have been a guess. The five aliases pointing at the retired keys (`animations`/`motion`/`transitions` → `animation`, `layers`/`stacking` → `zIndex`) and the seven pointing into the retired typography scales were **deleted with their targets**, not re-pointed — leaving them would answer an author with "did you mean `zIndex`?" and then reject `zIndex`, finding 7's exact shape, and this file has now signposted that failure mode three times |
| `app.zod.ts` | authorable | **strict as of #4001 PR B** — `AppSchema` + branding / area / context-selector / contribution, and the nav-item union converted to `z.discriminatedUnion('type', …)` (the union-error question, settled empirically: matched-branch-only errors, exact recursive paths, `toJSONSchema` clean). Per-target `params` stay open. PR A (#4142) tombstoned the seven audit-dead keys first |
| `dashboard.zod.ts` | authorable | **strict as of #4001 批 14 — 0 strip sites remain.** `DashboardWidgetSchema` has been strict since the ADR-0021 cutover; 批 14 closed the two NESTED holes inside it (`compareTo`'s object arm, `layout`), the same strict-shell-over-strip-children silhouette 批 13 found on `page.components[]`. `DashboardWidgetOptionsSchema` stays `passthrough` **deliberately** (renderer escape hatch) and the `responsive` tombstone (#4876) is untouched. ⚠️ **The `compareTo` union caveat this row carried is RESOLVED, and it is the one entry in this table whose limit was dissolved rather than worked around.** 批 14 recorded that `compareTo` was a UNION, so its curated prescription was produced but never delivered — `zodIssuesToFields` maps only top-level issues and a failed union collapses to a bare `Invalid input` (#5014) — with the rejection itself unaffected. **#5011 removed the union**: the slot converged onto the analytics executor's own contract, `{ kind, dimension? }`, a plain strict object whose message IS top-level. The reason was not the message, it was worse — all three declared arms were broken on the ADR-0021 dataset path (the two strings silently dropped by the renderer, `{ offset }` throwing `compareTo requires a timeDimension "undefined"`), while all three worked on the legacy inline path: same key, two fates, the failing one blessed. The union-free shape is the design benefit, pinned in `dashboard-compareto.test.ts` so it cannot silently return. **#5014 still binds every OTHER curated message this campaign has put inside a union arm** — this row is one slot's correction, not the finding's retraction. ⚠️ **#5010 retired four more widget keys and moved this row's posture by nothing, which is the point.** The `#4956` drill gave `DashboardWidgetSchema`'s 22 widget-level keys their first per-key verdicts and found six dead; `actionUrl`/`actionType`/`actionIcon` (a per-widget action BUTTON no renderer in either repo has ever drawn — all 14 `actionUrl` reads in `DashboardRenderer` are scoped to `header.actions[]`) and `aria` (ARIA attributes that never reached the DOM — the dashboard-level `aria` the #3896 sweep removed, one level down) are now `retiredKey` tombstones beside `responsive`. **Strip sites remain 0 and the strictness verdict is untouched**, because a retirement is ADR-0049 work and this ratchet is not: closing a door makes a *dropped* key loud, it cannot make a *declared* one live — the same boundary `theme.zod.ts` records two rows up, met here from the other side. The removal also settled a second-order cost the strictness campaign could never have reached: `packages/lint`'s dashboard action-ref rule enforced ERROR-severity reference integrity on `widgets[].actionUrl`, its docblock calling the key "the per-widget button" and claiming to mirror a runtime dispatch that does not exist, so an author could FAIL A BUILD because a control that cannot render pointed at an action that also did not — an enforcement gate sustaining the very false affordance ADR-0049 wrote it to delete. That widget branch is gone, pinned. ⚠️ **`colorVariant`, the fifth dead key, is deliberately NOT retired here and this row must not be read as closing it**: the rewrite target the #4956 triage assumed (`options.colorVariant`) measured dead too — `options` only reaches a renderer through `componentSchema` on the INLINE path, and `dataset` is required on this schema, so every spec-authorable widget is dataset-bound and renders through `DatasetWidget`, which has no colour affordance at all. Moving the key there would relocate 16 authored sites from one dead slot to another and mint a second inert key. Returned for adjudication; `chartConfig`'s dashboard-face inertness (11 of 12 keys, #5175) is the same shape on the neighbouring slot |
-| `widget.zod.ts` | ~~authorable (p)~~ **no door** | **no authoring door (measured, #4001 批 16)** — the `(p)` resolved NEGATIVE for the whole file, the second such run after 批 13's five. Three independent measurements on 2026-08-04: (1) nothing under `packages/spec/src` imports this module except the `ui/index.ts` barrel, so no schema anywhere declares a carrier key for a widget shape — `field.widget` is a `z.string()` naming a registered *component* and has never referenced `WidgetManifest`; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack` (4 766 nodes) reaches none of the six shapes, while `PageSchema` / `ObjectListViewSchema` resolve in the same run, a fresh `z.object` and a deliberate look-alike both resolve unreachable, and a synthetic carrier flips all six to reachable; (3) zero `.parse()` / `.safeParse()` in `objectstack`, `objectui` or `cloud` outside this file's own tests — objectui re-exports the inferred TYPES only and under different names (`RuntimeWidgetManifest` / `FieldWidgetComponentProps`, #4115 / #3161), and a `cloud` code search returns 0 for every symbol against a working index (`"@objectstack/spec"` → 345). ADR-0049 enforce-or-remove is **#5055**. ⚠️ **The campaign's own BFS said REACHABLE on the first run** — a false positive in the derived-clone bridge, filed as **#5056**: zod's `.describe()` returns a clone that SHARES the original `_zod.def`, so `WidgetManifestSchema.name` / `.label` (a described `SnakeCaseIdentifierSchema` / `I18nLabelSchema`) are def-identical to the same leaves on live schemas, and a bridge firing on ANY one shared property links two unrelated shapes. 2 shared keys of 20. The error is one-directional — it can only manufacture a door, i.e. it can only make a batch tighten something dead. Corrected to whole-shape overlap in `ui/door-reachability.testkit.ts` and pinned in `widget.test.ts` |
+| `widget.zod.ts` | ~~authorable (p)~~ **no door** | **no authoring door (measured, #4001 批 16)** — the `(p)` resolved NEGATIVE for the whole file, the second such run after 批 13's five. Three independent measurements on 2026-08-04: (1) nothing under `packages/spec/src` imports this module except the `ui/index.ts` barrel, so no schema anywhere declares a carrier key for a widget shape — `field.widget` is a `z.string()` naming a registered *component* and has never referenced `WidgetManifest`; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack` (4 766 nodes) reaches none of the six shapes, while `PageSchema` / `ObjectListViewSchema` resolve in the same run, a fresh `z.object` and a deliberate look-alike both resolve unreachable, and a synthetic carrier flips all six to reachable; (3) zero `.parse()` / `.safeParse()` in `objectstack`, `objectui` or `cloud` outside this file's own tests — objectui re-exports the inferred TYPES only and under different names (`RuntimeWidgetManifest` / `FieldWidgetComponentProps`, #4115 / #3161), and a `cloud` code search returns 0 for every symbol against a working index (`"@objectstack/spec"` → 345). ADR-0049 enforce-or-remove is **#5055**. ⚠️ **The campaign's own BFS said REACHABLE on the first run** — a false positive in the derived-clone bridge, filed as **#5056**: zod's `.describe()` returns a clone that SHARES the original `_zod.def`, so `WidgetManifestSchema.name` / `.label` (a described `SnakeCaseIdentifierSchema` / `I18nLabelSchema`) are def-identical to the same leaves on live schemas, and a bridge firing on ANY one shared property links two unrelated shapes. 2 shared keys of 20. The error is one-directional — it can only manufacture a door, i.e. it can only make a batch tighten something dead. Corrected to whole-shape overlap in `ui/door-reachability.testkit.ts` and pinned in `widget.test.ts` ✅ **#5055 ANSWERED the ADR-0049 call, and the answer SPLIT 8/1** (maintainer ruling 2026-08-06; window moved v18 → v17 on 2026-08-07). Eight of the nine sites were REMOVED — `WidgetManifestSchema`, `WidgetLifecycleSchema`, `WidgetEventSchema`, `WidgetPropertySchema` and `WidgetSourceSchema` (3 union branches) — after all three measurements above were re-run on `origin/main` with their controls passing in the same run. Route 3 ("nothing parses it → neither"): no carrier key means no shape for a `retiredKey()` tombstone and no source for a D2 conversion, so the declared record is the D3 `SemanticMigration` `ui-widget-i18n-family-retired` plus `RETIRED_DEFS_BY_MAJOR`. `WidgetManifest.performance`'s own tombstone (#3896) was subsumed by the removal of the shape that carried it. ⚠️ **The NINTH site, `FieldWidgetPropsSchema`, was KEPT — do not finish this file.** Its evidence shape differs and the difference arrived one day before 批 16 measured: it is a REACT PROPS CONTRACT, never authorable (absent from `authorable-surface/` and `json-schema.manifest/` — `onChange` is a `z.function()`), so "zero parse" is its design rather than its defect; and objectui PR #3289 (merged 2026-08-03) renamed `@object-ui/fields`' validation slot onto this contract's `error` with no alias, made the form renderer produce it, and pinned it in `packages/fields/src/__tests__/spec-symbol-batch7.test.ts` as a deliberate tripwire — "the day the spec stops exporting `FieldWidgetProps`, this file stops compiling". Re-verified on objectui `origin/main` 2026-08-07. That is a live cross-repo compile-time consumer, and `tsc` is where a props contract is enforced. So this row's remaining site stays `no door` **and stays**: unreachability is not the retirement trigger for a shape that was never authorable. Pinned bidirectionally in `ui/widget-i18n-retirement.test.ts`. ⚠️ The #5056 fixture moved with the schema: `door-reachability.testkit.test.ts` rebuilds the same 2-of-19 shared-leaf shape locally, so the instrument's regression bound is still measured rather than remembered |
| `page.zod.ts` | authorable | partially strict (ADR-0089) |
| `chart.zod.ts` | **mixed — 8 authorable** (~~2 no gate~~ **gate wired at #5020**) | **5 strict as of #4001 批 15**, a sixth added at **#5022**; 2 still open, but no longer `no gate` — see the #5020 note at the end of this cell. `ChartConfigSchema` / `ChartAxis` / `ChartSeries` / `ChartAnnotation` / `ChartInteraction` are `root-graph`-reachable from the `dashboard` and `report` metadata roots (`DashboardWidget.chartConfig`, `ReportChartSchema`), so they are judged on the stored-metadata path and are now closed. **`ChartAggregateSchema` and `ChartGroupBySchema`'s object arm are NOT**, and this is the batch's real finding. They are not 批 13's no-door case — their carrier is LIVE: `aggregate` is a real authorable prop on the react tier's `` (ADR-0081), published in the generated react-blocks contract, and objectui's `ObjectChart` reads `schema.aggregate` to run the query. What is missing is the PARSE: neither schema is reachable from any metadata-type root or from `ObjectStackSchema` (both `UNREACHABLE` in the run where the five above come back `root-graph`), nothing in the three repos calls `.parse()` on them outside this file's unit tests, and the gate that DOES judge an authored `aggregate` — the react-page publish lint — re-derives the rules by hand (`CHART_FUNCTIONS`, the count/field requirement, the result-column naming) and never checks unknown keys. `react-blocks.ts` publishes the prop as a hand-written TYPE STRING; the Zod schema beside it is not what the contract is generated from. So `groupby` / `dateGranularty` are silently dropped today and would go on being silently dropped after a `strictObject` here — `.strict()` is a property of a parse. A fourth class, **`no gate`**: carrier live, parse absent. Distinct from `no door` (批 13), where the carrier itself does not exist. The contract-first fix is to make the publish gate PARSE the schema instead of re-deriving it — a `packages/lint` change, filed rather than smuggled into a spec strictness batch. Recorded in three places (schema-adjacent comment, test pin incl. a standing BFS assertion that goes red the day a carrier key appears, this row). ✅ **That fix landed at #5020, and this row's `no gate` verdict is spent — the two sites are now `authorable`** (the second half of the `Class` cell above; the strip row further down carries the same flip). The publish gate calls `ChartAggregateSchema.safeParse()` on a static `aggregate={{…}}` literal, and `CHART_FUNCTIONS` plus the hand-written count/field twin are DELETED, so the vocabulary and the refinement are single-source again. Read the flip precisely, because it is the class's first worked example and the distinction is the whole value of having added `no gate`: what changed is the PARSE, not the posture. Both schemas are still STRIP, so `groupby` / `dateGranularty` are still dropped silently — wiring the parse is the *precondition* for closing them, not the closing, and the closing is **#5583** (a sub-issue of the campaign, where the two `chart.test.ts` STRIP pins invert). #5020 also pinned today's tolerance out loud in `validate-react-page-props.test.ts` so a wired gate cannot be mistaken for a closed door — the #4583 shape, guarded from the other side. One severity note that belongs in this ledger because it is a *declared ≠ enforced* judgement, not a lint detail: an absent `groupBy` reports at **`warning`**, alone among the graded violations, because the schema and the published react-blocks type declare it required while objectui's renderer honours its absence (`schema.aggregate?.groupBy || schema.xAxisKey`) and this protocol's own `chartAggregateCategoryKey` documents the ungrouped single-row result. Gating it would enforce a declaration the platform does not itself keep; which of the two moves is #5583's product question. ⚠️ One correction shipped with the tightening: the `clickAction` migration text #3752 wrote into this file prescribed **`drillDown`, which at the time was not a key this protocol declared anywhere** — it was an untyped `(schema as any).drillDown` read inside objectui's `ObjectChart`. Promoting that sentence into a strict rejection would have handed an author the platform's authority for a key the same gate then rejects: finding 7, third occurrence, this time caught before shipping. The prose and the tombstone now name `onSegmentClick` / `ReportSchema.drilldown` / the widget's `options` bag, all of which exist. Filed separately — and **closed at #5022**, which is the entry worth reading twice, because the fix is not the one the file's own prose implied. The gap was real (a live renderer capability with no declaration), but the two carriers that prose pointed at both measured DEAD on the dashboard metadata path: `widget.chartConfig.drillDown` is read by nothing (`DashboardRenderer` never looks at `chartConfig`; `DatasetWidget` forwards exactly one key out of it, `showLegend`), and `widget.options.drillDown` is read only inside `DashboardRenderer`'s legacy `isObjectProvider` branch, which a spec-legal v17 widget cannot reach — `dataset` is required, so `datasetBound` is always true and that component schema is discarded unrendered. An ADR-0021 dataset-bound widget drills through the semantic layer and reads no drill config at all, which the platform's own docs had already said (`content/docs/ui/dashboards.mdx`: *there is no per-widget drill configuration in the dataset form*) while this ledger row pointed authors at the `options` bag. So `drillDown` was declared as `ChartDrillDownSchema` at the ONE surface measured to read it — the react tier's `` prop, published through `react-blocks.ts`'s interaction overlay rather than through `ChartConfigSchema`, precisely so the dashboard surface does not inherit an inert key. The shape is the honest six (`enabled`/`filter`/`title`/`target`/`columns`/`maxRows`); objectui's wider renderer-side `DrillDownConfig` (`mode`/`report`/`view`/`sort`, and a `navigate` target) was NOT copied — a chart reads none of them and two are read by no widget at all (objectui#3354) — and each absent key is a `guidance` entry saying so rather than a rename. Two second-order findings came out of the same measurement and are filed, not fixed here: **#5175** (`chartConfig` delivers 1 of its 12 keys on the dashboard path, and `liveness/dashboard.json` records evidence that overstates it) and **objectui#3354**. **`chart` 6 → 7 at the re-measurement** — no schema changed: `ChartAggregateSchema` is written `z\n .object({`, and the old counter's `z\.object\(` could not match across the line break |
-| `i18n.zod.ts` | **split** | **`i18n` SPLITS across two classes (measured, #4001 批 16)** and is the file this table's standing warning was about. The warning said "label shapes are wide-open records by design"; measurement says something more useful. `AriaPropsSchema` is a **real door and is closed** — carried as `aria:` on ~30 live shapes under six metadata-type roots (`ListViewSchema`, `PageSchema`, `PageComponentSchema`, `DashboardWidgetSchema`, `ChartConfigSchema`, `ActionSchema`, 20 SDUI component defs) and directly BFS-reachable. It was stripping in the wild: through the `view` root, `aria: { label: 'Accounts', describedBy: 'x' }` parsed CLEAN and returned `aria: {}`, so the accessible name existed in the source file and nowhere else. The other five (`I18nObject`, `PluralRule`, `NumberFormat`, `DateFormat`, `LocaleConfig`) are **no door** — no carrier, unreachable, zero parse in all three repos; ADR-0049 is #5055. Note `NumberFormat` / `DateFormat` DO have a carrier (`LocaleConfig.numberFormat` / `.dateFormat`) but the carrier is itself doorless, so the subtree is `no door`, not `no gate`. And the warning's own subject — the wide-open **record** level — was never one of the six sites: `I18nObject.params` is a `z.record` interpolation bag whose key space is whatever the message template names, so openness there is the contract and there was nothing to close. Pinned in `i18n.zod.ts`'s header, in `i18n.test.ts`, and here |
+| `i18n.zod.ts` | **split** | **`i18n` SPLITS across two classes (measured, #4001 批 16)** and is the file this table's standing warning was about. The warning said "label shapes are wide-open records by design"; measurement says something more useful. `AriaPropsSchema` is a **real door and is closed** — carried as `aria:` on ~30 live shapes under six metadata-type roots (`ListViewSchema`, `PageSchema`, `PageComponentSchema`, `DashboardWidgetSchema`, `ChartConfigSchema`, `ActionSchema`, 20 SDUI component defs) and directly BFS-reachable. It was stripping in the wild: through the `view` root, `aria: { label: 'Accounts', describedBy: 'x' }` parsed CLEAN and returned `aria: {}`, so the accessible name existed in the source file and nowhere else. The other five (`I18nObject`, `PluralRule`, `NumberFormat`, `DateFormat`, `LocaleConfig`) are **no door** — no carrier, unreachable, zero parse in all three repos; ADR-0049 is #5055. Note `NumberFormat` / `DateFormat` DO have a carrier (`LocaleConfig.numberFormat` / `.dateFormat`) but the carrier is itself doorless, so the subtree is `no door`, not `no gate`. And the warning's own subject — the wide-open **record** level — was never one of the six sites: `I18nObject.params` is a `z.record` interpolation bag whose key space is whatever the message template names, so openness there is the contract and there was nothing to close. Pinned in `i18n.zod.ts`'s header, in `i18n.test.ts`, and here ✅ **#5055 ANSWERED the ADR-0049 call: all five are REMOVED** (maintainer ruling 2026-08-06; window moved v18 → v17 on 2026-08-07), after the three measurements were re-run on `origin/main` with controls passing in the same run. `NumberFormat` / `DateFormat` went with their doorless carrier as one subtree rather than surviving as exported schemas nothing references (#3950), and `I18nObject` turned out to be superseded by its own file-neighbour: `I18nLabelSchema`'s documentation already says translation keys are generated at registration time and translations live in translation files, and the live surface is `system/translation.zod.ts`, which uses none of these shapes. Route 3 — no tombstone, no D2 conversion; the declared record is the D3 `SemanticMigration` `ui-widget-i18n-family-retired` plus `RETIRED_DEFS_BY_MAJOR`. **`AriaPropsSchema` and `I18nLabelSchema` are untouched**, and their survival is pinned as the other half of `ui/widget-i18n-retirement.test.ts` — a sweep that emptied this file would satisfy every absence assertion and take the directory's most widely carried live shape with it. The standing warning's own subject, the wide-open `I18nObject.params` record, left with its schema: openness there was the contract, and there is now no shape for it to be the contract of |
| `responsive.zod.ts` | authorable | **strict as of #4001 批 13** — all four sites (`ResponsiveConfig`, `ResponsiveStyles`, and the two per-breakpoint maps). This is the one file of batch 13's six whose `(p)` resolved POSITIVE, and it resolved on the graph rather than on the file's face: `page.components[].responsive` / `.responsiveStyles` put both shapes inside the `page` metadata-type root (`dashboard.widgets[].responsive` was the second carrier until #4876 retired it, same day). What the closure bought is the batch's whole argument in one parse — **`PageComponentSchema` has been `.strict()` since ADR-0089 D3a and that never reached these blocks**, so `{ type:'element:text', responsiveStyles: { lg: {…} }, responsive: { colums: {…}, hideOn: [] } }` parsed CLEAN and returned `responsiveStyles: {}, responsive: {}` — every styling and layout instruction the author wrote, gone, reported valid. A strict shell over strip-mode children is a closed surface's silhouette, not a closed surface. The curation is the file's real hazard rather than typos: it carries TWO breakpoint vocabularies sixteen lines apart on the same component (`responsiveStyles`' `large`/`medium`/`small`/`xsmall`, ADR-0065, against `responsive`'s Tailwind `xs`…`2xl`), so the aliases run BOTH ways between them and are anchored to the named sibling, not to edit distance — batch 12's method, and the only thing that can answer `lg` → `large`. Two entries had to be measured rather than reasoned: `{ columns: { large: 4, lg: 3 } }` used to keep HALF the map (the node laid out, at the wrong width, on breakpoints the author never named — worse than a total loss, which is at least visible); and `hideOn` → `hiddenOn` needed a hand-written alias because the distance fallback provably cannot reach it — it lowercases the input but not the candidates, so a capital in a declared key costs an extra edit against a budget of 2, and the all-lowercase `hiddenon` resolves while the correctly-cased `hideOn` does not. That asymmetry is general to camelCase keys, i.e. to most of the spec, and is filed as **#4990**. `StyleMapSchema` stays deliberately OPEN (its key space is every CSS property; objectui's `declarations()` emits whatever it is handed) — recorded in the schema JSDoc, in a test pin, and in this row |
| `dataset.zod.ts` | authorable | **strict as of #4001 批 14 — 0 strip sites remain.** `DatasetSchema` was strict from the ADR-0021 cutover while the two shapes carrying the actual semantic contract — `DatasetDimension`, `DatasetMeasure` (+ `.derived`) — were not. Curated against the sibling this module's own header names, `data/analytics.zod.ts`'s Cube layer: a Cube metric's `type` IS its aggregation, so `{ name: 'revenue', type: 'sum', field: 'amount' }` parsed clean and computed a `count`; `sql` gets guidance rather than an alias, because aiming `SUM(amount)` at `field` is finding 7's trap |
| `report.zod.ts` | authorable | **strict as of #4001 批 14 — 0 strip sites remain.** `ReportSchema` was already strict; `ReportSortSchema` and `JoinedReportBlockSchema` were not. The order key is the THIRD spelling of "sort" an author meets (`SortNodeSchema`'s `{field, order}`, the widget's flat `sortBy`/`sortOrder`, this `{by, direction}`), and the mappings run in opposite directions, so none is inferrable. ⚠️ `ReportSchema`'s OWN alias table carries a live false prescription (`filter` → `filters`, a key it also rejects; the real key is `runtimeFilter`) — out of 批 14's scope, filed as #5013 and pinned as a known defect in `strictness-batch14.test.ts` so the list cannot outlive it |
@@ -865,14 +865,15 @@ next person to open that file will look.
|---|---|---|
| `component.zod.ts` | **authorable** | **was `no gate` until #5068** (one verdict per cell on purpose — it is the machine-readable input to the generated subtotal, so the history lives here in the evidence). ⛔ **was not strictness work** — measured at 批 17 as having no parse at all: BFS-unreachable from every metadata root (all 52 targets, controls green in the same run), zero production `.parse()` sites in the three repos, and an unknown key inside `components[].properties` demonstrably survives the live `definePage()` door. The carrier (`PageComponentSchema.properties`) is live but is `z.record(z.string(), z.unknown())` — ADR-0089 D3a strictness does not recurse into it. Closing these 29 sites would have gated nothing (#4583), so the batch recorded the verdict and filed the wiring as **#5068**. ✅ **#5068 wired it, and this row's `no gate` verdict is spent — the sites are `authorable`.** `packages/lint/src/validate-component-props.ts` dispatches `ComponentPropsMap` by the component's `type` and judges `properties`: undeclared keys through `lintUnknownKeysAgainstSchema` (the same walker `lintUnknownAuthoringKeys` runs on every metadata collection — one implementation of the posture rules, not a second), values through `safeParse`. It runs on all three authoring commands from the shared registry. **Read the flip precisely, exactly as at #5020: what changed is the PARSE, not the posture.** All 31 entries still STRIP; the gate reports an undeclared key because the walker reads a strip-mode object, and converting these sites to `strictObject` moves that same report into the gate's `safeParse` half (`unrecognized_keys`, routed to the same rule id) — which is what makes the ratchet meaningful rather than cosmetic. Three things the flip did NOT do, each of which someone will otherwise assume: (1) **the carrier is unchanged, by decision** — the maintainer's 2026-08-05 ruling took direction A (gate at the authoring door) and DECLINED direction B (a discriminated `properties`) as breaking against an open `type` union, so `PageComponentSchema.properties` is still `z.record(z.string(), z.unknown())` and `component.test.ts`'s three standing assertions stay GREEN — measured against the landed gate, with their prose updated to say which dispatch landed; (2) **unregistered types are SKIPPED**, a required semantic rather than leniency — the example corpus alone authors 87 nodes across ten types this map does not carry (`flex`, `grid`, `object-metric`, `object-chart`, `record:line_items`, …), and judging them against an absent schema would report every one as broken; (3) **the storage path is still open** — a `saveMetaItem` / REST `/meta` write stores an unvalidated props bag (#4463's fourth wall), recorded rather than fixed. ⚠️ The gate is **WARNING-level** in this first step, and the reason is a measurement: on the example corpus + the three published platform pages it reports **52 findings** (44 value verdicts, 8 undeclared keys), of which 34 are inline `{ en, 'zh-CN' }` label maps against an `I18nLabelSchema` that is a plain `z.string()` (**#5728**, undecided) and 8 more are the same shape on `element:text.content`. Gating those would fail the platform's own pages to enforce declarations the platform does not keep — the `groupBy` judgement #5020 had to make, at corpus scale. That inventory is the acceptance baseline for the error upgrade, which is its own step. See the triage row for the full 批 17 measurement |
| `view.zod.ts` | mixed · 1 authorable, 2 wire | **15 of 20 closed at #4001 批 18**, a sixteenth (`UserFiltersSchema`) at **#5073** once its protocol blocker was adjudicated, a seventeenth — `ViewFilterRuleSchema`, closed by an EARLIER wave — reopened at **#5114**, and then the file's last authoring debt cleared at **#5074**, which closed `ViewItemSchema` (×2 arms), `ListView.sort` AND `ViewFilterRuleSchema` in one structural change. **The strip count went 5 → 3, and the arithmetic is the finding, not the number: FOUR sites closed and TWO were ADDED** — the two arms of the new `ViewItemWireSchema`, which are strip BY DESIGN. That is why this row's Class cell is now a split (`1 authorable, 2 wire`) rather than a smaller `authorable` count: the wire contract that used to live on "the member nobody closed" now has a name, and this map measures posture, not intent. Closed: `ViewDataSchema`'s four provider arms, `UserFilterField.options`, `GanttQuickFilter.options`, `GanttConfig.tooltipFields`, `ListView.conditionalFormatting` / `.emptyState`, `FormFieldBase.keyField`, `FormView.subforms`, and `submitBehavior`'s four arms. Reachability was measured, not assumed: a BFS from all 24 metadata-type roots plus `ObjectStackSchema` resolves every one `root-graph`, with `ViewSchema`/`FormViewSchema`/`ViewItemSchema`/`PageSchema` as positive controls and 批 13's no-door shapes UNREACHABLE **in the same run** — and the instrument had to be fixed first: `lazySchema` returns a Proxy, but a carrier writes `X.optional()`, which RESOLVES it, so the closure holds the real instance and comparing the Proxy alone false-negatived `ViewDataSchema` (caught by cross-checking its two literal carrier keys, not by trusting the reading). ⚠️ **Re-checked against #5056**: every 批 18 target is `root-graph` by **identity**, so **none** of the fifteen rests on the `derived-clone` bridge that 批 16 found can mark a dead shape reachable. The one `derived-clone` verdict in the run is `ListViewSchema` — a positive CONTROL, not a target, and independently identity-reachable via `ObjectListViewSchema`. Every closed shape also has a literal carrier key in this file and a named parse door (`defineView` / `defineViewItem` / the `view` metadata-type schema / objectui's `GanttConfigSchema.safeParse` at `plugin-gantt/src/ObjectGantt.tsx:408`) — the strong-evidence class #5056 leaves standing. ⚠️ **`ListView.sort` was closed, REVERTED, and closed again at #5074 — the round trip is the file's most useful finding.** It carried `direction → order`, the #4721 alias for the identical tuple (`{field, direction:'desc'}` parsed to `{field, order:'asc'}` — a silently REVERSED sort). The full suite then failed one case: `view-metadata-schema.test.ts` pins `sort: [{ id, field, order }]` as the exact body a console column-sort PUT persists, and objectui stamps that `id` per row (`components/src/custom/sort-builder.tsx:68`/`:94`, `crypto.randomUUID()`). **The mechanism governs every nested block in this file and is the opposite of what the union's own comment implies: `.strip()` does NOT recurse.** `ViewMetadataSchema` rescues Studio's round-trip keys by making its flattened members `.strip()`, but that re-opens the TOP level only — a nested block closed inside `ListViewSchema` is still reached through that member, so a console-stamped key inside it becomes a 422 regardless. `id` was deliberately NOT declared to silence it: it is a React list key, and declaring it would put a UI artifact on the authorable surface and tell an AI author to emit one. **#5074 supplied the missing half and the shape is now CLOSED**: the write door removes the declared decoration vocabulary (`VIEW_CONSOLE_ROW_DECORATIONS` / `stripViewConsoleDecorations`, the mirror of `stripReadDecorations`) BEFORE the union runs, so the opening is recursive-effective where a member-level `.strip()` can never be, and the authoring surface never grew the key. The `direction → order` alias came back with it. Curation on what DID close is anchored to named siblings: an option `count` gets a wrong-layer pointer to `showCount` because objectui COMPUTES it per render; and a bare `name` on the `object` data source is deliberately NOT aliased — it is a real key on the view ITEM, so a rename would be finding 7 again. `submitBehavior` became a `discriminatedUnion` on the `kind` literal it already required: as a plain union of four strict members the rejection is an `invalid_union` whose prescription #5014 measured the renderers flattening away. ⚠️ **`GanttConfigSchema` / `TreeConfigSchema` are `strictObject(…).passthrough()`** — open at the parent by design, and this ledger's own counter used to read them as `strict`, because `postureOf` returned early on the `strictObject` idiom instead of walking the chain. **Fixed at #5072**: the idiom now seeds the initial posture and the chain always runs, so the two read `passthrough` and the directory's strict count drops by 2. The strip count was never affected — neither posture is strip — so this row's numbers do not move. **`UserFiltersSchema` is CLOSED as of #5073, and it is the one site in this file whose blocker was never a strictness question.** Closing it would have 422'd `allowAddTab` — a key objectui's renderer reads (`plugin-list/src/UserFilters.tsx:182`/`:742`) and the spec never declared; because `saveMetaItem` validates but persists the ORIGINAL body, the stripped key still reached the renderer, so the capability WORKED and closing would have removed it rather than making a silent failure loud. 批 18 stopped and filed rather than guessing, and the maintainer adjudicated **promote, then close, in one PR** (2026-08-04): `allowAddTab` is now DECLARED here, so the capability is discoverable from the contract (JSON Schema / Studio SchemaForm / an AI author) instead of living in one React file, and the shape closes behind it with no intermediate state. The rejected option was `SANCTIONED_LOCAL` in objectui, which would have made spec and objectui two sources of truth for one contract — the fork #2231's derive-by-reference exists to prevent (PD#12) — and would have taught authors to delete a working key with a rejection that was itself "correct" (finding 7). Two details the close is worth remembering for. **(a)** The promotion is scoped to what the renderer really does: the add-tab button objectui renders carries no click handler, so `allowAddTab` declares that the affordance RENDERS and deliberately says nothing about creating presets — a `.describe()` promising more would be PD#10's advertise-what-you-don't-deliver, and the renderer gap is filed as **#5236**. **(b)** The 批 6e reliance question resolved exactly as predicted — `ObjectUserFiltersSchema` is `.omit()`ed off this base and `.omit()` inherits posture, so the pin flipped from "drops" to "rejects", which is wanted (the CLI lint `validate-list-view-mode.ts` was already reporting these) — but inheriting the posture also inherits the base's ERROR MAP, whose `knownKeys` were read from the base shape and therefore still listed the omitted keys. Measured on the flip: `tab` was answered *"Did you mean `tab` → `tabs`?"*, steering the author at the one key that surface refuses — finding 7 produced by the fix for finding 7. So the object variant now carries its own map built over the OMITTED shape (the shape still derived by `.omit()`, so #2231 holds), with `guidance` pointing all three page-only keys at `listViews`. **⚠️ #5074 — the authoring/wire SPLIT, and the row's headline.** `ViewItemSchema` wore two contracts: the authoring gate (`defineViewItem`, objectui's view-create form, which validates `createBuildBody`'s output against the real spec schema) and member 1 of `ViewMetadataSchema`, the union `saveMetaItem` validates every persisted `view` body against. The wire role was measured, not inferred — objectui's pin control PUTs `{...storedItem, isPinned}` (`ObjectView.tsx:882` → `data-objectstack/src/index.ts:2801`); a stored ViewItem record carries `viewKind` AND `config`, so the merged body lands on member 1 (the flattened members are excluded by their `config: z.undefined()` guard) and closing the one schema would have 422'd pinning a saved view. The maintainer ruled **split** (2026-08-04), and the two-axis reasoning is worth keeping: `defineViewItem({name, object, viewKind, confg: {…}})` — one letter — used to strip the typo and hand back a ViewItem with **no view configuration at all**, parsed clean, which is #1535's `workflows: [...]` replayed on the file's densest authoring surface. `ViewItemSchema` is now `strictObject` on both arms; `ViewItemWireSchema` is the `.strip()` wire variant, built from the SAME `viewItemArmShape()` (derive-by-reference, #2231 — a `discriminatedUnion` cannot be `.extend()`ed, so sharing the shape factory is what keeps one contract from becoming two transcriptions), and `isPinned`/`sortOrder` are DECLARED on it — an explicit home, instead of surviving because nobody closed the member. **The scope addendum's hard requirement was recursive-effective openness, and that is the part a posture flip could not deliver.** `.strip()` re-opens a member's TOP level only, so the two console-decorated NESTED blocks (`ListView.sort[].id`, `ViewFilterRule.id`) were still reached at full strictness through it. The route taken is the addendum's second sanctioned one: a declared decoration vocabulary stripped before validation, at the wire door, reaching every carrier at every depth — including ones added later, which a hand-maintained parallel wire tree would not. It is deliberately NOT a second schema tree (PD#12's fork) and deliberately NOT a declared `id` (批 18 Q1's two-axis rejection: a React list key on the authoring surface teaches AI authors to emit UUIDs). Two landmines were named in the ruling and both are pinned in `view-authoring-wire-split.test.ts` §5: `z.toJSONSchema()` must still emit a four-member `anyOf` (the `/api/v1/meta/types/view` endpoint feeds Studio's SchemaForm from it — it does; a pipe converts to its output side, asserted in BOTH io directions), and the `lazySchema` Proxy's ADR-0089 D3a crash (`Cannot set properties of undefined (setting 'ref')`) must not recur under a pipe-rooted lazy schema — it does not, and each new schema is converted directly rather than only through its parent. **One real hazard the change surfaced, fixed in the same PR:** a `z.preprocess` at a registered root put TWO gate walkers into the exact blind spot #4488 had already found and fixed in `check-liveness.mts` — `metadata-authoring-lint.ts` and `metadata-form-zod-reconciliation.test.ts` both unwrapped a pipe via `def.in`, which for a preprocess is the TRANSFORM, so each reported `view` as *not key-bearing* and silently stopped covering it. Caught by their own coverage assertions (`lintables.length >= 1`, `root schema is not key-bearing`), which is precisely what those assertions exist for; both now prefer whichever side is not the transform. **A gate going quiet is worse than a gate failing** — and the pattern will recur on the next preprocess-rooted registration, so it is recorded here rather than only in the diff. **Still open, one site, measured:** `FormFieldBaseSchema` — a module-private BASE whose sole consumer already applies `.strict()` plus the ADR-0089 `strictVisibilityError` map; the door is closed, the ledger counts the base. The two remaining strip sites beyond it are `ViewItemWireSchema`'s arms, which are `wire` by design and are not debt. `ViewFilterRuleSchema` — **the same wire contamination, one block over, and it was already LIVE on `main`** (#5114): closed by an earlier wave, while objectui's filter builder stamps `id: crypto.randomUUID()` on every row it writes (`components/src/custom/filter-builder.tsx:228`, re-stamped on read-back at `plugin-view/src/config/view-config-utils.ts:146`/`:160`), and `saveMetaItem` persists the AUTHORED body verbatim — so saving a filter from the console 422'd, on all three paths including the flattened overlay that is the body actually PUT. Reopened as a p1 hotfix; `id` deliberately NOT declared, for the reason given for `sort` above. **That reopen was explicitly PROVISIONAL — "pending #5074" — and #5074 retired it rather than leaving it standing: the shape is CLOSED again, by the same decoration strip that closed `sort`, so the authoring gate rejects `id` by name while the console's own three paths still parse.** Its pin file now asserts the split per door, and the direction is the INVERTED one worth flagging to the next reader: probes 1/3 and 2/3 were GREEN before #5074 and are RED after (that IS the close), while 3/3 — the body the console actually PUTs — is green on BOTH sides and must stay so; a file that only asserted "the console body parses" would have passed unchanged through a change that quietly declared `id` as authorable. Two details worth keeping: the overlay path's rejection surfaces as `invalid_union` / *"Invalid input"* — the #5014 flattening, so the key that caused it is not in the message the author sees, which is why this sat on `main` unnoticed; and the reopening was verified in BOTH directions (re-close it and 7 assertions in `view-filter-rule-wire-id.test.ts` go red, while that file's two mechanism CONTROLS — top-level aux key rides, nested `emptyState` still rejects — stay green either way, which is what makes them controls). #5074's scope addendum named this site; the gate it was waiting on — a wire opening that REACHES a nested block — landed with it. Each verdict is recorded in three places (schema JSDoc + `view-strictness-batch18.test.ts` / `view-filter-rule-wire-id.test.ts` + this row) |
-| `widget.zod.ts` | **no door** | ⛔ **not strictness work** — the whole file measured unreachable from every authoring root (#4001 批 16), with no carrier key and zero parse in all three repos. ADR-0049 triage is **#5055**. See the triage row above, including why the campaign's own BFS said otherwise first (**#5056**) |
+| `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**) |
| `chart.zod.ts` | **authorable** | **was `no gate` until #5020** (the cell carries one verdict on purpose — it is the machine-readable input to the generated subtotal, so the history lives here in the evidence). `ChartAggregateSchema` + `ChartGroupBySchema`'s object arm. Config / axis / series / annotation / interaction closed at 批 15; these two were held OUT of the ratchet as `no gate` — carrier live, no parse — because closing them would have gated nothing (#4583). **#5020 wired the parse, so the hold is over and these two are ordinary strictness work again.** `packages/lint/src/validate-react-page-props.ts` now calls `ChartAggregateSchema.safeParse()` on a static `aggregate={{…}}` literal, and the hand-derived `CHART_FUNCTIONS` list + count/field refinement twin are deleted. That is the path **#5022 demonstrated on one key** and this row was blocked on: `ChartDrillDownSchema` arrived with its gate already wired, parsing instead of re-deriving, while `aggregate` beside it did the opposite. ⚠️ **The flip is `no gate` → `authorable`, NOT → closed.** Both sites still STRIP: the parse the gate runs drops `groupby` / `dateGranularty` rather than reporting them, so the ADR-0078 failure mode survives until the posture changes. Converting the two object arms to `strictObject` is **#5583** (Blocked-by resolved; a sub-issue of #4001), which is also where the two `chart.test.ts` "still STRIPS — deliberate" pins invert and where the one product question lands — `groupBy` is declared REQUIRED here and in the published react-blocks type while the renderer honours its absence, so #5020's gate reports that single case at `warning` instead of gating a shape the platform delivers |
-| `i18n.zod.ts` | **split** · 5 no door | **批 16 closed the one real door**: `AriaPropsSchema` (`strictObject`, carried as `aria:` on ~30 shapes under six metadata-type roots — it was returning `aria: {}` for a legacy-spelled block). The 5 left are `I18nObject` / `PluralRule` / `NumberFormat` / `DateFormat` / `LocaleConfig`, all **no door** (#5055) — ⛔ **do not close them**. This row shrinks without disappearing, the third such in the ledger after `flow` (批 11) and `etl` (批 12): the reverse pin fires on ZERO, so a row parked at a deliberate floor looks exactly like a row nobody finished, and only the `Class` column separates them |
-| `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 eight `ComponentPropsMap` carrier keys → carrier present → not carrier-absent at all (it was ten at the time of the #5249 sweep, and this cell said eleven; #5775 moved `page:section`/`page:footer`/`page:sidebar` off it onto the shared `PageContainerProps`, since all three renderers render a child list and "zero props" was the wrong declaration for a container. The count moves; the verdict does not). 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 |
+| `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 |
`sharing.zod.ts` and `notification.zod.ts` left this table at **#5015** by a route no other row has taken: not by being CLOSED, but by having their remaining sites REMOVED. Both were `no door` — ADR-0049 territory, explicitly out of this ratchet's scope — and the enforce-or-remove call came back REMOVE, so `EmbedConfigSchema` and `NotificationActionSchema` are gone rather than strict. Read the reverse pin carefully here, because it fires on zero either way and cannot tell the two routes apart: the `sharing.zod.ts` row said in as many words that it *"shrinks without disappearing — the first `no door` floor"*, and that was true right up until the floor was retired out from under it. A deliberate floor and a retired one look identical from the count; only the `Class` column and this paragraph separate them. `sharing.zod.ts` keeps its TRIAGE row above, because `SharingConfigSchema` is still there and still strict — the file is closed, not empty. `notification.zod.ts` keeps no row anywhere: it has zero object sites left.
+`i18n.zod.ts` left this table at **#5055** by the same route, and it is the cleanest instance of it: its five `no door` sites (`I18nObject`, `PluralRule`, `NumberFormat`, `DateFormat`, `LocaleConfig`) were REMOVED under ADR-0049, leaving only `AriaPropsSchema` — which 批 16 had already closed. The file reaches 0 strip by subtraction on one side and closure on the other, so the row goes; it keeps its TRIAGE row above, because both survivors are live and one of them is the directory's most widely carried shape. `widget.zod.ts` is the counter-example from the SAME PR, and the two must be read together: eight of its nine sites were removed by the same ruling and its row **stays**, because the ninth was deliberately kept. Same batch, same ADR, same three measurements — opposite dispositions, decided per site on the CURRENT evidence rather than on the issue body's, which had been overtaken by objectui PR #3289 the day before it was written.
+
`responsive.zod.ts` left this table at **批 13** (#4001) on reverse-pin evidence
— it reached 0 strip, the gate went red on the row still being there, and the row
was deleted. `action.zod.ts`, `report.zod.ts`, `dataset.zod.ts` and
@@ -933,6 +934,17 @@ close; the config block the map assumed was open alongside it turned out to be t
directory's most widely carried live shape (~30 `aria:` carriers under six
metadata-type roots), and it was returning `aria: {}` for a legacy-spelled block.
+That 14-site subtotal is now 1. **#5055 answered 批 16's enforce-or-remove call
+and removed 13 of the 14**, which is the largest single subtraction this ledger
+has recorded and the reason the `no door` bucket reads 1 rather than 14. The one
+that stayed is `FieldWidgetPropsSchema`, and it stayed for a reason the class was
+never designed to express: `no door` measures whether a shape is reachable from an
+authoring root, and a REACT PROPS CONTRACT is not supposed to be. Its enforcement
+lives in `tsc` in the repo that implements it, and objectui acquired exactly that
+consumer (PR #3289) the day before 批 16 took its measurement. So the bucket now
+holds one site that must not be retired, next to twelve that were — which is why
+its cells say so twice.
+
**批 18 is the ninth instance.** It computed 84 against a tree where 批 16's
rows still existed (`widget` still `authorable` at 9, `i18n` still 6) — right
against its own branch, wrong against the merge, which is **75**: a number
diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md
index 0644b8d5fe..1461592b4b 100644
--- a/docs/protocol-upgrade-guide.md
+++ b/docs/protocol-upgrade-guide.md
@@ -214,6 +214,8 @@ Finally it retires the two inert `IndexSchema` keys, `indexes[].type` and `index
It also retires the field-mapping `transform` key and the whole five-member `FieldMappingTransform` union behind it (#5552): `constant` / `cast` / `lookup` / `javascript` / `map`, declared on `shared/FieldMapping` and inherited by `integration/ConnectorFieldMapping` and `data/ExternalFieldMapping`. Nothing ever executed one. `fieldMappings` is spelled only inside `packages/spec` itself — the connector packages, the automation engine, REST and objectui never read it, and no code anywhere switches on `transform.type` — so all five members were declared-but-unenforced together, not just the one that got the bug filed. That one is the sharpest evidence though: `javascript`'s `.describe()` recommended the dialect `js`, which `ExpressionDialect` retired at #3278 (ADR-0058 addendum), so the envelope the documentation taught was rejected by the enum; the only spelling that parsed was the bare string, which `ExpressionInputSchema` wraps as `cel`; and the CEL that resulted could not evaluate the `value.toUpperCase()` the same line offered as its example. Three surfaces disagreeing about a capability with no implementation under any of them. Fixing the sentence alone was rejected (maintainer, 2026-08-06) as gilding a member that cannot run. The key is tombstoned rather than deleted because the schema and both extenders are plain `z.object`s and `ConnectorSchema.parse` is a live receiver, so a bare deletion would strip silently. What is NOT affected, despite the shared word: the import mapping's `mapping.fieldMapping[].transform`, a flat string enum applied row by row by the REST import path and live in the liveness ledger — including its own `javascript` value, which that path rejects with a 400 rather than pretending to run.
+The last of the #4001 enforce-or-remove batch lands on two more `ui/` files (#5055, ADR-0049 — read it next to #4988 above, it is the same shape one batch later). `ui/widget.zod.ts` published a whole widget-REGISTRATION vocabulary — `WidgetManifest` with `WidgetLifecycle` hooks, `WidgetEvent`s, `WidgetProperty` knobs and a `WidgetSource` npm/remote/inline implementation union — and `ui/i18n.zod.ts` published `I18nObject`, `PluralRule`, `NumberFormat`, `DateFormat` and `LocaleConfig`. Ten defs, twenty exported names, and not one carrier key between them: nothing under `packages/spec/src` imported `widget.zod` at all, the only live imports of `i18n.zod` name `I18nLabelSchema` / `AriaPropsSchema`, the BFS from all 24 metadata-type roots plus `defineStack` reached none of them, and no repo ever parsed one. So again nothing is applied for you and nothing needs to be — the change is TS2305 on an import, and a `field.widget: "my_picker"` string is untouched, because that key names a component the RENDERER registered and never referenced `WidgetManifest`. ⚠️ Read this scope precisely too, because BOTH files split. `ui/i18n.zod.ts` keeps `I18nLabelSchema` (the label primitive the whole `ui/` tree imports) and `AriaPropsSchema` — a REAL door, carried as `aria:` on ~30 live shapes and closed by 批 16, untouched here. And `ui/widget.zod.ts` keeps `FieldWidgetPropsSchema`, the one site of the nine whose evidence differs: it is a React props contract rather than authorable metadata (it never appeared in the authorable surface or the schema manifest — `onChange` is a `z.function()`), so having no parse is its design; and objectui PR #3289 (2026-08-03) made it a live compile-time consumer, renaming `@object-ui/fields`' validation slot onto this contract's `error` with no alias and pinning it as a deliberate tripwire. Retiring it would have broken the one consumer the batch had, one day after it appeared. The measurement that decides a site is the CURRENT one, not the one in the issue body.
+
Last, it reconciles the SDUI component-props surface with the renderers that serve it (#5775). #5068 wired the first parse `ComponentPropsMap` ever had, and the corpus it landed on diverged in BOTH directions: keys objectui honours that the schema never declared, and keys the schema declared — one of them REQUIRED — that no renderer reads. The maintainer ruled direction A (2026-08-06), the #5611 rule again: the delivered and authorized shape is the contract. So the honoured keys are declared (`element:record_picker` `labelField`/`valueField`/`label`/`emptyText`, `record:path` `stages[].terminal`, `page:tabs` `items[].value`/`items[].count`, `page:card` `children`, and `children` on `page:section`/`page:footer`/`page:sidebar`, which were declared `EmptyProps` while their renderers rendered a child list), and four keys retire. Two are synonym renames: `element:record_picker.displayField` → `labelField` (the required key no renderer read, while `labelField ?? 'name'` is what actually renders the row — so an author who followed the schema got a picker listing `name` with no diagnostic, the ADR-0078 shape), and `page:card.body` → `children` (one composition key across every container; the card renderer already reads both, and the showcase authors `children`). Two are enforce-or-remove deletions: `element:record_picker.searchFields` and `.multiple` — the control is a shadcn single-select with no search input, binding ONE record id into a page variable, so `searchFields` narrowed nothing and `multiple: true` selected nothing extra while reporting success. Either returns the day the capability is implemented (#5021 / #4988). Not in scope, and deliberately: `page:card.visible` is a component-level visibility predicate written into `properties` and hoisted by the renderer — a page to rewrite onto the ADR-0089 `visibleWhen`, not a key to declare.
### Mechanical (applied for you)
@@ -340,6 +342,9 @@ Last, it reconciles the SDUI component-props surface with the renderers that ser
- **`declarative-apis-endpoints-live`** — `stack.apis[] (every declared ApiEndpoint — REVIEW REQUIRED BEFORE UPGRADING)` → the same declarations, re-read as LIVE HTTP routes: `path` moved under `/api/v1/apps//`, and every entry that declares `authRequired: false` re-confirmed as an intentionally anonymous endpoint carrying `rateLimit: { enabled: true, … }`
- Why not automatic: This is the one protocol-17 entry that turns metadata ON rather than off, so read it as a SECURITY review item and not as a rename. Before 17 the declarative endpoint surface executed NOTHING: no route was mounted for a declared `path`, no matcher existed, and every key — `authRequired` included — parsed green and gated nothing (#4936, which refused a non-empty `apis:` outright for exactly that reason). Protocol 17 ships the executor (#5040) and narrows that refusal to a per-endpoint publish gate: an endpoint that PASSES the gate is mounted and serves real traffic as soon as the stack is published. So an `apis:` block written against an older major — or one restored from a pre-#4936 source, or authored from a doc that predates the refusal — changes meaning without changing a byte: what used to be inert documentation becomes an execution entry point into the data and automation pipelines. Nothing about that transition can be applied mechanically, because the judgment it needs is "did the author of this endpoint mean for the internet to reach it?" — and the one key where a wrong answer is unrecoverable is `authRequired`. Its schema default is `true`, so an omission is SAFE and needs no review; an EXPLICIT `authRequired: false` is the only thing that opens anonymous access, and under ADR-0121 D6 it now also requires an armed `rateLimit` (`enabled: true` — the key defaults to `false`, so a budget written without it meters nothing) or the stack refuses to publish. Grep every `apis:` entry for `authRequired: false` before you upgrade, delete the ones that were never meant to be public, and arm a budget on the ones that were. The path move is the mechanical-looking half and is still yours: ADR-0121 D1/D2 confine a declared path to your own namespace carve-out (`/api/v1/apps//…`), the namespace comes from an explicit `manifest.namespace` with no derivation fallback, and the subpath is the only part you name — rewriting it for you would silently change a URL third parties call.
- Done when: You have READ every entry of every `apis:` block, not just the ones that fail to publish. Concretely: (1) each declared `path` is `/api/v1/apps//` and the stack declares that `manifest.namespace` explicitly; (2) every entry declaring `authRequired: false` is one you INTEND to be reachable without a session, and each carries `rateLimit: { enabled: true, windowMs, maxRequests }` — entries that were not intended to be anonymous have the key removed so the safe default (`true`) applies; (3) `objectstack validate` passes, which also proves no endpoint declares a shape 17.x cannot execute (`type: script` / `proxy`, mapping `transform`, an `object_operation` missing `objectParams`, `cacheTtl` on a non-GET method, `inputMapping` on find/get/delete, or two endpoints claiming one METHOD + path); and (4) after publishing, each endpoint answers as you expect — an anonymous request to a session-only endpoint returns 401 rather than data.
+- **`ui-widget-i18n-family-retired`** — `ui.widgetManifest / ui.widgetLifecycle / ui.widgetEvent / ui.widgetProperty / ui.widgetSource / ui.i18nObject / ui.pluralRule / ui.numberFormat / ui.dateFormat / ui.localeConfig (the widget-registration vocabulary of ui/widget.zod.ts, and the five doorless shapes of ui/i18n.zod.ts — 10 defs, 26 exported names)` → (removed — there is no replacement key, because there was never a key. A custom field widget is still named the same way it always was: `field.widget` is a plain string naming a component the RENDERER has registered, and objectui's registry has always carried its own runtime manifest for that (`RuntimeWidgetManifest` / `RuntimeWidgetSource` in `@object-ui/types`, objectui#3161 / #4115), which models different keys and never derived from these. For localisation: write the default-language string on `label` / `description` — the framework generates the translation key at registration time from the naming convention — and put translations in translation files, which is the LIVE `system/translation.zod.ts` surface. Widget registration and locale formatting as authorable protocol metadata return via the ENFORCE route of ADR-0049 through a new ADR — the registry / loader / formatter first, the vocabulary second)
+ - Why not automatic: `ui/widget.zod.ts` published a complete widget-registration vocabulary — a manifest with lifecycle hooks, custom events, configurable properties and an npm/remote/inline implementation-source union — and `ui/i18n.zod.ts` published a structured-label, plural-rule and locale-formatting vocabulary. NOTHING in the protocol carried either. Three independent measurements, re-run on `origin/main` immediately before the removal with their controls passing in the SAME run: (1) no module under `packages/spec/src` imported `widget.zod` at all, and the only imports of `i18n.zod` anywhere name `I18nLabelSchema` / `AriaPropsSchema` (both KEPT), so no schema declared a carrier key — `field.widget` is a `z.string()` naming a registered component and has never referenced `WidgetManifest`; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema` reached none of them, while `PageSchema` / `ObjectListViewSchema` resolved `direct` in the same run and a synthetic carrier flipped every one of them; (3) zero `.parse()` / `.safeParse()` in objectstack, objectui or cloud outside these files' own unit tests. `NumberFormat` / `DateFormat` DID have a carrier key (`LocaleConfig.numberFormat` / `.dateFormat`) but the carrier was itself doorless, so the subtree was `no door` rather than `no gate` and goes whole — leaving the two leaves behind would strand exported schemas with no consumer (#3950). `I18nObjectSchema` was additionally superseded by its own file-neighbour: `I18nLabelSchema`'s documentation already says translation keys are generated at registration time and translations live in translation files, and the live translation surface is `system/translation.zod.ts`, which uses none of these shapes. The 2026-08-06 ruling weighed giving them a carrier (option B) and rejected it: that is a feature with a registry and a renderer behind it, not ledger clean-up. Tightening them to `strictObject` was rejected earlier and explicitly (#4001 批 16) — strictness is a property of a PARSE and there is no parse, so it would spend a breaking change to leave "a precisely validated dead slot, the more convincing lie" (#4583). With no carrier key there is nothing to tombstone and no `sys_metadata` row or source file for a D2 conversion to rewrite: this entry is the D3 record, route 3, the same shape as #4988 (the ui/ interaction config family), #4834 (kernel plugin-runtime family) and #4938 (`HttpServerConfig`). ⚠️ `WidgetManifest.performance`'s own `retiredKey()` tombstone (#3896 close-out) is SUBSUMED here, the #4657/#4834 way: it goes with the shape that carried it, which is strictly stronger than the tombstone, because there is no longer a manifest to author the key INTO. ⚠️ One of the nine widget sites is deliberately NOT retired. `FieldWidgetPropsSchema` survives: it is a REACT PROPS CONTRACT rather than authorable metadata (it never appeared in `authorable-surface/` or `json-schema.manifest/` — its `onChange` is a `z.function()`), so "zero parse" is its design and not its defect, and it acquired a live cross-repo compile-time consumer one day before 批 16 measured: objectui PR #3289 (2026-08-03) renamed `@object-ui/fields`' validation slot onto the spec's `error` with no alias, the form renderer began producing it, and `packages/fields/src/__tests__/spec-symbol-batch7.test.ts` pins the shape against `import type { FieldWidgetProps } from '@objectstack/spec/ui'` as an intentional tripwire. Re-verified on objectui `origin/main` 2026-08-07. ADR-0049, #5055.
+ - Done when: No code imports `WidgetManifest(Schema|Parsed)`, `WidgetLifecycle(Schema)`, `WidgetEvent(Schema|Parsed)`, `WidgetProperty(Schema|Parsed)`, `WidgetSource(Schema|Parsed)`, `I18nObject(Schema)`, `PluralRule(Schema)`, `NumberFormat(Schema|Parsed)`, `DateFormat(Schema)` or `LocaleConfig(Schema|Parsed)` from `@objectstack/spec` or `@objectstack/spec/ui` — every one is TS2305 after upgrade, on every public entry (pinned by resolved symbol identity in `ui/widget-i18n-retirement.test.ts`). No metadata document needs editing, because none could ever carry one of these shapes: a stack that parsed before parses byte-for-byte the same after, and a `field.widget: "my_picker"` string is untouched. `FieldWidgetProps` / `FieldWidgetPropsSchema` / `FieldWidgetPropsParsed`, `I18nLabel(Schema)` and `AriaProps(Schema)` all still resolve on `@objectstack/spec/ui` and are asserted to. ⚠️ objectui needs a companion PR in the same window: `packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts` asserts the spec STILL owns `WidgetManifest` / `WidgetSource` (it is the "a workaround should not outlive its reason" half of the objectui#3169 tripwire, designed to go red exactly here), and `packages/types/src/widget.ts`'s "Renamed off the spec's `WidgetManifest` name" comments now point at names that no longer exist. Both are prescribed responses to this removal, not collateral damage.
- **`ui-interaction-config-family-retired`** — `ui.touchInteraction / ui.gestureConfig / ui.dndConfig / ui.keyboardNavigationConfig / ui.componentAnimation / ui.motionConfig / ui.pageTransition / ui.offlineConfig (the whole export surface of ui/touch.zod.ts, ui/dnd.zod.ts, ui/keyboard.zod.ts, ui/animation.zod.ts and ui/offline.zod.ts — 32 defs, 64 exported names)` → (removed — there is no replacement key, because there was never a key. Touch targets, drag-and-drop, focus management, keyboard shortcuts and motion are RENDERER BUILT-IN behaviour: the component library decides them, not a per-page metadata author. Offline is a platform capability, and its vocabulary belongs on the sync engine that owns the queue, the conflict policy and the cache — none of which exists yet. Delete the import and the value. Whichever of these earns real product pull returns WITH its own vocabulary and its executor, the #4910 way, not by un-retiring a declaration)
- Why not automatic: Five `@objectstack/spec/ui` modules declared a full interaction-configuration vocabulary — 22 `z.object` sites across touch/gesture, drag-and-drop, focus/keyboard, animation/motion and offline/sync — and NOTHING in the protocol carried them. This is the ADR-0049 false-compliance shape in its most inviting form for an AI author (ADR-0033), and worse than the ordinary declared-but-unread defect: `authorable-surface.json` listed 109 keys under these defs and `content/docs/references/ui/{touch,dnd,keyboard,animation,offline}.mdx` rendered them as authoring tables, so the published documentation advertised a vocabulary with no carrier key anywhere. An author following `dnd.mdx` and writing a `dnd:` block onto a page component was rejected by `PageComponentSchema` for an unrecognized key — the docs and the schema disagreeing about the platform (Prime Directive #10). Three independent measurements, each with its controls passing in the same run: (1) no module under `packages/spec/src` imported any of the five except the `ui/index.ts` barrel, so no schema declared a carrier key; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema` (25 roots, 4742 nodes) reached none of the 21 named object shapes, while `PageSchema`, `WebhookSchema` and `StateMachineSchema` all resolved `direct` and a synthetic carrier flipped all 21 — so unreachability was a fact about the graph, not a broken walker; (3) zero `.parse()` / `.safeParse()` in objectstack, objectui or cloud outside these modules' own unit tests. objectui holds TYPE re-exports and parity ratchets, never validators, and says so (#2561). The 2026-08-04 ruling weighed wiring a carrier key (option B) and rejected it: that is a feature with a renderer behind it, not ledger clean-up. It also weighed tightening the shapes to `strictObject` and rejected that explicitly — strictness is a property of a PARSE and there is no parse, so it would spend a breaking change to leave "a precisely validated dead slot, the more convincing lie" (#4583). Because there was no carrier key there is nothing to tombstone and no `sys_metadata` row or source file for a D2 conversion to rewrite: this entry is the D3 record, the same route 3 as #4834 (kernel plugin-runtime family) and #4938 (`HttpServerConfig`). ⚠️ Not to be confused with #5021, which retired the THEME `animation` block — a different file, different defs, and that one did have a carrier key and therefore a tombstone. ADR-0049, #4988.
- Done when: No code imports any of the 64 retired names from `@objectstack/spec` or `@objectstack/spec/ui` — `TouchTargetConfig(Schema)`, `GestureType(Schema)`, `SwipeDirection(Schema)`, `SwipeGestureConfig(Schema)`, `PinchGestureConfig(Schema)`, `LongPressGestureConfig(Schema)`, `GestureConfig(Schema)`, `TouchInteraction(Schema)`, `TransitionPreset(Schema)`, `EasingFunction(Schema)`, `TransitionConfig(Schema)`, `AnimationTrigger(Schema)`, `ComponentAnimation(Schema)`, `PageTransition(Schema)`, `MotionConfig(Schema)`, `DragHandle(Schema)`, `DropEffect(Schema)`, `DragConstraint(Schema)`, `DropZone(Schema)`, `DragItem(Schema)`, `DndConfig(Schema)`, `FocusTrapConfig(Schema)`, `KeyboardShortcut(Schema)`, `FocusManagement(Schema)`, `KeyboardNavigationConfig(Schema)`, `OfflineStrategy(Schema)`, `ConflictResolution(Schema)`, `SyncConfig(Schema)`, `PersistStorage(Schema)`, `EvictionPolicy(Schema)`, `OfflineCacheConfig(Schema)`, `OfflineConfig(Schema)` — every one is TS2305 after upgrade, on every public entry (pinned by resolved symbol identity in `ui/interaction-config-retirement.test.ts`). No metadata document needs editing, because none could ever carry one of these blocks: a stack that parsed before parses byte-for-byte the same after. If you consumed the bare `ConflictResolution` from `@objectstack/spec/ui` as a TYPE for your own offline code, declare that union locally — it is your client's policy, not the platform's. `@objectstack/spec/integration`'s `ConnectorConflictResolution` (connector sync) and `@objectstack/spec/api`'s `ConflictResolutionStrategy` (route merge policy) are different concepts and are untouched.
diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json
index 1f5d51f755..4b7db7d5f3 100644
--- a/packages/spec/api-surface/ui.json
+++ b/packages/spec/api-surface/ui.json
@@ -127,8 +127,6 @@
"DatasetMeasureInput (type)",
"DatasetMeasureSchema (const)",
"DatasetSchema (const)",
- "DateFormat (type)",
- "DateFormatSchema (const)",
"DerivedMeasureOp (const)",
"DerivedMeasureOpValue (type)",
"ElementButtonPropsSchema (const)",
@@ -180,8 +178,6 @@
"HttpRequestSchema (const)",
"I18nLabel (type)",
"I18nLabelSchema (const)",
- "I18nObject (type)",
- "I18nObjectSchema (const)",
"InlineAction (type)",
"InlineActionInput (type)",
"InlineActionParsed (type)",
@@ -202,9 +198,6 @@
"ListView (type)",
"ListViewParsed (type)",
"ListViewSchema (const)",
- "LocaleConfig (type)",
- "LocaleConfigParsed (type)",
- "LocaleConfigSchema (const)",
"NavigationArea (type)",
"NavigationAreaParsed (type)",
"NavigationAreaSchema (const)",
@@ -225,9 +218,6 @@
"NotificationSeveritySchema (const)",
"NotificationType (type)",
"NotificationTypeSchema (const)",
- "NumberFormat (type)",
- "NumberFormatParsed (type)",
- "NumberFormatSchema (const)",
"ObjectListViewSchema (const)",
"ObjectNavItem (type)",
"ObjectNavItemParsed (type)",
@@ -261,8 +251,6 @@
"PaginationConfig (type)",
"PaginationConfigParsed (type)",
"PaginationConfigSchema (const)",
- "PluralRule (type)",
- "PluralRuleSchema (const)",
"REACT_BLOCKS (const)",
"REACT_OVERLAY_SHADOWS (const)",
"REACT_RECORD_BLOCK_ALTERNATIVES (const)",
@@ -375,20 +363,6 @@
"WidgetActionTypeSchema (const)",
"WidgetColorVariant (type)",
"WidgetColorVariantSchema (const)",
- "WidgetEvent (type)",
- "WidgetEventParsed (type)",
- "WidgetEventSchema (const)",
- "WidgetLifecycle (type)",
- "WidgetLifecycleSchema (const)",
- "WidgetManifest (type)",
- "WidgetManifestParsed (type)",
- "WidgetManifestSchema (const)",
- "WidgetProperty (type)",
- "WidgetPropertyParsed (type)",
- "WidgetPropertySchema (const)",
- "WidgetSource (type)",
- "WidgetSourceParsed (type)",
- "WidgetSourceSchema (const)",
"actionForm (const)",
"appForm (const)",
"chartAggregateCategoryKey (function)",
diff --git a/packages/spec/authorable-defaults/ui.json b/packages/spec/authorable-defaults/ui.json
index 6002edc7e1..a4ebc64337 100644
--- a/packages/spec/authorable-defaults/ui.json
+++ b/packages/spec/authorable-defaults/ui.json
@@ -61,13 +61,11 @@
"ui/JoinedReportBlock:type = \"tabular\"",
"ui/ListChartConfig:chartType = \"bar\"",
"ui/ListView:type = \"grid\"",
- "ui/LocaleConfig:direction = \"ltr\"",
"ui/NavigationConfig:mode = \"page\"",
"ui/NavigationConfig:openNewTab = false",
"ui/NavigationConfig:preventNavigation = false",
"ui/NavigationConfig:size = \"auto\"",
"ui/NavigationContribution:priority = 200",
- "ui/NumberFormat:style = \"decimal\"",
"ui/ObjectListView:type = \"grid\"",
"ui/ObjectUserFilters:element = \"dropdown\"",
"ui/Page:isDefault = false",
@@ -123,10 +121,6 @@
"ui/ViewSharing:type = \"collaborative\"",
"ui/ViewTab:isDefault = false",
"ui/ViewTab:pinned = false",
- "ui/ViewTab:visible = true",
- "ui/WidgetEvent:bubbles = false",
- "ui/WidgetEvent:cancelable = false",
- "ui/WidgetManifest:category = \"custom\"",
- "ui/WidgetProperty:required = false"
+ "ui/ViewTab:visible = true"
]
}
diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json
index 3537b1fddb..99fab194ca 100644
--- a/packages/spec/authorable-surface/ui.json
+++ b/packages/spec/authorable-surface/ui.json
@@ -364,10 +364,6 @@
"ui/DatasetMeasure:format",
"ui/DatasetMeasure:label",
"ui/DatasetMeasure:name",
- "ui/DateFormat:dateStyle",
- "ui/DateFormat:hour12",
- "ui/DateFormat:timeStyle",
- "ui/DateFormat:timeZone",
"ui/ElementButtonProps:action",
"ui/ElementButtonProps:aria",
"ui/ElementButtonProps:disabled",
@@ -562,9 +558,6 @@
"ui/HttpRequest:method",
"ui/HttpRequest:params",
"ui/HttpRequest:url",
- "ui/I18nObject:defaultValue",
- "ui/I18nObject:key",
- "ui/I18nObject:params",
"ui/InlineAction:confirmText",
"ui/InlineAction:errorMessage",
"ui/InlineAction:label",
@@ -671,11 +664,6 @@
"ui/ListView:userActions",
"ui/ListView:userFilters",
"ui/ListView:virtualScroll",
- "ui/LocaleConfig:code",
- "ui/LocaleConfig:dateFormat",
- "ui/LocaleConfig:direction",
- "ui/LocaleConfig:fallbackChain",
- "ui/LocaleConfig:numberFormat",
"ui/NavigationArea:description",
"ui/NavigationArea:icon",
"ui/NavigationArea:id",
@@ -691,12 +679,6 @@
"ui/NavigationContribution:group",
"ui/NavigationContribution:items",
"ui/NavigationContribution:priority",
- "ui/NumberFormat:currency",
- "ui/NumberFormat:maximumFractionDigits",
- "ui/NumberFormat:minimumFractionDigits",
- "ui/NumberFormat:style",
- "ui/NumberFormat:unit",
- "ui/NumberFormat:useGrouping",
"ui/ObjectListView:addRecord",
"ui/ObjectListView:allowPrinting",
"ui/ObjectListView:appearance",
@@ -843,13 +825,6 @@
"ui/PageVariable:type",
"ui/PaginationConfig:pageSize",
"ui/PaginationConfig:pageSizeOptions",
- "ui/PluralRule:few",
- "ui/PluralRule:key",
- "ui/PluralRule:many",
- "ui/PluralRule:one",
- "ui/PluralRule:other",
- "ui/PluralRule:two",
- "ui/PluralRule:zero",
"ui/RecordActivityProps:aria",
"ui/RecordActivityProps:enableMentions",
"ui/RecordActivityProps:enableReactions",
@@ -1053,46 +1028,6 @@
"ui/ViewTab:order",
"ui/ViewTab:pinned",
"ui/ViewTab:view",
- "ui/ViewTab:visible",
- "ui/WidgetEvent:bubbles",
- "ui/WidgetEvent:cancelable",
- "ui/WidgetEvent:description",
- "ui/WidgetEvent:label",
- "ui/WidgetEvent:name",
- "ui/WidgetEvent:payload",
- "ui/WidgetLifecycle:onBlur",
- "ui/WidgetLifecycle:onError",
- "ui/WidgetLifecycle:onFocus",
- "ui/WidgetLifecycle:onMount",
- "ui/WidgetLifecycle:onUnmount",
- "ui/WidgetLifecycle:onUpdate",
- "ui/WidgetLifecycle:onValidate",
- "ui/WidgetManifest:aria",
- "ui/WidgetManifest:author",
- "ui/WidgetManifest:category",
- "ui/WidgetManifest:dependencies",
- "ui/WidgetManifest:description",
- "ui/WidgetManifest:documentation",
- "ui/WidgetManifest:events",
- "ui/WidgetManifest:fieldTypes",
- "ui/WidgetManifest:icon",
- "ui/WidgetManifest:implementation",
- "ui/WidgetManifest:label",
- "ui/WidgetManifest:license",
- "ui/WidgetManifest:lifecycle",
- "ui/WidgetManifest:name",
- "ui/WidgetManifest:performance [RETIRED]",
- "ui/WidgetManifest:properties",
- "ui/WidgetManifest:screenshots",
- "ui/WidgetManifest:tags",
- "ui/WidgetManifest:version",
- "ui/WidgetProperty:category",
- "ui/WidgetProperty:default",
- "ui/WidgetProperty:description",
- "ui/WidgetProperty:label",
- "ui/WidgetProperty:name",
- "ui/WidgetProperty:required",
- "ui/WidgetProperty:type",
- "ui/WidgetProperty:validation"
+ "ui/ViewTab:visible"
]
}
diff --git a/packages/spec/json-schema.manifest/ui.json b/packages/spec/json-schema.manifest/ui.json
index 8b9ac9a046..52193becd1 100644
--- a/packages/spec/json-schema.manifest/ui.json
+++ b/packages/spec/json-schema.manifest/ui.json
@@ -49,7 +49,6 @@
"ui/Dataset",
"ui/DatasetDimension",
"ui/DatasetMeasure",
- "ui/DateFormat",
"ui/DerivedMeasureOp",
"ui/ElementButtonProps",
"ui/ElementDataSource",
@@ -76,7 +75,6 @@
"ui/HttpMethodSubset",
"ui/HttpRequest",
"ui/I18nLabel",
- "ui/I18nObject",
"ui/InlineAction",
"ui/InterfacePageConfig",
"ui/JoinedReportBlock",
@@ -84,7 +82,6 @@
"ui/ListChartConfig",
"ui/ListColumn",
"ui/ListView",
- "ui/LocaleConfig",
"ui/NavigationArea",
"ui/NavigationConfig",
"ui/NavigationContribution",
@@ -93,7 +90,6 @@
"ui/NotificationPosition",
"ui/NotificationSeverity",
"ui/NotificationType",
- "ui/NumberFormat",
"ui/ObjectListView",
"ui/ObjectNavItem",
"ui/ObjectUserFilters",
@@ -110,7 +106,6 @@
"ui/PageType",
"ui/PageVariable",
"ui/PaginationConfig",
- "ui/PluralRule",
"ui/RecordActivityProps",
"ui/RecordChatterProps",
"ui/RecordDetailsProps",
@@ -152,11 +147,6 @@
"ui/ViewTab",
"ui/VisualizationType",
"ui/WidgetActionType",
- "ui/WidgetColorVariant",
- "ui/WidgetEvent",
- "ui/WidgetLifecycle",
- "ui/WidgetManifest",
- "ui/WidgetProperty",
- "ui/WidgetSource"
+ "ui/WidgetColorVariant"
]
}
diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json
index 3bb2ce508f..02951eda25 100644
--- a/packages/spec/spec-changes.json
+++ b/packages/spec/spec-changes.json
@@ -589,6 +589,13 @@
"toMajor": 17,
"rationale": "This is the one protocol-17 entry that turns metadata ON rather than off, so read it as a SECURITY review item and not as a rename. Before 17 the declarative endpoint surface executed NOTHING: no route was mounted for a declared `path`, no matcher existed, and every key — `authRequired` included — parsed green and gated nothing (#4936, which refused a non-empty `apis:` outright for exactly that reason). Protocol 17 ships the executor (#5040) and narrows that refusal to a per-endpoint publish gate: an endpoint that PASSES the gate is mounted and serves real traffic as soon as the stack is published. So an `apis:` block written against an older major — or one restored from a pre-#4936 source, or authored from a doc that predates the refusal — changes meaning without changing a byte: what used to be inert documentation becomes an execution entry point into the data and automation pipelines. Nothing about that transition can be applied mechanically, because the judgment it needs is \"did the author of this endpoint mean for the internet to reach it?\" — and the one key where a wrong answer is unrecoverable is `authRequired`. Its schema default is `true`, so an omission is SAFE and needs no review; an EXPLICIT `authRequired: false` is the only thing that opens anonymous access, and under ADR-0121 D6 it now also requires an armed `rateLimit` (`enabled: true` — the key defaults to `false`, so a budget written without it meters nothing) or the stack refuses to publish. Grep every `apis:` entry for `authRequired: false` before you upgrade, delete the ones that were never meant to be public, and arm a budget on the ones that were. The path move is the mechanical-looking half and is still yours: ADR-0121 D1/D2 confine a declared path to your own namespace carve-out (`/api/v1/apps//…`), the namespace comes from an explicit `manifest.namespace` with no derivation fallback, and the subpath is the only part you name — rewriting it for you would silently change a URL third parties call."
},
+ {
+ "surface": "ui.widgetManifest / ui.widgetLifecycle / ui.widgetEvent / ui.widgetProperty / ui.widgetSource / ui.i18nObject / ui.pluralRule / ui.numberFormat / ui.dateFormat / ui.localeConfig (the widget-registration vocabulary of ui/widget.zod.ts, and the five doorless shapes of ui/i18n.zod.ts — 10 defs, 26 exported names)",
+ "replacement": "(removed — there is no replacement key, because there was never a key. A custom field widget is still named the same way it always was: `field.widget` is a plain string naming a component the RENDERER has registered, and objectui's registry has always carried its own runtime manifest for that (`RuntimeWidgetManifest` / `RuntimeWidgetSource` in `@object-ui/types`, objectui#3161 / #4115), which models different keys and never derived from these. For localisation: write the default-language string on `label` / `description` — the framework generates the translation key at registration time from the naming convention — and put translations in translation files, which is the LIVE `system/translation.zod.ts` surface. Widget registration and locale formatting as authorable protocol metadata return via the ENFORCE route of ADR-0049 through a new ADR — the registry / loader / formatter first, the vocabulary second)",
+ "migrationId": "ui-widget-i18n-family-retired",
+ "toMajor": 17,
+ "rationale": "`ui/widget.zod.ts` published a complete widget-registration vocabulary — a manifest with lifecycle hooks, custom events, configurable properties and an npm/remote/inline implementation-source union — and `ui/i18n.zod.ts` published a structured-label, plural-rule and locale-formatting vocabulary. NOTHING in the protocol carried either. Three independent measurements, re-run on `origin/main` immediately before the removal with their controls passing in the SAME run: (1) no module under `packages/spec/src` imported `widget.zod` at all, and the only imports of `i18n.zod` anywhere name `I18nLabelSchema` / `AriaPropsSchema` (both KEPT), so no schema declared a carrier key — `field.widget` is a `z.string()` naming a registered component and has never referenced `WidgetManifest`; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema` reached none of them, while `PageSchema` / `ObjectListViewSchema` resolved `direct` in the same run and a synthetic carrier flipped every one of them; (3) zero `.parse()` / `.safeParse()` in objectstack, objectui or cloud outside these files' own unit tests. `NumberFormat` / `DateFormat` DID have a carrier key (`LocaleConfig.numberFormat` / `.dateFormat`) but the carrier was itself doorless, so the subtree was `no door` rather than `no gate` and goes whole — leaving the two leaves behind would strand exported schemas with no consumer (#3950). `I18nObjectSchema` was additionally superseded by its own file-neighbour: `I18nLabelSchema`'s documentation already says translation keys are generated at registration time and translations live in translation files, and the live translation surface is `system/translation.zod.ts`, which uses none of these shapes. The 2026-08-06 ruling weighed giving them a carrier (option B) and rejected it: that is a feature with a registry and a renderer behind it, not ledger clean-up. Tightening them to `strictObject` was rejected earlier and explicitly (#4001 批 16) — strictness is a property of a PARSE and there is no parse, so it would spend a breaking change to leave \"a precisely validated dead slot, the more convincing lie\" (#4583). With no carrier key there is nothing to tombstone and no `sys_metadata` row or source file for a D2 conversion to rewrite: this entry is the D3 record, route 3, the same shape as #4988 (the ui/ interaction config family), #4834 (kernel plugin-runtime family) and #4938 (`HttpServerConfig`). ⚠️ `WidgetManifest.performance`'s own `retiredKey()` tombstone (#3896 close-out) is SUBSUMED here, the #4657/#4834 way: it goes with the shape that carried it, which is strictly stronger than the tombstone, because there is no longer a manifest to author the key INTO. ⚠️ One of the nine widget sites is deliberately NOT retired. `FieldWidgetPropsSchema` survives: it is a REACT PROPS CONTRACT rather than authorable metadata (it never appeared in `authorable-surface/` or `json-schema.manifest/` — its `onChange` is a `z.function()`), so \"zero parse\" is its design and not its defect, and it acquired a live cross-repo compile-time consumer one day before 批 16 measured: objectui PR #3289 (2026-08-03) renamed `@object-ui/fields`' validation slot onto the spec's `error` with no alias, the form renderer began producing it, and `packages/fields/src/__tests__/spec-symbol-batch7.test.ts` pins the shape against `import type { FieldWidgetProps } from '@objectstack/spec/ui'` as an intentional tripwire. Re-verified on objectui `origin/main` 2026-08-07. ADR-0049, #5055."
+ },
{
"surface": "ui.touchInteraction / ui.gestureConfig / ui.dndConfig / ui.keyboardNavigationConfig / ui.componentAnimation / ui.motionConfig / ui.pageTransition / ui.offlineConfig (the whole export surface of ui/touch.zod.ts, ui/dnd.zod.ts, ui/keyboard.zod.ts, ui/animation.zod.ts and ui/offline.zod.ts — 32 defs, 64 exported names)",
"replacement": "(removed — there is no replacement key, because there was never a key. Touch targets, drag-and-drop, focus management, keyboard shortcuts and motion are RENDERER BUILT-IN behaviour: the component library decides them, not a per-page metadata author. Offline is a platform capability, and its vocabulary belongs on the sync engine that owns the queue, the conflict policy and the cache — none of which exists yet. Delete the import and the value. Whichever of these earns real product pull returns WITH its own vocabulary and its executor, the #4910 way, not by un-retiring a declaration)",
@@ -1278,6 +1285,13 @@
"toMajor": 17,
"rationale": "This is the one protocol-17 entry that turns metadata ON rather than off, so read it as a SECURITY review item and not as a rename. Before 17 the declarative endpoint surface executed NOTHING: no route was mounted for a declared `path`, no matcher existed, and every key — `authRequired` included — parsed green and gated nothing (#4936, which refused a non-empty `apis:` outright for exactly that reason). Protocol 17 ships the executor (#5040) and narrows that refusal to a per-endpoint publish gate: an endpoint that PASSES the gate is mounted and serves real traffic as soon as the stack is published. So an `apis:` block written against an older major — or one restored from a pre-#4936 source, or authored from a doc that predates the refusal — changes meaning without changing a byte: what used to be inert documentation becomes an execution entry point into the data and automation pipelines. Nothing about that transition can be applied mechanically, because the judgment it needs is \"did the author of this endpoint mean for the internet to reach it?\" — and the one key where a wrong answer is unrecoverable is `authRequired`. Its schema default is `true`, so an omission is SAFE and needs no review; an EXPLICIT `authRequired: false` is the only thing that opens anonymous access, and under ADR-0121 D6 it now also requires an armed `rateLimit` (`enabled: true` — the key defaults to `false`, so a budget written without it meters nothing) or the stack refuses to publish. Grep every `apis:` entry for `authRequired: false` before you upgrade, delete the ones that were never meant to be public, and arm a budget on the ones that were. The path move is the mechanical-looking half and is still yours: ADR-0121 D1/D2 confine a declared path to your own namespace carve-out (`/api/v1/apps//…`), the namespace comes from an explicit `manifest.namespace` with no derivation fallback, and the subpath is the only part you name — rewriting it for you would silently change a URL third parties call."
},
+ {
+ "surface": "ui.widgetManifest / ui.widgetLifecycle / ui.widgetEvent / ui.widgetProperty / ui.widgetSource / ui.i18nObject / ui.pluralRule / ui.numberFormat / ui.dateFormat / ui.localeConfig (the widget-registration vocabulary of ui/widget.zod.ts, and the five doorless shapes of ui/i18n.zod.ts — 10 defs, 26 exported names)",
+ "replacement": "(removed — there is no replacement key, because there was never a key. A custom field widget is still named the same way it always was: `field.widget` is a plain string naming a component the RENDERER has registered, and objectui's registry has always carried its own runtime manifest for that (`RuntimeWidgetManifest` / `RuntimeWidgetSource` in `@object-ui/types`, objectui#3161 / #4115), which models different keys and never derived from these. For localisation: write the default-language string on `label` / `description` — the framework generates the translation key at registration time from the naming convention — and put translations in translation files, which is the LIVE `system/translation.zod.ts` surface. Widget registration and locale formatting as authorable protocol metadata return via the ENFORCE route of ADR-0049 through a new ADR — the registry / loader / formatter first, the vocabulary second)",
+ "migrationId": "ui-widget-i18n-family-retired",
+ "toMajor": 17,
+ "rationale": "`ui/widget.zod.ts` published a complete widget-registration vocabulary — a manifest with lifecycle hooks, custom events, configurable properties and an npm/remote/inline implementation-source union — and `ui/i18n.zod.ts` published a structured-label, plural-rule and locale-formatting vocabulary. NOTHING in the protocol carried either. Three independent measurements, re-run on `origin/main` immediately before the removal with their controls passing in the SAME run: (1) no module under `packages/spec/src` imported `widget.zod` at all, and the only imports of `i18n.zod` anywhere name `I18nLabelSchema` / `AriaPropsSchema` (both KEPT), so no schema declared a carrier key — `field.widget` is a `z.string()` naming a registered component and has never referenced `WidgetManifest`; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema` reached none of them, while `PageSchema` / `ObjectListViewSchema` resolved `direct` in the same run and a synthetic carrier flipped every one of them; (3) zero `.parse()` / `.safeParse()` in objectstack, objectui or cloud outside these files' own unit tests. `NumberFormat` / `DateFormat` DID have a carrier key (`LocaleConfig.numberFormat` / `.dateFormat`) but the carrier was itself doorless, so the subtree was `no door` rather than `no gate` and goes whole — leaving the two leaves behind would strand exported schemas with no consumer (#3950). `I18nObjectSchema` was additionally superseded by its own file-neighbour: `I18nLabelSchema`'s documentation already says translation keys are generated at registration time and translations live in translation files, and the live translation surface is `system/translation.zod.ts`, which uses none of these shapes. The 2026-08-06 ruling weighed giving them a carrier (option B) and rejected it: that is a feature with a registry and a renderer behind it, not ledger clean-up. Tightening them to `strictObject` was rejected earlier and explicitly (#4001 批 16) — strictness is a property of a PARSE and there is no parse, so it would spend a breaking change to leave \"a precisely validated dead slot, the more convincing lie\" (#4583). With no carrier key there is nothing to tombstone and no `sys_metadata` row or source file for a D2 conversion to rewrite: this entry is the D3 record, route 3, the same shape as #4988 (the ui/ interaction config family), #4834 (kernel plugin-runtime family) and #4938 (`HttpServerConfig`). ⚠️ `WidgetManifest.performance`'s own `retiredKey()` tombstone (#3896 close-out) is SUBSUMED here, the #4657/#4834 way: it goes with the shape that carried it, which is strictly stronger than the tombstone, because there is no longer a manifest to author the key INTO. ⚠️ One of the nine widget sites is deliberately NOT retired. `FieldWidgetPropsSchema` survives: it is a REACT PROPS CONTRACT rather than authorable metadata (it never appeared in `authorable-surface/` or `json-schema.manifest/` — its `onChange` is a `z.function()`), so \"zero parse\" is its design and not its defect, and it acquired a live cross-repo compile-time consumer one day before 批 16 measured: objectui PR #3289 (2026-08-03) renamed `@object-ui/fields`' validation slot onto the spec's `error` with no alias, the form renderer began producing it, and `packages/fields/src/__tests__/spec-symbol-batch7.test.ts` pins the shape against `import type { FieldWidgetProps } from '@objectstack/spec/ui'` as an intentional tripwire. Re-verified on objectui `origin/main` 2026-08-07. ADR-0049, #5055."
+ },
{
"surface": "ui.touchInteraction / ui.gestureConfig / ui.dndConfig / ui.keyboardNavigationConfig / ui.componentAnimation / ui.motionConfig / ui.pageTransition / ui.offlineConfig (the whole export surface of ui/touch.zod.ts, ui/dnd.zod.ts, ui/keyboard.zod.ts, ui/animation.zod.ts and ui/offline.zod.ts — 32 defs, 64 exported names)",
"replacement": "(removed — there is no replacement key, because there was never a key. Touch targets, drag-and-drop, focus management, keyboard shortcuts and motion are RENDERER BUILT-IN behaviour: the component library decides them, not a per-page metadata author. Offline is a platform capability, and its vocabulary belongs on the sync engine that owns the queue, the conflict policy and the cache — none of which exists yet. Delete the import and the value. Whichever of these earns real product pull returns WITH its own vocabulary and its executor, the #4910 way, not by un-retiring a declaration)",
diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts
index 16a19807ef..f44cbab388 100644
--- a/packages/spec/src/migrations/registry.ts
+++ b/packages/spec/src/migrations/registry.ts
@@ -1054,6 +1054,30 @@ const step17: MigrationStep = {
+ 'string enum applied row by row by the REST import path and live in the liveness '
+ 'ledger — including its own `javascript` value, which that path rejects with a 400 '
+ 'rather than pretending to run.\n\n'
+ + 'The last of the #4001 enforce-or-remove batch lands on two more `ui/` files (#5055, '
+ + 'ADR-0049 — read it next to #4988 above, it is the same shape one batch later). '
+ + '`ui/widget.zod.ts` published a whole widget-REGISTRATION vocabulary — `WidgetManifest` '
+ + 'with `WidgetLifecycle` hooks, `WidgetEvent`s, `WidgetProperty` knobs and a '
+ + '`WidgetSource` npm/remote/inline implementation union — and `ui/i18n.zod.ts` published '
+ + '`I18nObject`, `PluralRule`, `NumberFormat`, `DateFormat` and `LocaleConfig`. Ten defs, '
+ + 'twenty exported names, and not one carrier key between them: nothing under '
+ + '`packages/spec/src` imported `widget.zod` at all, the only live imports of `i18n.zod` '
+ + 'name `I18nLabelSchema` / `AriaPropsSchema`, the BFS from all 24 metadata-type roots '
+ + 'plus `defineStack` reached none of them, and no repo ever parsed one. So again nothing '
+ + 'is applied for you and nothing needs to be — the change is TS2305 on an import, and a '
+ + '`field.widget: "my_picker"` string is untouched, because that key names a component the '
+ + 'RENDERER registered and never referenced `WidgetManifest`. ⚠️ Read this scope precisely '
+ + 'too, because BOTH files split. `ui/i18n.zod.ts` keeps `I18nLabelSchema` (the label '
+ + 'primitive the whole `ui/` tree imports) and `AriaPropsSchema` — a REAL door, carried as '
+ + '`aria:` on ~30 live shapes and closed by 批 16, untouched here. And `ui/widget.zod.ts` '
+ + 'keeps `FieldWidgetPropsSchema`, the one site of the nine whose evidence differs: it is '
+ + 'a React props contract rather than authorable metadata (it never appeared in the '
+ + 'authorable surface or the schema manifest — `onChange` is a `z.function()`), so having '
+ + 'no parse is its design; and objectui PR #3289 (2026-08-03) made it a live compile-time '
+ + 'consumer, renaming `@object-ui/fields`\' validation slot onto this contract\'s `error` '
+ + 'with no alias and pinning it as a deliberate tripwire. Retiring it would have broken '
+ + 'the one consumer the batch had, one day after it appeared. The measurement that decides '
+ + 'a site is the CURRENT one, not the one in the issue body.\n\n'
+ 'Last, it reconciles the SDUI component-props surface with the renderers that serve it '
+ '(#5775). #5068 wired the first parse `ComponentPropsMap` ever had, and the corpus it '
+ 'landed on diverged in BOTH directions: keys objectui honours that the schema never '
@@ -1801,6 +1825,93 @@ const step17: MigrationStep = {
+ '(4) after publishing, each endpoint answers as you expect — an anonymous request to '
+ 'a session-only endpoint returns 401 rather than data.',
},
+ {
+ id: 'ui-widget-i18n-family-retired',
+ surface:
+ 'ui.widgetManifest / ui.widgetLifecycle / ui.widgetEvent / ui.widgetProperty '
+ + '/ ui.widgetSource / ui.i18nObject / ui.pluralRule / ui.numberFormat / ui.dateFormat '
+ + '/ ui.localeConfig (the widget-registration vocabulary of ui/widget.zod.ts, and the '
+ + 'five doorless shapes of ui/i18n.zod.ts — 10 defs, 26 exported names)',
+ replacement:
+ '(removed — there is no replacement key, because there was never a key. A custom field '
+ + 'widget is still named the same way it always was: `field.widget` is a plain string '
+ + 'naming a component the RENDERER has registered, and objectui\'s registry has always '
+ + 'carried its own runtime manifest for that (`RuntimeWidgetManifest` / '
+ + '`RuntimeWidgetSource` in `@object-ui/types`, objectui#3161 / #4115), which models '
+ + 'different keys and never derived from these. For localisation: write the '
+ + 'default-language string on `label` / `description` — the framework generates the '
+ + 'translation key at registration time from the naming convention — and put '
+ + 'translations in translation files, which is the LIVE `system/translation.zod.ts` '
+ + 'surface. Widget registration and locale formatting as authorable protocol metadata '
+ + 'return via the ENFORCE route of ADR-0049 through a new ADR — the registry / loader / '
+ + 'formatter first, the vocabulary second)',
+ reason:
+ '`ui/widget.zod.ts` published a complete widget-registration vocabulary — a manifest '
+ + 'with lifecycle hooks, custom events, configurable properties and an '
+ + 'npm/remote/inline implementation-source union — and `ui/i18n.zod.ts` published a '
+ + 'structured-label, plural-rule and locale-formatting vocabulary. NOTHING in the '
+ + 'protocol carried either. Three independent measurements, re-run on `origin/main` '
+ + 'immediately before the removal with their controls passing in the SAME run: (1) no '
+ + 'module under `packages/spec/src` imported `widget.zod` at all, and the only imports '
+ + 'of `i18n.zod` anywhere name `I18nLabelSchema` / `AriaPropsSchema` (both KEPT), so no '
+ + 'schema declared a carrier key — `field.widget` is a `z.string()` naming a registered '
+ + 'component and has never referenced `WidgetManifest`; (2) a BFS over the in-memory '
+ + 'Zod graph from all 24 metadata-type roots plus `defineStack`\'s `ObjectStackSchema` '
+ + 'reached none of them, while `PageSchema` / `ObjectListViewSchema` resolved `direct` '
+ + 'in the same run and a synthetic carrier flipped every one of them; (3) zero '
+ + '`.parse()` / `.safeParse()` in objectstack, objectui or cloud outside these files\' '
+ + 'own unit tests. `NumberFormat` / `DateFormat` DID have a carrier key '
+ + '(`LocaleConfig.numberFormat` / `.dateFormat`) but the carrier was itself doorless, '
+ + 'so the subtree was `no door` rather than `no gate` and goes whole — leaving the two '
+ + 'leaves behind would strand exported schemas with no consumer (#3950). '
+ + '`I18nObjectSchema` was additionally superseded by its own file-neighbour: '
+ + '`I18nLabelSchema`\'s documentation already says translation keys are generated at '
+ + 'registration time and translations live in translation files, and the live '
+ + 'translation surface is `system/translation.zod.ts`, which uses none of these shapes. '
+ + 'The 2026-08-06 ruling weighed giving them a carrier (option B) and rejected it: that '
+ + 'is a feature with a registry and a renderer behind it, not ledger clean-up. '
+ + 'Tightening them to `strictObject` was rejected earlier and explicitly (#4001 批 16) '
+ + '— strictness is a property of a PARSE and there is no parse, so it would spend a '
+ + 'breaking change to leave "a precisely validated dead slot, the more convincing lie" '
+ + '(#4583). With no carrier key there is nothing to tombstone and no `sys_metadata` row '
+ + 'or source file for a D2 conversion to rewrite: this entry is the D3 record, route 3, '
+ + 'the same shape as #4988 (the ui/ interaction config family), #4834 (kernel '
+ + 'plugin-runtime family) and #4938 (`HttpServerConfig`). '
+ + '⚠️ `WidgetManifest.performance`\'s own `retiredKey()` tombstone (#3896 close-out) is '
+ + 'SUBSUMED here, the #4657/#4834 way: it goes with the shape that carried it, which is '
+ + 'strictly stronger than the tombstone, because there is no longer a manifest to '
+ + 'author the key INTO. '
+ + '⚠️ One of the nine widget sites is deliberately NOT retired. '
+ + '`FieldWidgetPropsSchema` survives: it is a REACT PROPS CONTRACT rather than '
+ + 'authorable metadata (it never appeared in `authorable-surface/` or '
+ + '`json-schema.manifest/` — its `onChange` is a `z.function()`), so "zero parse" is its '
+ + 'design and not its defect, and it acquired a live cross-repo compile-time consumer '
+ + 'one day before 批 16 measured: objectui PR #3289 (2026-08-03) renamed '
+ + '`@object-ui/fields`\' validation slot onto the spec\'s `error` with no alias, the '
+ + 'form renderer began producing it, and '
+ + '`packages/fields/src/__tests__/spec-symbol-batch7.test.ts` pins the shape against '
+ + '`import type { FieldWidgetProps } from \'@objectstack/spec/ui\'` as an intentional '
+ + 'tripwire. Re-verified on objectui `origin/main` 2026-08-07. ADR-0049, #5055.',
+ acceptanceCriteria:
+ 'No code imports `WidgetManifest(Schema|Parsed)`, `WidgetLifecycle(Schema)`, '
+ + '`WidgetEvent(Schema|Parsed)`, `WidgetProperty(Schema|Parsed)`, '
+ + '`WidgetSource(Schema|Parsed)`, `I18nObject(Schema)`, `PluralRule(Schema)`, '
+ + '`NumberFormat(Schema|Parsed)`, `DateFormat(Schema)` or '
+ + '`LocaleConfig(Schema|Parsed)` from `@objectstack/spec` or `@objectstack/spec/ui` — '
+ + 'every one is TS2305 after upgrade, on every public entry (pinned by resolved symbol '
+ + 'identity in `ui/widget-i18n-retirement.test.ts`). No metadata document needs '
+ + 'editing, because none could ever carry one of these shapes: a stack that parsed '
+ + 'before parses byte-for-byte the same after, and a `field.widget: "my_picker"` string '
+ + 'is untouched. `FieldWidgetProps` / `FieldWidgetPropsSchema` / '
+ + '`FieldWidgetPropsParsed`, `I18nLabel(Schema)` and `AriaProps(Schema)` all still '
+ + 'resolve on `@objectstack/spec/ui` and are asserted to. ⚠️ objectui needs a companion '
+ + 'PR in the same window: `packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts` '
+ + 'asserts the spec STILL owns `WidgetManifest` / `WidgetSource` (it is the '
+ + '"a workaround should not outlive its reason" half of the objectui#3169 tripwire, '
+ + 'designed to go red exactly here), and `packages/types/src/widget.ts`\'s '
+ + '"Renamed off the spec\'s `WidgetManifest` name" comments now point at names that no '
+ + 'longer exist. Both are prescribed responses to this removal, not collateral damage.',
+ },
{
id: 'ui-interaction-config-family-retired',
surface:
@@ -2356,5 +2467,33 @@ export const RETIRED_DEFS_BY_MAJOR: Readonly>
// value schema had no other consumer, so it goes with the key rather than
// surviving as an exported union nothing references — an exported schema with
// no consumer reads as a capability to whoever finds it (#3950).
- 17: ['shared/FieldMappingTransform'],
+ //
+ // The ten that follow are #5055 (ADR-0049 enforce-or-remove, maintainer ruling
+ // 2026-08-06, window moved to protocol 17 on 2026-08-07): `ui/widget.zod.ts`'s
+ // widget-registration vocabulary and the five doorless shapes of
+ // `ui/i18n.zod.ts`. None had a carrier key, none was reachable from the
+ // metadata-type roots, and none was ever parsed in objectstack / objectui /
+ // cloud — so there is no tombstone and no D2 conversion (route 3 of the
+ // retirement playbook), and this table plus the D3 `SemanticMigration`
+ // `ui-widget-i18n-family-retired` IS the declaration. Same shape as #4988.
+ //
+ // ⚠️ `ui/FieldWidgetProps` is deliberately NOT here — it was never in the
+ // manifest to begin with (its `onChange` is a `z.function()`, so no JSON
+ // Schema is emitted) and it survives the retirement: it is a React props
+ // contract, not authorable metadata, and it has a live compile-time consumer
+ // in objectui (`packages/fields/src/__tests__/spec-symbol-batch7.test.ts`,
+ // landed by objectui PR #3289).
+ 17: [
+ 'shared/FieldMappingTransform',
+ 'ui/WidgetManifest',
+ 'ui/WidgetLifecycle',
+ 'ui/WidgetEvent',
+ 'ui/WidgetProperty',
+ 'ui/WidgetSource',
+ 'ui/I18nObject',
+ 'ui/PluralRule',
+ 'ui/NumberFormat',
+ 'ui/DateFormat',
+ 'ui/LocaleConfig',
+ ],
};
diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts
index 9e1ad30341..7ff8962620 100644
--- a/packages/spec/src/type-alias-convention.pin.test.ts
+++ b/packages/spec/src/type-alias-convention.pin.test.ts
@@ -240,7 +240,6 @@ import type * as M164 from './ui/report.zod.js';
import type * as M165 from './ui/responsive.zod.js';
import type * as M166 from './ui/theme.zod.js';
import type * as M167 from './ui/view.zod.js';
-import type * as M168 from './ui/widget.zod.js';
// Appended out of alphabetical order deliberately: the M-indices are positional
// identifiers the pin lines below reference by number, so a new module takes the
// next free index rather than renumbering 169 imports and every pin that names
@@ -1275,11 +1274,8 @@ export type Iso683 = Assert, z.infer
export type Iso684 = Assert, z.infer< typeof M160.DatasetSchema > >>;
// ui/i18n.zod.ts
-export type Iso685 = Assert, z.infer< typeof M161.I18nObjectSchema > >>;
export type Iso686 = Assert, z.infer< typeof M161.I18nLabelSchema > >>;
export type Iso687 = Assert, z.infer< typeof M161.AriaPropsSchema > >>;
-export type Iso688 = Assert, z.infer< typeof M161.PluralRuleSchema > >>;
-export type Iso689 = Assert, z.infer< typeof M161.DateFormatSchema > >>;
// ui/notification.zod.ts
export type Iso690 = Assert, z.infer< typeof M162.NotificationTypeSchema > >>;
@@ -1319,9 +1315,6 @@ export type Iso713 = Assert, z.i
export type Iso714 = Assert, z.infer< typeof M167.VisualizationTypeSchema > >>;
export type Iso715 = Assert, z.infer< typeof M167.UserFilterFieldSchema > >>;
-// ui/widget.zod.ts
-export type Iso716 = Assert, z.infer< typeof M168.WidgetLifecycleSchema > >>;
-
// ui/component.zod.ts
// #5775 — the shared `children` contract for `page:section`/`page:footer`/
// `page:sidebar`. A lone optional array with no default, transform, catch or
@@ -1402,9 +1395,25 @@ describe('ADR-0122 type-alias convention', () => {
// sends it here rather than to an `XParsed` (718 -> 719 was
// `ConnectorActionEffectSchema`, #4395 — a bare `z.enum`, like the
// `ConnectorType` / `ConnectorStatus` pins beside it).
+ //
+ // And it drops by MORE than one when schemas are RETIRED, which is the third
+ // way and the one to read carefully, because from the count alone it looks
+ // exactly like the edit this case exists to stop: #5055 removed
+ // `I18nObjectSchema`, `PluralRuleSchema`, `DateFormatSchema` and
+ // `WidgetLifecycleSchema` under ADR-0049, i.e. -4. What separates it from a
+ // bare deletion is that the SCHEMAS went with the pins —
+ // `check:spec-parsed-alias` has nothing left to exempt, and
+ // `ui/widget-i18n-retirement.test.ts` asserts their absence on every public
+ // entry. A pin deleted while its schema still exports is still the failure
+ // this counts.
+ //
+ // 720 -> 716 is that retirement landing on top of #5775's addition
+ // (`PageContainerProps`, the +1 that had taken the count to 720). Both moves
+ // are in this number at once, which is exactly why it is recomputed from the
+ // source rather than reasoned about: -4 retired, +0 of my own.
const self = readFileSync(fileURLToPath(import.meta.url), 'utf8');
const pins = self.match(/^export type Iso\d+ = Assert {
diff --git a/packages/spec/src/ui/door-reachability.testkit.test.ts b/packages/spec/src/ui/door-reachability.testkit.test.ts
index d203b866cc..c70f95b8d0 100644
--- a/packages/spec/src/ui/door-reachability.testkit.test.ts
+++ b/packages/spec/src/ui/door-reachability.testkit.test.ts
@@ -19,10 +19,33 @@ import { z } from 'zod';
import { measureDoors } from './door-reachability.testkit';
import { PageSchema } from './page.zod';
import { ObjectListViewSchema } from './view.zod';
-import { WidgetManifestSchema } from './widget.zod';
import { I18nLabelSchema } from './i18n.zod';
import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod';
+/**
+ * The #5056 subject, reconstructed.
+ *
+ * The false positive was measured on the real `WidgetManifestSchema`: 19 keys,
+ * of which exactly two (`name`, `label`) were `.describe()` clones of the SHARED
+ * `SnakeCaseIdentifierSchema` / `I18nLabelSchema` leaf instances — which is why
+ * the old any-one-shared-property bridge fired on it. #5055 retired that schema
+ * (ADR-0049 enforce-or-remove: no carrier key, nothing ever parsed it), so the
+ * fixture is rebuilt here from the same two shared leaf instances plus 17
+ * unshared fillers.
+ *
+ * Rebuilding rather than re-pointing at some other live schema is deliberate:
+ * what this pin means is the RATIO (2/19) over those two specific shared leaves,
+ * and a substitute subject would silently change both. Nothing about the
+ * walker's behaviour ever depended on the subject being a widget manifest.
+ */
+const SHARED_LEAF_2_OF_19 = z.object({
+ name: SnakeCaseIdentifierSchema.describe('Machine name'),
+ label: I18nLabelSchema.describe('Display label'),
+ ...Object.fromEntries(
+ Array.from({ length: 17 }, (_, i) => [`osDoorProbeFiller${i}`, z.string()]),
+ ),
+});
+
/** The walker's identity key, mirrored here so the premise tests can assert on it. */
const defOf = (s: unknown): unknown => (s as { _zod?: { def?: unknown } })?._zod?.def;
@@ -131,7 +154,7 @@ describe('#5056 controls — the walker finds doors, and only real ones', () =>
// 3. The #5056 regression boundary itself.
// ============================================================================
describe('#5056 regression — the any-one-shared-property bridge stays dead', () => {
- it('WidgetManifestSchema shares leaves with the live graph but is NOT derived from it', () => {
+ it('a 2-of-19 shared-leaf shape shares leaves with the live graph but is NOT derived from it', () => {
// The reverse verification, standing rather than one-shot.
//
// The OLD bridge fired when ANY one property of the candidate was def-equal
@@ -149,10 +172,10 @@ describe('#5056 regression — the any-one-shared-property bridge stays dead', (
// a v17 breaking to tighten a file nothing imports (#4583's "precisely
// validated dead slot").
const { verdict, cloneOverlap } = measureDoors();
- const overlap = cloneOverlap(WidgetManifestSchema);
+ const overlap = cloneOverlap(SHARED_LEAF_2_OF_19);
expect(overlap, 'it DOES share leaves — the old bridge fired on exactly this').toBeGreaterThan(0);
expect(overlap, 'but nothing structural: measured 2/19').toBeLessThan(0.2);
- expect(verdict(WidgetManifestSchema)).toBe('unreachable');
+ expect(verdict(SHARED_LEAF_2_OF_19)).toBe('unreachable');
});
it('the threshold discriminates by SHARE OF SHAPE, not by count of shared keys', () => {
@@ -179,7 +202,8 @@ describe('#5056 regression — the any-one-shared-property bridge stays dead', (
//
// It costs nothing today: this is a synthetic shape, and the smallest real
// `no door` shapes the campaign has measured sit far below the threshold
- // (WidgetManifestSchema at 2/19). It becomes a real hazard the moment a
+ // (`WidgetManifestSchema` at 2/19, before #5055 retired it). It becomes a
+ // real hazard the moment a
// batch measures a SMALL shape (roughly 4 keys or fewer) whose keys are all
// shared leaves — measure `cloneOverlap` and read this pin before trusting
// a `derived-clone` verdict on one. Filed for the campaign as #5828.
diff --git a/packages/spec/src/ui/door-reachability.testkit.ts b/packages/spec/src/ui/door-reachability.testkit.ts
index b5db5416ce..6e5d1708e6 100644
--- a/packages/spec/src/ui/door-reachability.testkit.ts
+++ b/packages/spec/src/ui/door-reachability.testkit.ts
@@ -54,7 +54,9 @@
* leaves scores 1.0 however few they are, and still bridges. No threshold in
* (0, 1] excludes it, because a genuine `.strip()` scores 1.0 too. It costs
* nothing today (the real `no door` shapes measured so far sit far below the
- * threshold — `WidgetManifestSchema` at 2/19), but read `cloneOverlap` and
+ * threshold — `WidgetManifestSchema` at 2/19, until #5055 retired it under
+ * ADR-0049; the ratio survives as a reconstructed fixture in the test), but
+ * read `cloneOverlap` and
* the key count before trusting a `derived-clone` verdict on a SMALL shape.
*/
@@ -118,7 +120,8 @@ export interface DoorMeasurement {
* `ObjectListViewSchema.strip()` at 1.0 — both pinned as the bridge's positive
* control), and 批 16's false positive sits far below (`WidgetManifestSchema`,
* 2 shared keys of 19 — `name` and `label`, both shared LEAVES rather than
- * shared structure, measured 0.105).
+ * shared structure, measured 0.105; the schema itself was retired by #5055 and
+ * the test rebuilds the same 2-of-19 shape to keep the number pinned).
*
* Note the chart family this bridge was originally written for measures
* `direct`, not `derived-clone`: `ChartConfigSchema` is in the graph outright.
diff --git a/packages/spec/src/ui/i18n.test.ts b/packages/spec/src/ui/i18n.test.ts
index 35ebe0d3a6..e6a3136093 100644
--- a/packages/spec/src/ui/i18n.test.ts
+++ b/packages/spec/src/ui/i18n.test.ts
@@ -1,54 +1,15 @@
import { describe, it, expect } from 'vitest';
import { z } from 'zod';
import {
- I18nObjectSchema,
I18nLabelSchema,
AriaPropsSchema,
- PluralRuleSchema,
- NumberFormatSchema,
- DateFormatSchema,
- LocaleConfigSchema,
- type I18nObject,
type I18nLabel,
type AriaProps,
- type PluralRule,
- type LocaleConfig,
} from './i18n.zod';
import { measureDoors } from './door-reachability.testkit';
import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas';
import { PageSchema } from './page.zod';
-describe('I18nObjectSchema', () => {
- it('should accept valid i18n object with key only', () => {
- const obj: I18nObject = {
- key: 'views.task_list.label',
- };
-
- const result = I18nObjectSchema.parse(obj);
- expect(result.key).toBe('views.task_list.label');
- expect(result.defaultValue).toBeUndefined();
- expect(result.params).toBeUndefined();
- });
-
- it('should accept i18n object with all fields', () => {
- const obj: I18nObject = {
- key: 'apps.crm.description',
- defaultValue: 'Sales CRM Application',
- params: { count: 5, name: 'John' },
- };
-
- const result = I18nObjectSchema.parse(obj);
- expect(result.key).toBe('apps.crm.description');
- expect(result.defaultValue).toBe('Sales CRM Application');
- expect(result.params).toEqual({ count: 5, name: 'John' });
- });
-
- it('should reject i18n object without key', () => {
- expect(() => I18nObjectSchema.parse({})).toThrow();
- expect(() => I18nObjectSchema.parse({ defaultValue: 'Test' })).toThrow();
- });
-});
-
describe('I18nLabelSchema', () => {
it('should accept plain string', () => {
const result = I18nLabelSchema.parse('All Active');
@@ -158,97 +119,6 @@ describe('I18n Integration', () => {
expect(() => I18nLabelSchema.parse({ key: 'labels.with_params', params: { count: 10 } })).toThrow();
});
});
-
-describe('PluralRuleSchema', () => {
- it('should accept minimal plural rule', () => {
- const rule: PluralRule = {
- key: 'items.count',
- other: '{count} items',
- };
- expect(() => PluralRuleSchema.parse(rule)).not.toThrow();
- });
- it('should accept full plural rule', () => {
- const rule = PluralRuleSchema.parse({
- key: 'items.count',
- zero: 'No items',
- one: '{count} item',
- two: '{count} items',
- few: '{count} items',
- many: '{count} items',
- other: '{count} items',
- });
- expect(rule.zero).toBe('No items');
- expect(rule.one).toBe('{count} item');
- });
- it('should reject rule without key', () => {
- expect(() => PluralRuleSchema.parse({ other: 'items' })).toThrow();
- });
- it('should reject rule without other', () => {
- expect(() => PluralRuleSchema.parse({ key: 'test' })).toThrow();
- });
-});
-
-describe('NumberFormatSchema', () => {
- it('should accept minimal number format', () => {
- const result = NumberFormatSchema.parse({});
- expect(result.style).toBe('decimal');
- });
- it('should accept currency format', () => {
- const result = NumberFormatSchema.parse({
- style: 'currency',
- currency: 'USD',
- minimumFractionDigits: 2,
- });
- expect(result.currency).toBe('USD');
- });
- it('should accept percent format', () => {
- expect(() => NumberFormatSchema.parse({ style: 'percent' })).not.toThrow();
- });
-});
-
-describe('DateFormatSchema', () => {
- it('should accept empty date format', () => {
- expect(() => DateFormatSchema.parse({})).not.toThrow();
- });
- it('should accept full date format', () => {
- const result = DateFormatSchema.parse({
- dateStyle: 'medium',
- timeStyle: 'short',
- timeZone: 'America/New_York',
- hour12: true,
- });
- expect(result.dateStyle).toBe('medium');
- expect(result.timeZone).toBe('America/New_York');
- });
-});
-
-describe('LocaleConfigSchema', () => {
- it('should accept minimal locale config', () => {
- const result = LocaleConfigSchema.parse({ code: 'en-US' });
- expect(result.code).toBe('en-US');
- expect(result.direction).toBe('ltr');
- });
- it('should accept RTL locale', () => {
- const result = LocaleConfigSchema.parse({ code: 'ar-SA', direction: 'rtl' });
- expect(result.direction).toBe('rtl');
- });
- it('should accept locale with fallback chain', () => {
- const config: z.input = {
- code: 'zh-CN',
- fallbackChain: ['zh-TW', 'en'],
- direction: 'ltr',
- numberFormat: { style: 'decimal', useGrouping: true },
- dateFormat: { dateStyle: 'medium', timeStyle: 'short' },
- };
- const result = LocaleConfigSchema.parse(config);
- expect(result.fallbackChain).toEqual(['zh-TW', 'en']);
- expect(result.numberFormat?.useGrouping).toBe(true);
- });
- it('should reject locale without code', () => {
- expect(() => LocaleConfigSchema.parse({})).toThrow();
- });
-});
-
// ============================================================================
// #4001 批 16 — the SPLIT verdict for this file, both halves pinned.
//
@@ -413,56 +283,3 @@ describe('#4001 批 16 — AriaPropsSchema is closed (the door is real)', () =>
expect(Object.keys(pageShape)).toContain('aria');
});
});
-
-describe('#4001 批 16 — the other five shapes have no authoring door', () => {
- const NO_DOOR: Array<[string, unknown]> = [
- ['I18nObjectSchema', I18nObjectSchema],
- ['PluralRuleSchema', PluralRuleSchema],
- ['NumberFormatSchema', NumberFormatSchema],
- ['DateFormatSchema', DateFormatSchema],
- ['LocaleConfigSchema', LocaleConfigSchema],
- ];
-
- it('measures: AriaProps reachable, the other five not — controls in the same run', () => {
- const { verdict, nodeCount, rootCount } = measureDoors();
- expect(rootCount).toBeGreaterThan(20);
- expect(nodeCount).toBeGreaterThan(1000);
- expect(verdict(PageSchema), 'positive control').toBe('direct');
- expect(verdict(AriaPropsSchema), 'the half of this file that HAS a door').toBe('direct');
- expect(verdict(z.object({ a: z.string() })), 'negative control').toBe('unreachable');
- for (const [name, schema] of NO_DOOR) {
- expect(verdict(schema), `${name} must have no door`).toBe('unreachable');
- }
- });
-
- it('a synthetic carrier flips all five — the verdict is the graph, not the walker', () => {
- const carrier = z.object({
- i18nObject: I18nObjectSchema,
- plural: PluralRuleSchema,
- numberFormat: NumberFormatSchema,
- dateFormat: DateFormatSchema,
- locale: LocaleConfigSchema,
- });
- const { verdict } = measureDoors([carrier]);
- for (const [name, schema] of NO_DOOR) {
- expect(verdict(schema), `${name} must become reachable once something carries it`).toBe('direct');
- }
- });
-
- it('they still accept undeclared keys — this pins "open", not "broken"', () => {
- expect(I18nObjectSchema.safeParse({ key: 'k', notAnI18nKey: 1 }).success).toBe(true);
- expect(PluralRuleSchema.safeParse({ key: 'k', other: 'x', notAPluralForm: 1 }).success).toBe(true);
- expect(NumberFormatSchema.safeParse({ notANumberFormatKey: 1 }).success).toBe(true);
- expect(DateFormatSchema.safeParse({ notADateFormatKey: 1 }).success).toBe(true);
- expect(LocaleConfigSchema.safeParse({ code: 'en-US', notALocaleKey: 1 }).success).toBe(true);
- });
-
- it('`I18nObject.params` stays a record ON PURPOSE — openness there is the contract', () => {
- // The remeasure's standing warning for this file. `params` is an
- // interpolation bag whose key space is whatever the message template names;
- // it is not a site this ratchet could close and must not become one.
- const r = I18nObjectSchema.safeParse({ key: 'items.count', params: { count: 5, anything: 'at all', ok: true } });
- expect(r.success).toBe(true);
- expect((r.data as { params?: Record }).params).toEqual({ count: 5, anything: 'at all', ok: true });
- });
-});
diff --git a/packages/spec/src/ui/i18n.zod.ts b/packages/spec/src/ui/i18n.zod.ts
index aa6d308e93..b7ddad026b 100644
--- a/packages/spec/src/ui/i18n.zod.ts
+++ b/packages/spec/src/ui/i18n.zod.ts
@@ -2,66 +2,56 @@
import { z } from 'zod';
-// ⚠️ #4001 批 16 — this file SPLITS across the ledger's classes. Read before editing.
+// ⚠️ #4001 批 16 measured this file as SPLIT across the ledger's classes, and the
+// split is now resolved in BOTH directions. Read before editing.
//
-// `AriaPropsSchema` is a REAL DOOR and is closed (`strictObject`). Every other
-// shape here is `no door` and must NOT be tightened — see the per-schema notes.
+// KEPT. `AriaPropsSchema` is a REAL DOOR and is closed (`strictObject`): it is
+// carried as `aria:` on ~30 live shapes across six metadata-type roots —
+// `ListViewSchema`, `PageSchema`, `PageComponentSchema`, `ChartConfigSchema`,
+// `ActionSchema`, and 20 SDUI component defs — and a BFS from all 24 roots plus
+// `defineStack` reaches it directly. (`DashboardWidgetSchema` was a seventh
+// carrier when this was measured; its `aria` embed was retired later the same
+// day — #5010, ADR-0049 — because no dashboard renderer applied it. The shape
+// and every carrier above are unaffected.) It was silently stripping: through
+// the `view` root, `aria: { label: 'Accounts', describedBy: 'x' }` parsed CLEAN
+// and returned `aria: {}`, so the accessible name the author wrote simply did
+// not exist. `I18nLabelSchema` is the live label primitive the whole `ui/` tree
+// imports. Both stay, and `i18n.test.ts` pins their survival.
//
-// The split was measured on 2026-08-04, three independent ways with positive AND
-// negative controls in the same run (`i18n.test.ts` pins both halves):
+// REMOVED. `I18nObjectSchema` / `PluralRuleSchema` / `NumberFormatSchema` /
+// `DateFormatSchema` / `LocaleConfigSchema` — per ADR-0049 enforce-or-remove
+// (#5055, maintainer ruling 2026-08-06; window moved to protocol 17 on
+// 2026-08-07). They had no carrier key anywhere, were unreachable in that same
+// BFS, and were never parsed in objectstack / objectui / cloud outside this
+// file's own tests — all three re-measured on `origin/main` immediately before
+// the removal, controls passing in the same run.
//
-// - `AriaPropsSchema` is carried as `aria:` on ~30 live shapes across six
-// metadata-type roots — `ListViewSchema`, `PageSchema`, `PageComponentSchema`,
-// `ChartConfigSchema`, `ActionSchema`, and 20 SDUI component defs — and a BFS
-// from all 24 roots plus `defineStack` reaches it directly. (`DashboardWidgetSchema`
-// was a seventh carrier when this was measured; its `aria` embed was retired
-// later the same day — #5010, ADR-0049 — because no dashboard renderer applied
-// it. The shape and every carrier above are unaffected.)
-// It was silently stripping: through the `view` root,
-// `aria: { label: 'Accounts', describedBy: 'x' }` parsed CLEAN and returned
-// `aria: {}`, so the accessible name the author wrote simply did not exist.
-// - `I18nObjectSchema` / `PluralRuleSchema` / `NumberFormatSchema` /
-// `DateFormatSchema` / `LocaleConfigSchema` have no carrier key anywhere, are
-// unreachable in that same BFS, and are never parsed in `objectstack`,
-// `objectui` or `cloud` outside this file's own tests. `.strict()` is a
-// property of a PARSE; with no parse it enforces nothing and only makes a dead
-// slot look load-bearing (#4583). ADR-0049 enforce-or-remove is #5055.
+// Two things worth keeping straight about what left:
//
-// Note `NumberFormatSchema` / `DateFormatSchema` DO have a carrier
-// (`LocaleConfig.numberFormat` / `.dateFormat`) — but the carrier is itself
-// doorless, so the whole subtree is `no door`, not `no gate`.
+// - `NumberFormatSchema` / `DateFormatSchema` DID have a carrier key
+// (`LocaleConfig.numberFormat` / `.dateFormat`) — but the carrier was itself
+// doorless, so the subtree was `no door` rather than `no gate` and it goes as
+// one subtree. Leaving the two leaf shapes behind would strand exported
+// schemas with no consumer, which reads as a capability to whoever finds them
+// (#3950).
+// - `I18nObjectSchema` was a vocabulary SUPERSEDED BY ITS OWN NEIGHBOUR.
+// `I18nLabelSchema` below says so in prose: translation keys are generated by
+// the framework at registration time from a naming convention, the author
+// writes only the default-language string, and translations live in
+// translation files rather than inline i18n objects. The live translation
+// surface is `system/translation.zod.ts` (tightened in an earlier batch),
+// which uses none of these shapes.
//
-// The `z.record` slots stay open ON PURPOSE and are not sites this ratchet can
-// close: `I18nObject.params` is an interpolation bag whose key space is whatever
-// the message template names. Openness there is the contract.
+// ⚠️ Route 3 of the retirement playbook ("nothing parses it → neither"): with no
+// carrier key there is no shape for a `retiredKey()` tombstone to sit on and no
+// author document for an ADR-0087 D2 conversion to rewrite. The declared record
+// is the D3 `SemanticMigration` `ui-widget-i18n-family-retired` plus
+// `RETIRED_DEFS_BY_MAJOR`. Localisation as authorable protocol metadata returns
+// via the ENFORCE route of ADR-0049 through a new ADR — the formatter that reads
+// a `LocaleConfig` first, the vocabulary second.
-/**
- * I18n Object Schema
- * Structured internationalization label with translation key and parameters.
- *
- * @example
- * ```typescript
- * const label: I18nObject = {
- * key: 'views.task_list.label',
- * defaultValue: 'Task List',
- * params: { count: 5 },
- * };
- * ```
- */
import { lazySchema } from '../shared/lazy-schema';
import { strictObject } from '../shared/strict-object';
-export const I18nObjectSchema = lazySchema(() => z.object({
- /** Translation key (e.g., "views.task_list.label", "apps.crm.description") */
- key: z.string().describe('Translation key (e.g., "views.task_list.label")'),
-
- /** Default value when translation is not available */
- defaultValue: z.string().optional().describe('Fallback value when translation key is not found'),
-
- /** Interpolation parameters for dynamic translations */
- params: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional().describe('Interpolation parameters (e.g., { count: 5 })'),
-}));
-
-export type I18nObject = z.infer;
/**
* I18n Label Schema
@@ -161,133 +151,3 @@ export const AriaPropsSchema = lazySchema(() => strictObject({
}).describe('ARIA accessibility attributes'));
export type AriaProps = z.infer;
-
-/**
- * Plural Rule Schema
- *
- * Defines plural forms for a translation key, following ICU MessageFormat / i18next conventions.
- * Supports zero, one, two, few, many, other forms per CLDR plural rules.
- *
- * @see https://unicode.org/reports/tr35/tr35-numbers.html#Language_Plural_Rules
- *
- * @example
- * ```typescript
- * const plural: PluralRule = {
- * key: 'items.count',
- * zero: 'No items',
- * one: '{count} item',
- * other: '{count} items',
- * };
- * ```
- */
-export const PluralRuleSchema = lazySchema(() => z.object({
- /** Translation key for the plural form */
- key: z.string().describe('Translation key'),
- /** Form for zero quantity */
- zero: z.string().optional().describe('Zero form (e.g., "No items")'),
- /** Form for singular (1) */
- one: z.string().optional().describe('Singular form (e.g., "{count} item")'),
- /** Form for dual (2) — used in Arabic, Welsh, etc. */
- two: z.string().optional().describe('Dual form (e.g., "{count} items" for exactly 2)'),
- /** Form for few (2-4 in Slavic languages) */
- few: z.string().optional().describe('Few form (e.g., for 2-4 in some languages)'),
- /** Form for many (5+ in Slavic languages) */
- many: z.string().optional().describe('Many form (e.g., for 5+ in some languages)'),
- /** Default/fallback form */
- other: z.string().describe('Default plural form (e.g., "{count} items")'),
-}).describe('ICU plural rules for a translation key'));
-
-export type PluralRule = z.infer;
-
-/**
- * Number Format Schema
- *
- * Defines number formatting rules for localization.
- *
- * @example
- * ```typescript
- * const format: NumberFormat = {
- * style: 'currency',
- * currency: 'USD',
- * minimumFractionDigits: 2,
- * };
- * ```
- */
-export const NumberFormatSchema = lazySchema(() => z.object({
- style: z.enum(['decimal', 'currency', 'percent', 'unit']).default('decimal')
- .describe('Number formatting style'),
- currency: z.string().optional().describe('ISO 4217 currency code (e.g., "USD", "EUR")'),
- unit: z.string().optional().describe('Unit for unit formatting (e.g., "kilometer", "liter")'),
- minimumFractionDigits: z.number().optional().describe('Minimum number of fraction digits'),
- maximumFractionDigits: z.number().optional().describe('Maximum number of fraction digits'),
- useGrouping: z.boolean().optional().describe('Whether to use grouping separators (e.g., 1,000)'),
-}).describe('Number formatting rules'));
-
-export type NumberFormat = z.infer;
-/** Post-parse shape of {@link NumberFormat} — defaults applied, transforms run (ADR-0122). */
-export type NumberFormatParsed = z.infer;
-
-/**
- * Date Format Schema
- *
- * Defines date/time formatting rules for localization.
- *
- * @example
- * ```typescript
- * const format: DateFormat = {
- * dateStyle: 'medium',
- * timeStyle: 'short',
- * timeZone: 'America/New_York',
- * };
- * ```
- */
-export const DateFormatSchema = lazySchema(() => z.object({
- dateStyle: z.enum(['full', 'long', 'medium', 'short']).optional()
- .describe('Date display style'),
- timeStyle: z.enum(['full', 'long', 'medium', 'short']).optional()
- .describe('Time display style'),
- timeZone: z.string().optional().describe('IANA time zone (e.g., "America/New_York")'),
- hour12: z.boolean().optional().describe('Use 12-hour format'),
-}).describe('Date/time formatting rules'));
-
-export type DateFormat = z.infer;
-
-/**
- * Locale Configuration Schema
- *
- * Defines a complete locale configuration including language code,
- * fallback chain, and formatting preferences.
- *
- * @example
- * ```typescript
- * const locale: LocaleConfig = {
- * code: 'zh-CN',
- * fallbackChain: ['zh-TW', 'en'],
- * direction: 'ltr',
- * numberFormat: { style: 'decimal', useGrouping: true },
- * dateFormat: { dateStyle: 'medium', timeStyle: 'short' },
- * };
- * ```
- */
-export const LocaleConfigSchema = lazySchema(() => z.object({
- /** BCP 47 language code (e.g., "en-US", "zh-CN", "ar-SA") */
- code: z.string().describe('BCP 47 language code (e.g., "en-US", "zh-CN")'),
-
- /** Ordered fallback chain for missing translations */
- fallbackChain: z.array(z.string()).optional()
- .describe('Fallback language codes in priority order (e.g., ["zh-TW", "en"])'),
-
- /** Text direction */
- direction: z.enum(['ltr', 'rtl']).default('ltr')
- .describe('Text direction: left-to-right or right-to-left'),
-
- /** Default number formatting */
- numberFormat: NumberFormatSchema.optional().describe('Default number formatting rules'),
-
- /** Default date formatting */
- dateFormat: DateFormatSchema.optional().describe('Default date/time formatting rules'),
-}).describe('Locale configuration'));
-
-export type LocaleConfig = z.infer;
-/** Post-parse shape of {@link LocaleConfig} — defaults applied, transforms run (ADR-0122). */
-export type LocaleConfigParsed = z.infer;
diff --git a/packages/spec/src/ui/widget-i18n-retirement.test.ts b/packages/spec/src/ui/widget-i18n-retirement.test.ts
new file mode 100644
index 0000000000..5c06d02f0b
--- /dev/null
+++ b/packages/spec/src/ui/widget-i18n-retirement.test.ts
@@ -0,0 +1,254 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import { describe, it, expect } from 'vitest';
+
+// ─── [#5055] the widget-registration and locale vocabularies are RETIRED ────
+//
+// ADR-0049 enforce-or-remove, maintainer ruling 2026-08-06 (window moved from
+// protocol 18 to 17 on 2026-08-07): `ui/widget.zod.ts`'s five widget shapes and
+// `ui/i18n.zod.ts`'s five doorless shapes are deleted — 10 emitted defs, 20
+// exported names — reference docs with them.
+//
+// The measurement that decided it, re-run on `origin/main` before the removal,
+// with its controls passing in the SAME run:
+//
+// 1. STATIC — nothing under `packages/spec/src` imported `widget.zod` at all
+// (not even the shapes' own siblings), and every live import of `i18n.zod`
+// names `I18nLabelSchema` or `AriaPropsSchema`. So no schema declared a
+// carrier key and no author could write a path that reached these shapes.
+// `field.widget` is a `z.string()` naming a component the RENDERER has
+// registered; it has never referenced `WidgetManifest`.
+// 2. GRAPH — a BFS from all 24 metadata-type roots plus `defineStack`'s
+// `ObjectStackSchema` reached none of them, while `PageSchema` /
+// `ObjectListViewSchema` resolved `direct` and a synthetic carrier flipped
+// every one of them (`door-reachability.testkit.ts`).
+// 3. CALL SITES — zero `.parse()` / `.safeParse()` in objectstack / objectui /
+// cloud outside these files' own unit tests.
+//
+// ## Why route 3, and why there is nothing to tombstone
+//
+// With no carrier key there is no shape on which a `retiredKey()` tombstone
+// could sit, and no author document for an ADR-0087 D2 conversion to rewrite.
+// That is route 3 of the retirement playbook ("nothing parses it → neither"),
+// the shape #4988 used for the ui/ interaction-config family and #4834 for the
+// kernel plugin-runtime family. The declared record is the D3
+// `SemanticMigration` `ui-widget-i18n-family-retired` plus the
+// `RETIRED_DEFS_BY_MAJOR` entries the #4725 manifest-deletion gate reads.
+//
+// `WidgetManifest.performance`'s own tombstone (#3896) is subsumed rather than
+// deleted-in-isolation: it goes with the shape that carried it, which is
+// strictly stronger, because there is no longer a manifest to author it INTO.
+//
+// ## This pin is BIDIRECTIONAL, and the SURVIVAL half carries most of the weight
+//
+// Absence alone is satisfiable by deleting far too much, and here that is not a
+// hypothetical — three of the survivors sit in the two files being emptied:
+//
+// - `FieldWidgetPropsSchema` is the NINTH widget site and was deliberately
+// KEPT. It is a React props contract, not authorable metadata; it never
+// appeared in `authorable-surface/` or `json-schema.manifest/`; and objectui
+// PR #3289 (2026-08-03) gave it a live cross-repo compile-time consumer in
+// `packages/fields/src/__tests__/spec-symbol-batch7.test.ts`, which imports
+// the type as an intentional tripwire. A sweep "finishing widget.zod.ts"
+// would take it and break that consumer.
+// - `AriaPropsSchema` is the one REAL door of `i18n.zod.ts` — carried as
+// `aria:` on ~30 live shapes across six metadata-type roots and closed by
+// #4001 批 16. It was measured in the same batch as the five that left.
+// - `I18nLabelSchema` is the label primitive the whole `ui/` tree imports, and
+// it lives in the file five shapes were removed from.
+//
+// Form follows #4988 / PR #5300: resolved symbol identity over every public
+// entry in `package.json`'s exports map. #4642 established that a compile-time
+// conditional-type pin in this package was a no-op until #5286 (tsconfig
+// excluded `**/*.test.ts`; vitest never enables `typecheck`), so the
+// compiler-API walk with anti-vacuity guards is the load-bearing instrument.
+describe('[#5055] ui/ widget + i18n family retirement', () => {
+ /** The 20 names the ten retired defs exported (10 schema consts + 10 types). */
+ const RETIRED_NAMES = [
+ // widget.zod.ts — the widget-registration vocabulary
+ 'WidgetManifest', 'WidgetManifestSchema',
+ 'WidgetLifecycle', 'WidgetLifecycleSchema',
+ 'WidgetEvent', 'WidgetEventSchema',
+ 'WidgetProperty', 'WidgetPropertySchema',
+ 'WidgetSource', 'WidgetSourceSchema',
+ // i18n.zod.ts — the five doorless shapes
+ 'I18nObject', 'I18nObjectSchema',
+ 'PluralRule', 'PluralRuleSchema',
+ 'NumberFormat', 'NumberFormatSchema',
+ 'DateFormat', 'DateFormatSchema',
+ 'LocaleConfig', 'LocaleConfigSchema',
+ ] as const;
+
+ /** The ADR-0122 parsed aliases that went with them. */
+ const RETIRED_PARSED_ALIASES = [
+ 'WidgetEventParsed',
+ 'WidgetPropertyParsed',
+ 'WidgetSourceParsed',
+ 'WidgetManifestParsed',
+ 'NumberFormatParsed',
+ 'LocaleConfigParsed',
+ ] as const;
+
+ /**
+ * Names that must SURVIVE on `./ui`. The first three are the reason this half
+ * exists: all three live in the two files this retirement empties.
+ */
+ const MUST_SURVIVE = [
+ // The ninth widget site — kept, with a live objectui compile-time consumer.
+ 'FieldWidgetPropsSchema',
+ // i18n.zod.ts's real door (批 16) and its label primitive.
+ 'AriaPropsSchema',
+ 'I18nLabelSchema',
+ // Neighbours a too-wide `ui/` sweep would plausibly take.
+ 'ResponsiveConfigSchema',
+ 'NotificationTypeSchema',
+ 'SharingConfigSchema',
+ 'ThemeSchema',
+ 'PageSchema',
+ 'PageComponentSchema',
+ ] as const;
+
+ it('every retired name has ZERO holders on any public entry; the survivors still stand on ./ui', async () => {
+ const ts = (await import('typescript')).default;
+ const { resolve, dirname } = await import('node:path');
+ const { fileURLToPath } = await import('node:url');
+ const { readFileSync } = await import('node:fs');
+
+ const specDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
+ // Read the entry list from package.json's exports map so a future entry
+ // cannot silently escape the absence assertions below (the PR #5300 form).
+ const pkg = JSON.parse(readFileSync(resolve(specDir, 'package.json'), 'utf8')) as {
+ exports: Record;
+ };
+ const entries: Record = {};
+ for (const sub of Object.keys(pkg.exports)) {
+ if (sub === '.') entries[sub] = resolve(specDir, 'src/index.ts');
+ else if (/^\.\/[a-z-]+$/.test(sub)) entries[sub] = resolve(specDir, `src/${sub.slice(2)}/index.ts`);
+ }
+ // Anti-vacuity: the enumeration must have found the real surface.
+ for (const needed of ['.', './ui', './system', './data', './api']) {
+ expect(Object.keys(entries), `exports map must include ${needed}`).toContain(needed);
+ }
+ expect(Object.keys(entries).length).toBeGreaterThan(10);
+
+ const program = ts.createProgram(Object.values(entries), {
+ module: ts.ModuleKind.ESNext,
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
+ skipLibCheck: true,
+ noEmit: true,
+ });
+ const checker = program.getTypeChecker();
+
+ const exportNamesOf = (sub: string) => {
+ const sf = program.getSourceFile(entries[sub]);
+ const moduleSym = sf && checker.getSymbolAtLocation(sf);
+ // Without this guard a resolution failure makes every absence assertion
+ // pass vacuously — exactly how a gate goes dormant (#4642).
+ expect(moduleSym, `${sub} module symbol must resolve`).toBeTruthy();
+ return checker.getExportsOfModule(moduleSym!).map((s) => s.getName());
+ };
+
+ const byEntry = new Map();
+ for (const sub of Object.keys(entries)) byEntry.set(sub, exportNamesOf(sub));
+
+ // Anti-vacuity: `./ui` is a large, real surface — so `not.toContain` on it
+ // means something.
+ expect(byEntry.get('./ui')!.length, './ui must export a non-trivial surface').toBeGreaterThan(100);
+
+ // ── ABSENCE (every entry, not just ./ui) ──────────────────────────────
+ for (const name of [...RETIRED_NAMES, ...RETIRED_PARSED_ALIASES]) {
+ const holders = [...byEntry.entries()].filter(([, names]) => names.includes(name)).map(([sub]) => sub);
+ expect(holders, `${name} must have zero holders after #5055`).toEqual([]);
+ }
+
+ // ── SURVIVAL (on ./ui, where they live) ───────────────────────────────
+ const uiNames = byEntry.get('./ui')!;
+ for (const name of MUST_SURVIVE) {
+ expect(uiNames, `${name} must SURVIVE this retirement`).toContain(name);
+ }
+ // The surviving widget site's own type aliases, spelled out: ADR-0122 pairs
+ // them, and a half-deletion that took the type but left the schema would
+ // pass the const check above alone.
+ for (const name of ['FieldWidgetProps', 'FieldWidgetPropsParsed']) {
+ expect(uiNames, `${name} must SURVIVE this retirement`).toContain(name);
+ }
+ });
+
+ it('both files survive on disk — this is a SHAPE retirement, not a file deletion', async () => {
+ const fs = await import('node:fs');
+ const path = await import('node:path');
+ const { fileURLToPath } = await import('node:url');
+ const uiRoot = path.dirname(fileURLToPath(import.meta.url));
+
+ // Unlike #4988 (five whole files), both files here keep a live occupant, so
+ // the existence probe runs in the opposite direction. Anti-vacuity comes
+ // from the negative below.
+ for (const f of ['widget.zod.ts', 'i18n.zod.ts']) {
+ expect(fs.existsSync(path.join(uiRoot, f)), `ui/${f} must NOT be deleted`).toBe(true);
+ }
+ expect(fs.existsSync(path.join(uiRoot, 'this-file-does-not-exist.zod.ts'))).toBe(false);
+
+ // The retired declarations must be gone from the sources themselves, not
+ // merely unexported — an unexported `export const` left behind still emits
+ // a def and would show up in the manifest ratchet a release later.
+ const widget = fs.readFileSync(path.join(uiRoot, 'widget.zod.ts'), 'utf-8');
+ const i18n = fs.readFileSync(path.join(uiRoot, 'i18n.zod.ts'), 'utf-8');
+ for (const name of RETIRED_NAMES) {
+ expect(widget, `widget.zod.ts must not declare ${name}`).not.toMatch(
+ new RegExp(`(?:export )?(?:const|type) ${name}\\b`),
+ );
+ expect(i18n, `i18n.zod.ts must not declare ${name}`).not.toMatch(
+ new RegExp(`(?:export )?(?:const|type) ${name}\\b`),
+ );
+ }
+ // …and the survivors ARE still declared there, so the regex above is not
+ // simply failing to match anything.
+ expect(widget).toMatch(/export const FieldWidgetPropsSchema\b/);
+ expect(i18n).toMatch(/export const AriaPropsSchema\b/);
+ expect(i18n).toMatch(/export const I18nLabelSchema\b/);
+ });
+
+ it('runtime namespace agrees with the compiler view', async () => {
+ const ui = await import('./index');
+ for (const name of RETIRED_NAMES) {
+ expect(name in ui, `ui must not export ${name}`).toBe(false);
+ }
+ // Survival at runtime too — the const half of MUST_SURVIVE.
+ for (const name of MUST_SURVIVE) {
+ expect(name in ui, `${name} must SURVIVE at runtime`).toBe(true);
+ }
+ });
+
+ it('the surviving `error` slot is exactly the one objectui pinned to (objectui#3289)', async () => {
+ // The keep is only defensible while the consumer's assertion holds. objectui
+ // asserts `HasKey` and
+ // `Equal`
+ // over an OPTIONAL STRING. Pinning the same three facts here means a change
+ // that would silently break that repo goes red in this one first.
+ const { FieldWidgetPropsSchema } = await import('./widget.zod');
+ const shape = (FieldWidgetPropsSchema as never as { shape: Record }).shape;
+ expect(Object.keys(shape), 'the slot objectui renamed onto').toContain('error');
+ expect(Object.keys(shape), 'the old name must not come back as an alias').not.toContain('errorMessage');
+ expect(FieldWidgetPropsSchema.safeParse({
+ value: 'x', onChange: () => {}, field: { name: 'f', type: 'text' }, error: 'required',
+ }).success).toBe(true);
+ // Optional: a valid field carries no message at all.
+ expect(FieldWidgetPropsSchema.safeParse({
+ value: 'x', onChange: () => {}, field: { name: 'f', type: 'text' },
+ }).success).toBe(true);
+ // …and it is a STRING slot, not a structured error object.
+ expect(FieldWidgetPropsSchema.safeParse({
+ value: 'x', onChange: () => {}, field: { name: 'f', type: 'text' }, error: { message: 'required' },
+ }).success).toBe(false);
+ });
+
+ it('`field.widget` — the key that names a widget for real — is untouched', async () => {
+ // The retirement's blast radius stops here, and this is the assertion that
+ // says so: authors name custom widgets with a STRING on the field, and that
+ // never went through `WidgetManifest`. If this ever goes red, the removal
+ // took something authorable with it.
+ const { FieldSchema } = await import('../data/field.zod');
+ const parsed = FieldSchema.parse({ name: 'rating', type: 'number', widget: 'star_rating' });
+ expect((parsed as { widget?: string }).widget).toBe('star_rating');
+ });
+});
diff --git a/packages/spec/src/ui/widget.test.ts b/packages/spec/src/ui/widget.test.ts
index f115bc32b7..fd4e82a66e 100644
--- a/packages/spec/src/ui/widget.test.ts
+++ b/packages/spec/src/ui/widget.test.ts
@@ -1,14 +1,6 @@
import { describe, it, expect } from 'vitest';
import { z } from 'zod';
import { FieldWidgetPropsSchema, type FieldWidgetProps } from './widget.zod';
-import {
- WidgetManifestSchema,
- WidgetLifecycleSchema,
- WidgetEventSchema,
- WidgetPropertySchema,
- WidgetSourceSchema,
- type WidgetManifest,
-} from './widget.zod';
import { Field } from '../data/field.zod';
import { measureDoors } from './door-reachability.testkit';
import { PageSchema } from './page.zod';
@@ -338,69 +330,42 @@ describe('FieldWidgetPropsSchema', () => {
});
});
-describe('Widget I18n Integration', () => {
- it('should reject i18n object as widget manifest label', () => {
- expect(() => WidgetManifestSchema.parse({
- name: 'i18n_widget',
- label: { key: 'widgets.date_picker', defaultValue: 'Date Picker' },
- })).toThrow();
- });
- it('should reject i18n as widget description', () => {
- expect(() => WidgetManifestSchema.parse({
- name: 'desc_widget',
- label: 'Test Widget',
- description: { key: 'widgets.test.desc', defaultValue: 'A test widget' },
- })).toThrow();
- });
-});
-
-describe('Widget ARIA Integration', () => {
- it('should accept widget manifest with ARIA attributes', () => {
- expect(() => WidgetManifestSchema.parse({
- name: 'accessible_widget',
- label: 'Accessible Widget',
- aria: { ariaLabel: 'Custom date picker widget', role: 'widget' },
- })).not.toThrow();
- });
-});
-
-describe('Widget — retired performance (#3896 close-out)', () => {
- it('REJECTS the retired `performance` and names the live alternative', () => {
- let message = '';
- try {
- WidgetManifestSchema.parse({
- name: 'perf_widget', label: 'Performance Widget', performance: { lazyLoad: true },
- });
- } catch (e) { message = String((e as Error).message); }
- expect(message).toMatch(/virtualScroll/);
- expect(message).toMatch(/#3896/);
- });
-});
-
// ============================================================================
-// #4001 批 16 — the `no door` verdict for this WHOLE FILE, pinned.
+// #5055 — what is LEFT of this file, and why it is left.
+//
+// 批 16 measured nine sites here and #5055 disposed of them under ADR-0049:
+// eight were REMOVED (`WidgetManifest`, `WidgetLifecycle`, `WidgetEvent`,
+// `WidgetProperty`, `WidgetSource` with its three union branches) and one —
+// `FieldWidgetProps` — was KEPT.
+//
+// The keep is the part that needs a pin, because the removal took the other
+// eight with it and a later sweep "finishing widget.zod.ts" is exactly the shape
+// that would take the ninth too. It must not, and the reason is not that this
+// shape is more reachable — it is `unreachable` like the others were:
//
-// This file was scheduled as `authorable (p)` / 9 sites and resolved NEGATIVE.
-// The pin exists because the verdict regresses in one specific way: a later
-// sweep "finishing the ui/ directory" wraps these nine sites in `strictObject`,
-// spends a breaking change, and gates nothing (#4583). Same verdict is recorded
-// in this file's header comment and in the ui/ row of
-// `docs/audits/2026-07-unknown-key-strictness-ledger.md` — the three-places
-// standard, because a row in a table is not where the next person looks.
+// - It is not authorable metadata at all. It never appeared in
+// `authorable-surface/` or `json-schema.manifest/` (its `onChange` is a
+// `z.function()`, so no JSON Schema is emitted), so ADR-0049's question
+// about a declared-but-unenforced AUTHORABLE key never applied to it.
+// - It is a React props contract, and a props contract is enforced by `tsc` in
+// the implementing repo, not by a `.parse()` here. "Zero parse" is its
+// design.
+// - It has a live cross-repo reader, added the day before 批 16 measured:
+// objectui PR #3289 (2026-08-03) renamed `@object-ui/fields`' validation
+// slot onto the spec's `error` with no alias and made the form renderer
+// produce it, and `packages/fields/src/__tests__/spec-symbol-batch7.test.ts`
+// pins `HasKey` against
+// `import type { FieldWidgetProps } from '@objectstack/spec/ui'` — a
+// deliberate tripwire: "the day the spec stops exporting `FieldWidgetProps`,
+// this file stops compiling and the rename's reason is up for re-triage".
//
-// ADR-0049 enforce-or-remove for these shapes is #5055.
+// So the door measurement below is kept for the surviving shape (an unreachable
+// verdict is still the truth about it, and the controls keep the walker honest),
+// but `unreachable` is NOT the retirement trigger for this one. Re-measure the
+// objectui consumer before touching it.
// ============================================================================
-describe('#4001 批 16 — widget.zod.ts has no authoring door', () => {
- const SHAPES: Array<[string, unknown]> = [
- ['WidgetManifestSchema', WidgetManifestSchema],
- ['WidgetLifecycleSchema', WidgetLifecycleSchema],
- ['WidgetEventSchema', WidgetEventSchema],
- ['WidgetPropertySchema', WidgetPropertySchema],
- ['WidgetSourceSchema', WidgetSourceSchema],
- ['FieldWidgetPropsSchema', FieldWidgetPropsSchema],
- ];
-
- it('is unreachable from all 24 metadata-type roots and defineStack', () => {
+describe('#5055 — the one surviving shape, and the eight that left', () => {
+ it('FieldWidgetPropsSchema is still unreachable — and that is not a reason to retire it', () => {
const { verdict, nodeCount, rootCount } = measureDoors();
// Controls FIRST, in the same run. An empty result and a broken walker
@@ -411,45 +376,22 @@ describe('#4001 批 16 — widget.zod.ts has no authoring door', () => {
expect(verdict(ObjectListViewSchema), 'positive control').toBe('direct');
expect(verdict(z.object({ a: z.string() })), 'negative control').toBe('unreachable');
- for (const [name, schema] of SHAPES) {
- expect(verdict(schema), `${name} must have no door`).toBe('unreachable');
- }
+ expect(verdict(FieldWidgetPropsSchema), 'a props contract has no authoring door, by design').toBe('unreachable');
});
- it('a synthetic carrier flips every one of them — the verdict is the graph, not the walker', () => {
- // Without this the assertion above is satisfiable by a walker that reaches
- // nothing at all. 批 15 shipped exactly that shape of vacuous pin once.
- const carrier = z.object({
- manifest: WidgetManifestSchema,
- lifecycle: WidgetLifecycleSchema,
- event: WidgetEventSchema,
- property: WidgetPropertySchema,
- source: WidgetSourceSchema,
- props: FieldWidgetPropsSchema,
- });
+ it('a synthetic carrier flips it — the verdict is the graph, not the walker', () => {
+ const carrier = z.object({ props: FieldWidgetPropsSchema });
const { verdict } = measureDoors([carrier]);
- for (const [name, schema] of SHAPES) {
- expect(verdict(schema), `${name} must become reachable once something carries it`).toBe('direct');
- }
- });
-
- it('#5056 — the OLD any-one-shared-property bridge would have called this file reachable', () => {
- // The regression pin for the instrument defect this batch found. zod's
- // `.describe()` returns a clone sharing the original `_zod.def`, so
- // `WidgetManifestSchema.name` (a described SnakeCaseIdentifierSchema) and
- // `.label` (a described I18nLabelSchema) are def-identical to the same
- // leaves on live schemas. Two keys out of twenty is a coincidence, not a
- // derivation — assert the OVERLAP is low, so a future edit that reinstates
- // the any-property bridge cannot pass this file off as live surface.
- const { cloneOverlap } = measureDoors();
- expect(cloneOverlap(WidgetManifestSchema)).toBeGreaterThan(0); // it DOES share leaves…
- expect(cloneOverlap(WidgetManifestSchema)).toBeLessThan(0.2); // …but nothing structural
+ expect(verdict(FieldWidgetPropsSchema)).toBe('direct');
});
- it('the shapes still accept their own vocabulary — this pins "open", not "broken"', () => {
- expect(WidgetLifecycleSchema.safeParse({ onMount: 'x', notAHook: 1 }).success).toBe(true);
- expect(WidgetEventSchema.safeParse({ name: 'e', notAnEventKey: 1 }).success).toBe(true);
- expect(WidgetPropertySchema.safeParse({ name: 'p', type: 'string', notAPropKey: 1 }).success).toBe(true);
- expect(WidgetManifestSchema.safeParse({ name: 'w_one', label: 'W', notAManifestKey: 1 }).success).toBe(true);
+ it('the shape still accepts its own vocabulary — this pins "open", not "broken"', () => {
+ const ok = FieldWidgetPropsSchema.safeParse({
+ value: 'x',
+ onChange: () => {},
+ field: { name: 'f', type: 'text' },
+ notAPropsKey: 1,
+ });
+ expect(ok.success).toBe(true);
});
});
diff --git a/packages/spec/src/ui/widget.zod.ts b/packages/spec/src/ui/widget.zod.ts
index 6ad2b98b03..8dc9355824 100644
--- a/packages/spec/src/ui/widget.zod.ts
+++ b/packages/spec/src/ui/widget.zod.ts
@@ -2,437 +2,83 @@
import { z } from 'zod';
import { FieldSchema } from '../data/field.zod';
-import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod';
-import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod';
-import { retiredKey } from '../shared/retired-key';
+import { lazySchema } from '../shared/lazy-schema';
-// ⛔ #4001 批 16 — EVERY shape in this file is `no door`. Do NOT `.strict()` them.
+// ⛔ This file used to declare a WIDGET-REGISTRATION vocabulary as well. It no
+// longer does, and the two halves are worth telling apart before you add to it.
//
-// The ledger scheduled this file as `authorable (p)` / 9 sites. Resolving the
-// `(p)` found no authoring door at all, measured three independent ways on
-// 2026-08-04, with positive AND negative controls in the same run:
+// `WidgetManifestSchema` / `WidgetLifecycleSchema` / `WidgetEventSchema` /
+// `WidgetPropertySchema` / `WidgetSourceSchema` (with its three `npm` / `remote`
+// / `inline` branches) were REMOVED per ADR-0049 enforce-or-remove (#5055,
+// maintainer ruling 2026-08-06, window moved to protocol 17 on 2026-08-07).
+// Together they were 8 of the 9 sites #4001 批 16 measured in this file, and the
+// measurement — re-run on `origin/main` immediately before the removal, controls
+// passing in the same run — said the same three things every time:
//
-// 1. **No carrier key.** Nothing under `packages/spec/src` imports this module
-// except the `ui/index.ts` barrel, so no schema anywhere declares a key
-// whose value is a widget shape. `field.widget` is a `z.string()` naming a
-// registered *component*; it has never referenced `WidgetManifest`.
-// 2. **Unreachable.** A BFS over this build's in-memory Zod graph from all 24
-// metadata-type roots plus `defineStack`'s `ObjectStackSchema` (4 766 nodes)
-// reaches none of them, while `PageSchema` / `ObjectListViewSchema` resolve
-// in the same run and a synthetic carrier flips every one of them to
-// reachable — so the verdict is a fact about the graph, not a broken walker.
-// 3. **Never parsed.** No `.parse()` / `.safeParse()` on any of these exists in
-// `objectstack`, `objectui` or `cloud` outside this file's own unit tests.
-// objectui re-exports the inferred TYPES only, under different names
-// (`RuntimeWidgetManifest` / `FieldWidgetComponentProps`, #4115 / #3161).
+// 1. **No carrier key.** Nothing under `packages/spec/src` imported this module
+// except the `ui/index.ts` barrel, so no schema anywhere declared a key
+// whose value was a widget shape. `field.widget` is a `z.string()` naming a
+// REGISTERED COMPONENT and has never referenced `WidgetManifest`.
+// 2. **Unreachable.** A BFS from all 24 metadata-type roots plus `defineStack`'s
+// `ObjectStackSchema` reached none of them, while `PageSchema` /
+// `ObjectListViewSchema` resolved `direct` in the same run and a synthetic
+// carrier flipped every one of them to reachable.
+// 3. **Zero parse.** No `.parse()` / `.safeParse()` on any of them in
+// objectstack / objectui / cloud outside this file's own unit tests.
//
-// `.strict()` is a property of a PARSE. With no parse it enforces nothing and
-// only makes a dead slot look load-bearing — "a precisely validated dead slot,
-// the more convincing lie" (#4583). The live question here is ADR-0049
-// enforce-or-remove, filed as #5055 (same class as #4988), NOT this ratchet.
+// So the published vocabulary described a widget-registration capability the
+// platform does not have: a manifest nobody could author, lifecycle hooks
+// nobody would run, an implementation-source union nothing would load. That is
+// the #3950 shape at its most inviting to an AI author (ADR-0033). objectui's
+// widget registry has ALWAYS had its own runtime manifest — `RuntimeWidgetManifest`
+// / `RuntimeWidgetSource` in `@object-ui/types` (objectui#3161, #4115) — and it
+// models different keys (`source`, `defaultProps`, `inputs`, `isContainer`,
+// `capabilities`); it never derived from these.
//
-// ⚠️ The campaign's own BFS reported `WidgetManifestSchema` as REACHABLE on the
-// first run. That was a false positive in the walker's derived-clone bridge, not
-// a door: zod's `.describe()` returns a clone that SHARES the original `_zod.def`
-// object, so `WidgetManifestSchema.name` (a described `SnakeCaseIdentifierSchema`)
-// and `.label` (a described `I18nLabelSchema`) are def-identical to the same
-// leaves on live schemas, and a bridge that fires on ANY one shared property
-// under a shared name links two unrelated shapes. Filed as #5056; `widget.test.ts`
-// pins the corrected (whole-shape overlap) form. Same verdict recorded in the
-// ui/ row of `docs/audits/2026-07-unknown-key-strictness-ledger.md`.
-
-/**
- * Widget Lifecycle Hooks Schema
- *
- * Defines lifecycle callbacks for custom widgets inspired by Web Components and React.
- * These hooks allow widgets to perform initialization, cleanup, and respond to changes.
- *
- * @see https://developer.mozilla.org/en-US/docs/Web/API/Web_components
- * @see https://react.dev/reference/react/Component#component-lifecycle
- *
- * @example
- * ```typescript
- * const widget = {
- * lifecycle: {
- * onMount: "console.log('Widget mounted')",
- * onUpdate: "if (prevProps.value !== props.value) { updateUI() }",
- * onUnmount: "cleanup()",
- * onValidate: "return value.length > 0 ? null : 'Required field'"
- * }
- * }
- * ```
- */
-import { lazySchema } from '../shared/lazy-schema';
-export const WidgetLifecycleSchema = lazySchema(() => z.object({
- /**
- * Called when widget is mounted/rendered for the first time
- * Use for initialization, setting up event listeners, loading data, etc.
- *
- * @example "initializeDatePicker(); loadOptions();"
- */
- onMount: z.string().optional().describe('Initialization code when widget mounts'),
-
- /**
- * Called when widget props change
- * Receives previous props for comparison
- *
- * @example "if (prevProps.value !== props.value) { updateDisplay() }"
- */
- onUpdate: z.string().optional().describe('Code to run when props change'),
-
- /**
- * Called when widget is about to be removed from DOM
- * Use for cleanup, removing event listeners, canceling timers, etc.
- *
- * @example "destroyDatePicker(); cancelPendingRequests();"
- */
- onUnmount: z.string().optional().describe('Cleanup code when widget unmounts'),
-
- /**
- * Custom validation logic for this widget
- * Should return error message string if invalid, null/undefined if valid
- *
- * @example "return value && value.length >= 10 ? null : 'Minimum 10 characters'"
- */
- onValidate: z.string().optional().describe('Custom validation logic'),
-
- /**
- * Called when widget receives focus
- *
- * @example "highlightField(); logFocusEvent();"
- */
- onFocus: z.string().optional().describe('Code to run on focus'),
-
- /**
- * Called when widget loses focus
- *
- * @example "validateField(); saveFieldState();"
- */
- onBlur: z.string().optional().describe('Code to run on blur'),
-
- /**
- * Called on any error in the widget
- *
- * @example "logError(error); showErrorNotification();"
- */
- onError: z.string().optional().describe('Error handling code'),
-}));
-
-export type WidgetLifecycle = z.infer;
-
-/**
- * Widget Event Schema
- *
- * Defines custom events that widgets can emit, inspired by DOM Events and Lightning Web Components.
- *
- * @see https://developer.mozilla.org/en-US/docs/Web/Events/Creating_and_triggering_events
- * @see https://developer.salesforce.com/docs/component-library/documentation/en/lwc/lwc.events
- *
- * @example
- * ```typescript
- * const searchEvent = {
- * name: 'search',
- * bubbles: true,
- * cancelable: false,
- * payload: {
- * query: 'string',
- * filters: 'object'
- * }
- * }
- * ```
- */
-export const WidgetEventSchema = lazySchema(() => z.object({
- /**
- * Event name
- * Should be lowercase, dash-separated for consistency
- *
- * @example "value-change", "item-selected", "search-complete"
- */
- name: z.string().describe('Event name'),
-
- /**
- * Event label for documentation
- */
- label: I18nLabelSchema.optional().describe('Human-readable event label'),
-
- /**
- * Event description
- */
- description: I18nLabelSchema.optional().describe('Event description and usage'),
-
- /**
- * Whether event bubbles up through the DOM hierarchy
- *
- * @default false
- */
- bubbles: z.boolean().default(false).describe('Whether event bubbles'),
-
- /**
- * Whether event can be cancelled
- *
- * @default false
- */
- cancelable: z.boolean().default(false).describe('Whether event is cancelable'),
-
- /**
- * Event payload schema
- * Defines the data structure sent with the event
- *
- * @example { userId: 'string', timestamp: 'number' }
- */
- payload: z.record(z.string(), z.unknown()).optional().describe('Event payload schema'),
-}));
-
-export type WidgetEvent = z.infer;
-/** Post-parse shape of {@link WidgetEvent} — defaults applied, transforms run (ADR-0122). */
-export type WidgetEventParsed = z.infer;
-
-/**
- * Widget Property Definition Schema
- *
- * Defines the contract for widget configuration properties.
- * Inspired by React PropTypes and Web Component attributes.
- *
- * @see https://react.dev/reference/react/Component#static-proptypes
- * @see https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements
- *
- * @example
- * ```typescript
- * const widgetProps = {
- * maxLength: {
- * type: 'number',
- * required: false,
- * default: 100,
- * description: 'Maximum input length'
- * }
- * }
- * ```
- */
-export const WidgetPropertySchema = lazySchema(() => z.object({
- /**
- * Property name
- * Should be camelCase following ObjectStack conventions
- */
- name: z.string().describe('Property name (camelCase)'),
-
- /**
- * Property label for UI
- */
- label: I18nLabelSchema.optional().describe('Human-readable label'),
-
- /**
- * Property data type
- *
- * @example "string", "number", "boolean", "array", "object", "function"
- */
- type: z.enum(['string', 'number', 'boolean', 'array', 'object', 'function', 'any'])
- .describe('TypeScript type'),
-
- /**
- * Whether property is required
- *
- * @default false
- */
- required: z.boolean().default(false).describe('Whether property is required'),
-
- /**
- * Default value for the property
- */
- default: z.unknown().optional().describe('Default value'),
-
- /**
- * Property description
- */
- description: I18nLabelSchema.optional().describe('Property description'),
-
- /**
- * Property validation schema
- * Can include min/max, regex, enum values, etc.
- */
- validation: z.record(z.string(), z.unknown()).optional().describe('Validation rules'),
-
- /**
- * Property category for grouping in UI
- */
- category: z.string().optional().describe('Property category'),
-}));
-
-export type WidgetProperty = z.infer;
-/** Post-parse shape of {@link WidgetProperty} — defaults applied, transforms run (ADR-0122). */
-export type WidgetPropertyParsed = z.infer;
-
-/**
- * Widget Manifest Schema
- *
- * Complete definition for a custom widget including metadata, lifecycle, events, and props.
- * This is used for widget registration and discovery.
- *
- * @example
- * ```typescript
- * const customWidget = {
- * name: 'custom_date_picker',
- * label: 'Custom Date Picker',
- * version: '1.0.0',
- * author: 'Company Name',
- * fieldTypes: ['date', 'datetime'],
- * lifecycle: { ... },
- * events: [ ... ],
- * properties: [ ... ]
- * }
- * ```
- */
-/**
- * Widget Source Schema
- * Defines how the widget code is loaded.
- */
-export const WidgetSourceSchema = lazySchema(() => z.discriminatedUnion('type', [
- // NPM Registry (standard)
- z.object({
- type: z.literal('npm'),
- packageName: z.string().describe('NPM package name'),
- version: z.string().default('latest'),
- exportName: z.string().optional().describe('Named export (default: default)'),
- }),
- // Module Federation (Remote)
- z.object({
- type: z.literal('remote'),
- url: z.string().url().describe('Remote entry URL (.js)'),
- moduleName: z.string().describe('Exposed module name'),
- scope: z.string().describe('Remote scope name'),
- }),
- // Inline Code (Simple scripts)
- z.object({
- type: z.literal('inline'),
- code: z.string().describe('JavaScript code body'),
- }),
-]));
-
-export type WidgetSource = z.infer;
-/** Post-parse shape of {@link WidgetSource} — defaults applied, transforms run (ADR-0122). */
-export type WidgetSourceParsed = z.infer;
-
-export const WidgetManifestSchema = lazySchema(() => z.object({
- /**
- * Widget identifier (snake_case)
- */
- name: SnakeCaseIdentifierSchema
- .describe('Widget identifier (snake_case)'),
-
- /**
- * Human-readable widget name
- */
- label: I18nLabelSchema.describe('Widget display name'),
-
- /**
- * Widget description
- */
- description: I18nLabelSchema.optional().describe('Widget description'),
-
- /**
- * Widget version (semver)
- */
- version: z.string().optional().describe('Widget version (semver)'),
-
- /**
- * Widget author/organization
- */
- author: z.string().optional().describe('Widget author'),
-
- /**
- * Icon name or URL
- */
- icon: z.string().optional().describe('Widget icon'),
-
- /**
- * Field types this widget supports
- *
- * @example ["text", "email", "url"]
- */
- fieldTypes: z.array(z.string()).optional().describe('Supported field types'),
-
- /**
- * Widget category for organization
- */
- category: z.enum(['input', 'display', 'picker', 'editor', 'custom'])
- .default('custom')
- .describe('Widget category'),
-
- /**
- * Widget lifecycle hooks
- */
- lifecycle: WidgetLifecycleSchema.optional().describe('Lifecycle hooks'),
-
- /**
- * Custom events this widget emits
- */
- events: z.array(WidgetEventSchema).optional().describe('Custom events'),
-
- /**
- * Widget configuration properties
- */
- properties: z.array(WidgetPropertySchema).optional().describe('Configuration properties'),
-
- /**
- * Widget implementation
- * Defines how to load the widget code
- */
- implementation: WidgetSourceSchema.optional().describe('Widget implementation source'),
-
- /**
- * Widget dependencies
- * External libraries or scripts needed
- */
- dependencies: z.array(z.object({
- name: z.string(),
- version: z.string().optional(),
- url: z.string().url().optional(),
- })).optional().describe('Widget dependencies'),
-
- /**
- * Widget screenshots for showcase
- */
- screenshots: z.array(z.string().url()).optional().describe('Screenshot URLs'),
-
- /**
- * Widget documentation URL
- */
- documentation: z.string().url().optional().describe('Documentation URL'),
-
- /**
- * License information
- */
- license: z.string().optional().describe('License (SPDX identifier)'),
-
- /**
- * Tags for discovery
- */
- tags: z.array(z.string()).optional().describe('Tags for categorization'),
-
- /** ARIA accessibility attributes */
- aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'),
-
- /** Performance optimization settings */
- // `performance` REMOVED (#3896 audit close-out): call-graph closed across
- // both repos — zero readers (objectui's virtual scrolling reads the LIVE
- // top-level `virtualScroll` key, never performance.virtualScroll).
- performance: retiredKey(
- '`widget.performance` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — ' +
- 'no renderer or runtime ever read it. Delete the key. Virtual scrolling is the live ' +
- 'top-level `virtualScroll` on list-shaped views.',
- ),
-}));
-
-export type WidgetManifest = z.infer;
-/** Post-parse shape of {@link WidgetManifest} — defaults applied, transforms run (ADR-0122). */
-export type WidgetManifestParsed = z.infer;
+// ⚠️ Route 3 of the retirement playbook ("nothing parses it → neither"): with no
+// carrier key there is no shape for a `retiredKey()` tombstone to sit on and no
+// author document for a D2 conversion to rewrite. `WidgetManifest.performance`'s
+// own tombstone (#3896) went with the shape that carried it — strictly stronger
+// than the tombstone, since there is no longer a manifest to author it INTO. The
+// declared record is the D3 `SemanticMigration` `ui-widget-i18n-family-retired`
+// plus `RETIRED_DEFS_BY_MAJOR`. Same shape as #4988 (batch 13's 22 sites) and
+// #4834 (kernel plugin-runtime family).
+//
+// Widget registration as protocol metadata returns via the ENFORCE route of
+// ADR-0049 through a new ADR — registry and loader first, vocabulary second.
+//
+// ─────────────────────────────────────────────────────────────────────────────
+// `FieldWidgetPropsSchema` SURVIVES, and it is not an oversight — it is the one
+// site of the nine whose evidence differs (#5055 comment, 2026-08-06 11:40).
+//
+// It is not authorable metadata at all: it never appears in
+// `authorable-surface/` or `json-schema.manifest/` (its `onChange` is a
+// `z.function()`, so no JSON Schema is emitted), and it is a REACT PROPS
+// CONTRACT — a thing that is implemented by a component, not parsed from a
+// document. "Zero `.parse()`" is its design, not its defect, so the
+// enforce-or-remove question ADR-0049 asks of an unenforced authorable key does
+// not bind it.
+//
+// And it has a live reader. objectui PR #3289 (merged 2026-08-03, one day before
+// 批 16's measurement) resolved objectui#3222 in the direction the contract
+// points: `@object-ui/fields` renamed its validation slot from `errorMessage` to
+// the spec's `error` with no alias, the form renderer started producing it, and
+// `packages/fields/src/__tests__/spec-symbol-batch7.test.ts` pinned all of it
+// against `import type { FieldWidgetProps } from '@objectstack/spec/ui'` —
+// deliberately, so that "the day the spec stops exporting `FieldWidgetProps`,
+// this file stops compiling and the rename's reason is up for re-triage". That
+// is a cross-repo, compile-time consumer of this exact shape, and `tsc` is where
+// a props contract is enforced. Verified on objectui `origin/main` 2026-08-07.
+//
+// Before retiring it, re-measure THAT — not this file's parse count.
/**
* Field Widget Props Schema
- *
+ *
* This defines the contract for custom field components and plugin UI extensions.
* Third-party developers use this interface to build custom field widgets that integrate
* seamlessly with the ObjectStack UI system.
- *
+ *
* @example
* // Custom widget implementation
* function CustomDatePicker(props: FieldWidgetProps) {
@@ -450,7 +96,7 @@ export const FieldWidgetPropsSchema = lazySchema(() => z.object({
/**
* Callback function to update the field value.
* Should be called when user interaction changes the value.
- *
+ *
* @param newValue - The new value to set
*/
onChange: z.function()
diff --git a/packages/spec/test-typecheck-debt.json b/packages/spec/test-typecheck-debt.json
index 7f606463d4..3840710b77 100644
--- a/packages/spec/test-typecheck-debt.json
+++ b/packages/spec/test-typecheck-debt.json
@@ -79,6 +79,6 @@
"src/ui/report.test.ts": 3,
"src/ui/theme.test.ts": 6,
"src/ui/view.test.ts": 79,
- "src/ui/widget.test.ts": 4
+ "src/ui/widget.test.ts": 3
}
}
diff --git a/packages/spec/variant-docs.json b/packages/spec/variant-docs.json
index 4b9dc04a09..90bfe79bd3 100644
--- a/packages/spec/variant-docs.json
+++ b/packages/spec/variant-docs.json
@@ -22,6 +22,10 @@
" - widget implementation became GOVERNED. protocol/objectui/widget-contract.mdx has",
" documented all three variants in a `Widget Source` section the whole time; the gate",
" could not see them because its matcher was blind to YAML. Fixed in the matcher.",
+ " (Since RETIRED: #5055 removed `WidgetSourceSchema` under ADR-0049 — no carrier key,",
+ " nothing ever parsed it — so the union is gone and its entry went with it, in the same",
+ " PR. An entry whose union has left the source is a STALE ledger row, which is the",
+ " #5552 failure mode; `pnpm check:variant-docs` is the gate that says so.)",
" - tenant isolation and settings-manifest handler became NOT-AUTHORABLE. Both reasons",
" already said the words (`operator-set`, `consumed by Setup/Studio`); the label just",
" disagreed with them.",
@@ -71,14 +75,6 @@
"content/docs/protocol/knowledge.mdx"
]
},
- {
- "key": "type:inline|npm|remote",
- "label": "widget implementation",
- "docs": [
- "content/docs/protocol/objectui/widget-contract.mdx"
- ],
- "note": "Was exempt as generated-reference-only until the #4001 audit: the page's `Widget Source` section documents all three variants, but in YAML examples, which the coverage matcher could not see. The exemption was recording a gate blind-spot as a doc gap."
- },
{
"key": "type:cron|interval|once",
"label": "job schedule",
diff --git a/skills/objectstack-data/references/_index.md b/skills/objectstack-data/references/_index.md
index c5be60ce17..d6c6bd8f6d 100644
--- a/skills/objectstack-data/references/_index.md
+++ b/skills/objectstack-data/references/_index.md
@@ -42,7 +42,7 @@ from `node_modules` — there is no local copy in the skill bundle.
- `node_modules/@objectstack/spec/src/ui/action.zod.ts` — Action Parameter Schema
- `node_modules/@objectstack/spec/src/ui/bulk-action.zod.ts` — Bulk Action Schemas
- `node_modules/@objectstack/spec/src/ui/chart.zod.ts` — Unified Chart Type Taxonomy
-- `node_modules/@objectstack/spec/src/ui/i18n.zod.ts` — I18n Object Schema
+- `node_modules/@objectstack/spec/src/ui/i18n.zod.ts` — I18n Label Schema
- `node_modules/@objectstack/spec/src/ui/sharing.zod.ts` — Sharing & Embedding Protocol
- `node_modules/@objectstack/spec/src/ui/view.zod.ts` — HTTP Method Enum & HTTP Request Schema
diff --git a/skills/objectstack-i18n/references/_index.md b/skills/objectstack-i18n/references/_index.md
index dedad513a2..3112d6ba0e 100644
--- a/skills/objectstack-i18n/references/_index.md
+++ b/skills/objectstack-i18n/references/_index.md
@@ -10,7 +10,7 @@ from `node_modules` — there is no local copy in the skill bundle.
## Core schemas
- `node_modules/@objectstack/spec/src/system/translation.zod.ts` — Shared history sentence for every shape in this file (#4001).
-- `node_modules/@objectstack/spec/src/ui/i18n.zod.ts` — I18n Object Schema
+- `node_modules/@objectstack/spec/src/ui/i18n.zod.ts` — I18n Label Schema
## Transitive dependencies
diff --git a/skills/objectstack-platform/references/_index.md b/skills/objectstack-platform/references/_index.md
index 6bdb8ec5a9..fa238aa7d3 100644
--- a/skills/objectstack-platform/references/_index.md
+++ b/skills/objectstack-platform/references/_index.md
@@ -43,7 +43,7 @@ from `node_modules` — there is no local copy in the skill bundle.
- `node_modules/@objectstack/spec/src/system/tenant.zod.ts` — Tenant Schema (Multi-Tenant Architecture)
- `node_modules/@objectstack/spec/src/ui/action.zod.ts` — Action Parameter Schema
- `node_modules/@objectstack/spec/src/ui/app.zod.ts` — Base Navigation Item Schema
-- `node_modules/@objectstack/spec/src/ui/i18n.zod.ts` — I18n Object Schema
+- `node_modules/@objectstack/spec/src/ui/i18n.zod.ts` — I18n Label Schema
## How to read these
diff --git a/skills/objectstack-ui/references/_index.md b/skills/objectstack-ui/references/_index.md
index 18be463b71..8f7366e140 100644
--- a/skills/objectstack-ui/references/_index.md
+++ b/skills/objectstack-ui/references/_index.md
@@ -19,7 +19,7 @@ from `node_modules` — there is no local copy in the skill bundle.
- `node_modules/@objectstack/spec/src/ui/report.zod.ts` — Report Type Enum
- `node_modules/@objectstack/spec/src/ui/theme.zod.ts` — Color Palette Schema
- `node_modules/@objectstack/spec/src/ui/view.zod.ts` — HTTP Method Enum & HTTP Request Schema
-- `node_modules/@objectstack/spec/src/ui/widget.zod.ts` — Widget Lifecycle Hooks Schema
+- `node_modules/@objectstack/spec/src/ui/widget.zod.ts` — Field Widget Props Schema
## Transitive dependencies
@@ -36,7 +36,7 @@ from `node_modules` — there is no local copy in the skill bundle.
- `node_modules/@objectstack/spec/src/shared/protection.zod.ts` — Package-level metadata protection (ADR-0010 §3.7 — Phase 4.3)
- `node_modules/@objectstack/spec/src/shared/suggestions.zod.ts` — "Did you mean?" Suggestion Utilities
- `node_modules/@objectstack/spec/src/ui/bulk-action.zod.ts` — Bulk Action Schemas
-- `node_modules/@objectstack/spec/src/ui/i18n.zod.ts` — I18n Object Schema
+- `node_modules/@objectstack/spec/src/ui/i18n.zod.ts` — I18n Label Schema
- `node_modules/@objectstack/spec/src/ui/responsive.zod.ts` — Breakpoint Name Enum
- `node_modules/@objectstack/spec/src/ui/sharing.zod.ts` — Sharing & Embedding Protocol