|
| 1 | +# Audit: `ObjectViewSchema` / `DetailViewSchema` vs `@objectstack/spec` (2026-07) |
| 2 | + |
| 3 | +**Scope**: the two view schemas #2231 never touched — `ObjectViewSchema` |
| 4 | +(`packages/types/src/zod/objectql.zod.ts:189-208`) and `DetailViewSchema` |
| 5 | +(`packages/types/src/zod/views.zod.ts:89-122`). |
| 6 | +**Method**: full field dump of both schemas (zod ∪ TS interface), a read-site sweep of |
| 7 | +every field across `packages/` and `apps/`, and a field-by-field mapping against |
| 8 | +`@objectstack/spec` at `16.1.0`. |
| 9 | +**Deliverable**: a per-field verdict — **promote-to-spec** / **keep-local** / **drop** — |
| 10 | +and an answer to the question #2890 scope B actually asks: *is a spec derivation even the |
| 11 | +right shape for these two?* |
| 12 | + |
| 13 | +For `ListViewSchema` the answer was yes, and #2231 phases 1–3 executed it. For these two |
| 14 | +the answer is **no for both, for different reasons**. Neither should be derived. |
| 15 | + |
| 16 | +--- |
| 17 | + |
| 18 | +## Summary |
| 19 | + |
| 20 | +| | `ObjectViewSchema` | `DetailViewSchema` | |
| 21 | +| --- | --- | --- | |
| 22 | +| Declared fields (zod ∪ TS) | 25 | 37 | |
| 23 | +| Declared in zod | 13 | 23 | |
| 24 | +| Declared in TS only | 12 | 14 | |
| 25 | +| **READ** by a renderer | 22 | 33 | |
| 26 | +| **WRITTEN-ONLY** (produced, never read) | 2 | 1 | |
| 27 | +| **DEAD** (neither) | 1 | 2 | |
| 28 | +| Read but declared **nowhere** | **28** | 2 | |
| 29 | +| Fields with a first-party runtime producer | 13 | 11 | |
| 30 | +| Nearest spec counterpart | `ViewSchema` (the ADR-0047 **container**) — *not* `ObjectListViewSchema` | the `record:*` page-component family — *not* `FormViewSchema` | |
| 31 | +| Verdict | **Restructure, don't derive** | **Freeze and retire, don't derive** | |
| 32 | + |
| 33 | +Two facts sit under everything below and are worth stating once: |
| 34 | + |
| 35 | +1. **Neither schema is parsed at runtime, anywhere.** `DetailViewSchema.safeParse` appears |
| 36 | + only in `packages/types/src/__tests__/phase2-schemas.test.ts:527`; `ObjectViewSchema` |
| 37 | + is never parsed at all. `ObjectQLComponentSchema` and `ViewComponentSchema` are |
| 38 | + re-exported but never consumed. This is the same finding that redirected #2890 scope A |
| 39 | + away from a `z.preprocess` fold. |
| 40 | +2. **`BaseSchema` is `.passthrough()`** (`packages/types/src/zod/base.zod.ts:141`), so |
| 41 | + undeclared keys survive validation untouched. Combined with (1), the zod shapes are |
| 42 | + **documentation, not a contract** — and nothing catches drift between zod, the TS |
| 43 | + interface, and what the renderer actually reads. There is no equivalent of |
| 44 | + `list-view-spec-parity.test.ts` for either schema. |
| 45 | + |
| 46 | +The measurable consequence: **the zod shape is a strict subset of the TS interface in both |
| 47 | +cases, and the TS interface is a strict subset of what the renderer reads.** Every drift |
| 48 | +has gone the same direction — the renderer grew, the declarations did not. |
| 49 | + |
| 50 | +--- |
| 51 | + |
| 52 | +## 1. `ObjectViewSchema` |
| 53 | + |
| 54 | +### 1.1 The counterpart is the container, not the list view |
| 55 | + |
| 56 | +#2890 nominates spec `ObjectListViewSchema` (`view.zod.ts:1114`) as the nearest |
| 57 | +counterpart and notes it is "not 1:1". It is not 1:1 because it is not the counterpart. |
| 58 | + |
| 59 | +`ObjectViewSchema` is a **composite shell**: a list surface (`table`), a create/edit form |
| 60 | +surface (`form`), named-view state (`listViews` / `defaultListView`), and toolbar chrome. |
| 61 | +`ObjectListViewSchema` covers only the first of those four. |
| 62 | + |
| 63 | +The structurally matching spec schema already exists — `ViewSchema` |
| 64 | +(`framework/packages/spec/src/ui/view.zod.ts:1135-1155`), the ADR-0047 container: |
| 65 | + |
| 66 | +```ts |
| 67 | +{ list: ObjectListViewSchema, form: FormViewSchema, |
| 68 | + listViews: Record<string, ObjectListViewSchema>, |
| 69 | + formViews: Record<string, FormViewSchema>, protection } |
| 70 | +``` |
| 71 | + |
| 72 | +That maps onto objectui's `table` / `form` / `listViews` cluster essentially 1:1. The |
| 73 | +identity fields objectui folds into `listViews` and `defaultListView` also already have a |
| 74 | +spec home in `ViewItemSchema` (`view.zod.ts:1319-1332`), where the default is marked |
| 75 | +**on each view** (`isDefault`) rather than pointed at from the container. |
| 76 | + |
| 77 | +**So the spec does not need to grow a composite-view schema — it has one.** The gap is |
| 78 | +that objectui's composite predates it and encodes the same structure differently. |
| 79 | + |
| 80 | +### 1.2 The declared surface is a fiction |
| 81 | + |
| 82 | +The strongest argument against deriving this schema today: **28 keys are read off the |
| 83 | +`object-view` node that are declared in neither the zod schema nor the TS interface.** |
| 84 | +All are read through `(schema as any).X` in the `renderListView` pass-through |
| 85 | +(`packages/plugin-view/src/ObjectView.tsx:1019-1060`): |
| 86 | + |
| 87 | +``` |
| 88 | +showHideFields, showGroup, showColor, showDensity, compactToolbar, allowExport, |
| 89 | +striped, bordered, color, inlineEdit, wrapHeaders, clickIntoRecordDetails, |
| 90 | +addRecordViaForm, addDeleteRecordsInline, collapseAllByDefault, fieldTextColor, |
| 91 | +prefixField, showDescription, selection, pagination, resizable, hiddenFields, |
| 92 | +rowActions, rowActionDefs, bulkActions, sharing, addRecord, conditionalFormatting |
| 93 | +``` |
| 94 | + |
| 95 | +Every one of these is a `ListViewSchema` key that `ObjectView` relays to the list view it |
| 96 | +renders. The node is, in practice, *`ListViewSchema` plus a form slot plus toolbar |
| 97 | +chrome* — but its declaration admits to about a third of that. |
| 98 | + |
| 99 | +There is nested drift too: `ObjectView.tsx:858` reads `schema.table?.title` and `:430` |
| 100 | +reads `schema.table?.operations`, but the zod `ObjectGridSchema` |
| 101 | +(`objectql.zod.ts:112-144`) declares neither. Only `.passthrough()` keeps them alive. |
| 102 | + |
| 103 | +**Deriving a schema whose declared surface is a third of its real surface would encode the |
| 104 | +wrong thing.** The prerequisite is to make the declaration match the reads. |
| 105 | + |
| 106 | +### 1.3 Field-level verdicts |
| 107 | + |
| 108 | +**Drop (3)** |
| 109 | + |
| 110 | +| Field | Evidence | Verdict | |
| 111 | +| --- | --- | --- | |
| 112 | +| `viewTabBar` | Repo-wide grep across `packages/`, `apps/`, `examples/` (tests included) returns exactly one line: its own declaration at `objectql.ts:1365`. | **Drop.** Dead since introduction. | |
| 113 | +| `showRefresh` | Three producers (`app-shell/src/views/ObjectView.tsx:1624`, `ViewPreview.tsx:136`, `plugin-view/src/index.tsx:94`) and a designer input at `plugin-view/src/index.tsx:81`; **zero readers** in `ObjectView.tsx`. | **Drop the local field.** The affordance is real and the spec already has it — `UserActionsConfigSchema.refresh` (`view.zod.ts:353`). If the refresh button is wanted, wire it to `userActions.refresh` rather than resurrecting a key nothing reads. | |
| 114 | +| `className` | Declared on the node (`objectql.ts:1359`, redeclaring `BaseSchema`), and `SchemaRenderer` does pass it (`packages/react/src/SchemaRenderer.tsx:453`) — but `ObjectViewRenderer` (`plugin-view/src/index.tsx:58-64`) forwards only `schema` and `dataSource`, dropping it on the floor. | **Fix or drop.** Forwarding it is a one-line change and restores Commandment #3 (`className` overridable via JSON); leaving it declared-but-discarded is the worst of both. | |
| 115 | + |
| 116 | +**Promote / align — exact or near-exact spec matches already sitting in `ListViewSchema` (3)** |
| 117 | + |
| 118 | +| Field | Spec counterpart | Verdict | |
| 119 | +| --- | --- | --- | |
| 120 | +| `searchableFields` | `ListViewSchema.searchableFields` (`view.zod.ts:681`) — identical `string[]` | **Re-export by reference**, as `ListViewSchema` already does for its spec fields. | |
| 121 | +| `filterableFields` | `ListViewSchema.filterableFields` (`:682`) — identical | **Re-export by reference.** Note the spec marks it "legacy shorthand for `userFilters.fields`", so it inherits that deprecation. | |
| 122 | +| `navigation` | `ListViewSchema.navigation` (`:698`) → `NavigationConfigSchema` (`:584-612`) | **Re-export by reference.** objectui's `ViewNavigationConfig` (`objectql.ts:1604-1636`) has the same six keys *and* the same `#2578` deprecation note — it is a hand-copied duplicate, the exact class of fork #2231 phase 2 retired. | |
| 123 | + |
| 124 | +**Rename to the spec vocabulary — the scope-A migration, extended to this node (7)** |
| 125 | + |
| 126 | +| objectui | Spec | Note | |
| 127 | +| --- | --- | --- | |
| 128 | +| `objectName` | `data: { provider: 'object', object }` (`ViewDataSchema`, `:29`) | Same divergence as scope A step 6 — and the same upstream blocker (`react-blocks.ts` sanctions `objectName` as a React-tier prop). Move together, or not at all. | |
| 129 | +| `defaultViewType` | `ListViewSchema.type` (`:643`) | Spec is a superset (adds `chart`, `tree`). objectui's own `ListViewSchema` **already imports this enum by reference** (`objectql.zod.ts:365`) — this schema should use the same import instead of restating a narrower copy. | |
| 130 | +| `showSearch` / `showFilters` / `showSort` | `UserActionsConfigSchema.{search,filter,sort}` (`:350-352`) | Scope A step 3, same fold. | |
| 131 | +| `showCreate` | `AddRecordConfigSchema.enabled` (`:448`) | Spec's config also carries `position` / `mode` / `formView`; the boolean is a lossy shorthand for it. | |
| 132 | +| `title` | `label` (`:642`) | Type differs: objectui `z.string()`, spec `I18nLabelSchema`. Promoting means accepting the i18n envelope. | |
| 133 | +| `description` | `description` (`:713`) | Same name, same i18n type difference. | |
| 134 | +| `layout` (`drawer\|modal\|page`) | `NavigationConfigSchema.mode` (`:585`) | Spec is a superset (`split`, `popover`, `new_window`, `none`). Fold into `navigation`, don't keep a parallel three-value enum. | |
| 135 | + |
| 136 | +**Restructure — the container shape (4)** |
| 137 | + |
| 138 | +`table`, `form`, `listViews`, `defaultListView` are one decision, not four: adopt spec |
| 139 | +`ViewSchema`'s `{ list, form, listViews, formViews }` and let `isDefault` live on each |
| 140 | +view item. Splitting them across separate PRs would leave the node in a shape that is |
| 141 | +neither vocabulary. |
| 142 | + |
| 143 | +**Keep local (7)** |
| 144 | + |
| 145 | +| Field | Why it stays | |
| 146 | +| --- | --- | |
| 147 | +| `type: 'object-view'` | Component discriminator, load-bearing for the component union. Spec's `ListViewSchema.type` is the *view kind*, a different axis — the same collision `viewType` already documents. | |
| 148 | +| `onNavigate` | A function. Non-serializable; cannot live in a JSON protocol. | |
| 149 | +| `operations` | CRUD affordance at the view layer. Spec expresses this at the object/permission layer, and `resolveCrudAffordances` is already the runtime authority — so this is arguably **also a drop candidate**, but it is READ at `ObjectView.tsx:430` and `:1140` and removing it needs its own permission-semantics review. Flagged, not decided. | |
| 150 | +| `allowCreateView`, `viewActions` | View-management chrome (`ViewSwitcher.tsx:256,258,278`). No spec counterpart; `UserActionsConfigSchema.buttons` is a `string[]` of action ids, a different shape. | |
| 151 | +| `showViewSwitcher` | Derivable — `appearance.allowedVisualizations.length > 1` is exactly how `ObjectDataPage.tsx:227` and `InterfaceListPage.tsx:386` already compute it. **Keep local only until those two are the single source**; then drop. | |
| 152 | +| `id`, `visible*`, `hidden*`, `disabled*`, `ariaLabel`, … | `BaseSchema` envelope, owned by the renderer, out of scope for a view protocol. | |
| 153 | + |
| 154 | +Three envelope near-misses are worth recording because they will bite any future |
| 155 | +derivation: `data` — objectui `z.any()` payload vs spec `ViewDataSchema` *source |
| 156 | +descriptor*; `name` — objectui free string vs spec `SnakeCaseIdentifierSchema`; |
| 157 | +`visibleWhen` — objectui `z.string()` vs spec `ExpressionInputSchema` (which since #2661 |
| 158 | +may be a `{dialect, source}` envelope, not a bare string). |
| 159 | + |
| 160 | +### 1.4 Verdict |
| 161 | + |
| 162 | +**Restructure, don't derive — and in this order:** |
| 163 | + |
| 164 | +1. Make the declaration match the reads (the 28 undeclared keys). Until then any |
| 165 | + derivation encodes a third of the node. |
| 166 | +2. Add a drift guard modelled on `list-view-spec-parity.test.ts`. Without one, step 1 |
| 167 | + decays immediately — that test is the only reason `ListViewSchema` stayed honest. |
| 168 | +3. Then adopt spec `ViewSchema` as the container shape and re-export the three exact |
| 169 | + matches by reference. |
| 170 | + |
| 171 | +Steps 1–2 are worth doing on their own even if step 3 never happens. |
| 172 | + |
| 173 | +--- |
| 174 | + |
| 175 | +## 2. `DetailViewSchema` |
| 176 | + |
| 177 | +### 2.1 `FormViewSchema` is the wrong counterpart |
| 178 | + |
| 179 | +By field *names* the nomination is defensible — `title`, `description`, `layout`, |
| 180 | +`columns`, `sections`, `fields`, `data` and `defaultTab` all appear in both. By |
| 181 | +*semantics* it is wrong: `FormViewSchema` is the **create/edit** surface; |
| 182 | +`DetailViewSchema` is a **read-only record** surface. |
| 183 | + |
| 184 | +The name overlap is actively misleading. Three examples from the mapping: |
| 185 | + |
| 186 | +- **`data`** — objectui's is the record *payload* (`z.any()`, read at |
| 187 | + `DetailView.tsx:384`); spec's is a data-*source descriptor* (`ViewDataSchema`). Same |
| 188 | + key, opposite direction of dataflow. Deriving would silently invert its meaning. |
| 189 | +- **`columns`** — objectui `z.number()`; spec `FormViewSchema.columns` |
| 190 | + `z.number().int().min(1)`; spec `RecordDetailsProps.columns` |
| 191 | + `z.enum(['1','2','3','4'])` — a **string** enum. Three encodings of one concept. |
| 192 | +- **`layout`** — objectui `vertical|horizontal|grid`; `FormViewSchema.layout` |
| 193 | + `vertical|horizontal|inline|grid`; `RecordDetailsProps.layout` `auto|custom`. Two |
| 194 | + different spec fields share the name with two different meanings. |
| 195 | + |
| 196 | +The spec's actual read-only record surface is the **`record:*` page-component family**: |
| 197 | +`RecordDetailsProps` (`component.zod.ts:75-82`), `RecordHighlightsProps` (`:147-152`), |
| 198 | +`RecordRelatedListProps` (`:84-145`), `RecordActivityProps` (`:154-179`), |
| 199 | +`RecordChatterProps` (`:181-194`), composed under `PageSchema`. Those are declarative |
| 200 | +components that name what to render; `DetailViewSchema` inlines *pre-fetched payloads* |
| 201 | +(`history.entries[]`, `comments[]`, `activities[]`) alongside callbacks. The mismatch is |
| 202 | +structural, not cosmetic. |
| 203 | + |
| 204 | +### 2.2 objectui already migrated — this schema is the surface left behind |
| 205 | + |
| 206 | +The decisive evidence is in the producer count. Only **two** first-party call sites emit a |
| 207 | +`detail-view` node: |
| 208 | + |
| 209 | +- `packages/plugin-detail/src/renderers/record-details.tsx:243-257` |
| 210 | +- `packages/plugin-detail/src/RecordDetailDrawer.tsx:299-332` |
| 211 | + |
| 212 | +Everything else that shows a record — `packages/plugin-detail/src/synth/buildDefaultPageSchema.ts`, |
| 213 | +`packages/app-shell/src/views/RecordDetailView.tsx` — builds a **page schema out of |
| 214 | +`record:*` components**, i.e. the spec shape, and does not touch `DetailViewSchema` at all. |
| 215 | + |
| 216 | +The consequence shows up as dead weight: of the 33 fields the renderer READS, only **11** |
| 217 | +are ever produced by first-party runtime code (`type`, `objectName`, `resourceId`, `data`, |
| 218 | +`columns`, `sections`, `fields`, `actions`, `showBack`, `showDelete`, plus the undeclared |
| 219 | +`showHeader`). The other 22 — `history`, `comments`, `activities`, `recordNavigation`, |
| 220 | +`sectionGroups`, `highlightFields`, `summaryFields`, `tabs`, `related`, `header`, `footer`, |
| 221 | +`primaryField`, … — are read by `DetailView.tsx` but written only by tests, the package |
| 222 | +README, and the designer-input list. They are capability the product reaches through the |
| 223 | +page path instead. |
| 224 | + |
| 225 | +### 2.3 Field-level verdicts |
| 226 | + |
| 227 | +**Drop (3)** |
| 228 | + |
| 229 | +| Field | Evidence | |
| 230 | +| --- | --- | |
| 231 | +| `onBack` | Never read. The `onBack` at `DetailView.tsx:144/194/493` is the React **prop** (`() => void`) — a different channel and a different type from the schema's `string`. | |
| 232 | +| `autoDiscoverRelated` | Repo-wide grep returns exactly one line: its declaration at `views.ts:615`. | |
| 233 | +| `layout` | **WRITTEN-ONLY.** Produced by `record-details.tsx:248` and exposed as a designer input (`plugin-detail/src/index.tsx:141`), read nowhere in `DetailView.tsx`. A user can set it in the designer and nothing happens. | |
| 234 | + |
| 235 | +**Keep local, do not promote (the runtime-state cluster)** |
| 236 | + |
| 237 | +`loading`, `data`, `history`, `comments`, `activities`, `recordNavigation`, `onNavigate`, |
| 238 | +`onTabChange`, `onAddComment` are **live state and callbacks**, not metadata. They are |
| 239 | +correctly local and must never enter a serializable protocol. Their existence on a schema |
| 240 | +is itself the smell: they are why this "schema" cannot be validated, persisted, or |
| 241 | +authored — it is a props bag wearing a schema's clothes. |
| 242 | + |
| 243 | +**Already-solved-upstream (do not re-promote)** |
| 244 | + |
| 245 | +| objectui | Spec home | |
| 246 | +| --- | --- | |
| 247 | +| `primaryField` | `Object.nameField` — title resolution belongs to the object | |
| 248 | +| `summaryFields` / `highlightFields` | `Object.highlightFields` (`data/object.zod.ts:1051`, ADR-0085) and `RecordHighlightsProps.fields` (`:148`, capped `.min(1).max(7)`) | |
| 249 | +| `related[]` | `RecordRelatedListProps` (`:84-145`) — a **superset**: it adds `relationshipValueField`, `sort`, `limit`, `filter`, `showViewAll`, `actions`, `add{picker,linkField,label}` that objectui's inline shape has no room for | |
| 250 | +| `tabs` / `autoTabs` / `defaultTab` | `PageTabsProps` (`:32-55`) | |
| 251 | +| `sectionGroups` | `PageAccordionProps` (`:206-216`) | |
| 252 | +| `header` / `footer` | `PageCardProps.body` / `.footer` (`:63-65`) | |
| 253 | +| `title` / `actions` | `PageHeaderProps` (`:22-30`) | |
| 254 | + |
| 255 | +Every one of these already has a better spec home **that objectui already renders**. There |
| 256 | +is nothing to promote; there is a caller to migrate. |
| 257 | + |
| 258 | +**No spec counterpart, and shouldn't get one** |
| 259 | + |
| 260 | +`showBack` / `backUrl`, `showEdit` / `editUrl`, `showDelete` / `deleteConfirmation`, |
| 261 | +`api`, `resourceId`, `showHeader`. These are navigation chrome and imperative fetch |
| 262 | +plumbing. The spec's answer to "show a back button" is that the *page* owns navigation |
| 263 | +chrome; the answer to `api`/`resourceId` is that the record is bound by page/route |
| 264 | +context. Promoting them would import objectui's fetch model into the protocol. |
| 265 | + |
| 266 | +### 2.4 Verdict |
| 267 | + |
| 268 | +**Freeze and retire, don't derive.** |
| 269 | + |
| 270 | +1. **Freeze.** Add no fields. Every capability request already has a `record:*` home. |
| 271 | +2. **Drop** the three dead/written-only fields (`onBack`, `autoDiscoverRelated`, `layout`) |
| 272 | + and the `layout` designer input that advertises a no-op. |
| 273 | +3. **Migrate the two producers.** `record-details.tsx` and `RecordDetailDrawer.tsx` are |
| 274 | + the only things keeping the node alive; `buildDefaultPageSchema.ts` is the pattern to |
| 275 | + follow. |
| 276 | +4. **Then delete** `DetailViewSchema`, `DetailViewFieldSchema`, `DetailViewSectionSchema`, |
| 277 | + `DetailViewTabSchema` and `DetailView.tsx`. |
| 278 | + |
| 279 | +Deriving it from `FormViewSchema` would spend the effort of a migration to make a |
| 280 | +superseded surface look canonical — and would bake in the `data` / `columns` / `layout` |
| 281 | +collisions above while doing it. |
| 282 | + |
| 283 | +--- |
| 284 | + |
| 285 | +## 3. Cross-cutting: the missing guard |
| 286 | + |
| 287 | +`ListViewSchema` stayed honest through three migration phases because |
| 288 | +`packages/types/src/__tests__/list-view-spec-parity.test.ts` fails when the spec grows a |
| 289 | +field objectui ignores, when an anchor objectui aliases disappears upstream, or when |
| 290 | +someone adds a local field without a sanctioned rationale. |
| 291 | + |
| 292 | +Neither schema in this audit has one, and the 28-undeclared-keys number is the cost. Any |
| 293 | +work on `ObjectViewSchema` should land a guard **first**: it is what converts "we cleaned |
| 294 | +this up once" into "it cannot re-drift." |
| 295 | + |
| 296 | +--- |
| 297 | + |
| 298 | +## Appendix — counts |
| 299 | + |
| 300 | +`ObjectViewSchema`: 25 declared (13 zod / 12 TS-only) — 22 READ, 2 WRITTEN-ONLY |
| 301 | +(`showRefresh`, `className`), 1 DEAD (`viewTabBar`), plus 28 read-but-undeclared. |
| 302 | + |
| 303 | +`DetailViewSchema`: 37 declared (23 zod / 14 TS-only) — 33 READ, 1 WRITTEN-ONLY |
| 304 | +(`layout`), 2 DEAD (`onBack`, `autoDiscoverRelated`), plus 2 read-but-undeclared |
| 305 | +(`showHeader` READ, `inlineEdit` WRITTEN-ONLY). Of the 33 READ, 11 have a first-party |
| 306 | +runtime producer. |
| 307 | + |
| 308 | +Type-level drift, both schemas: zod ⊂ TS, with no field going the other way. |
| 309 | +`DetailViewSchema` additionally disagrees with itself on two types — `actions` |
| 310 | +(zod `z.array(z.any())` vs TS `ActionSchema[]`) and `related[].columns` |
| 311 | +(zod `z.array(z.any())` vs TS `TableColumn[]`). |
0 commit comments