Skip to content

Commit 6a56aa5

Browse files
os-zhuangclaude
andauthored
docs(audit): field-level verdict for ObjectViewSchema / DetailViewSchema (#2890 scope B) (#2934)
#2890 scope B asks for an audit, not a swap: field by field, decide promote-to-spec / keep-local / drop, and only then decide whether a spec derivation is the right shape. For ListViewSchema the answer was yes and #2231 executed it. For these two the answer is no, for different reasons. ObjectViewSchema — restructure, don't derive. Its counterpart is not ObjectListViewSchema but spec `ViewSchema`, the ADR-0047 container ({list, form, listViews, formViews}), which maps onto its table/form/listViews cluster ~1:1. But the prerequisite is that its declared surface is a fiction: 28 keys are read off the node that are declared in neither the zod schema nor the TS interface, all relayed to the list view via `(schema as any)`. Deriving a schema whose declaration covers a third of its reads would encode the wrong thing. Verdicts: drop 3 (viewTabBar dead, showRefresh written-only, className discarded by the renderer), re-export 3 exact spec matches by reference (searchableFields, filterableFields, navigation — the last a hand-copied duplicate down to its #2578 deprecation note), rename 7, restructure 4, keep 7. DetailViewSchema — freeze and retire, don't derive. FormViewSchema is the wrong counterpart: it is the create/edit surface, this is a read-only record surface, and the shared names collide rather than align (`data` is a payload here and a source descriptor there; `columns` has three encodings; `layout` matches two different spec fields with two different meanings). The spec's real counterpart is the `record:*` page-component family — which objectui already renders: buildDefaultPageSchema and RecordDetailView build pages of record:* components and never touch this schema. Only two producers keep the node alive, and only 11 of its 33 read fields have any first-party producer at all. Verdicts: drop 3 (onBack, autoDiscoverRelated, layout — the last written, exposed as a designer input, and read nowhere), keep the runtime-state cluster local and out of any protocol, and migrate the two producers onto the page path. Cross-cutting: neither schema is parsed at runtime and BaseSchema is .passthrough(), so both zod shapes are documentation rather than contract, and neither has a drift guard like list-view-spec-parity.test.ts. That absence is what the 28-undeclared-keys number costs. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent f05b84e commit 6a56aa5

1 file changed

Lines changed: 311 additions & 0 deletions

File tree

Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
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

Comments
 (0)