|
| 1 | +# ADR-0047: Two run modes for object UI — data views vs interface pages, user filters, and runtime visualization choice |
| 2 | + |
| 3 | +**Status**: Proposed (2026-06-12) |
| 4 | +**Deciders**: ObjectStack Protocol Architects |
| 5 | +**Builds on**: [ADR-0005](./0005-metadata-customization-overlay.md) (one Zod source per type, org overlay), [ADR-0017](./0017-object-has-many-view.md) (independent view entities, `viewKind`), [ADR-0019](./0019-app-as-consumer-unit.md) (App is the consumer-facing unit — navigation decides what users see), [ADR-0027](./0027-metadata-authoring-lifecycle.md) (draft · publish lifecycle), [ADR-0033](./0033-ai-assisted-metadata-authoring.md) (**AI is the long-term author of metadata — the design center this ADR inherits**) |
| 6 | +**Consumers**: `@objectstack/spec` (view + page Zod schemas), `@objectstack/objectql` (registry validation/diagnostics), `../objectui` (console `ObjectView` / `PageView`, `plugin-list`), framework templates (`hotcrm`, `app-showcase`), the `objectstack-ui` authoring skill |
| 7 | + |
| 8 | +**Premise**: same as ADR-0033 — the platform is pre-launch, the primary author of UI metadata is an AI, and a human confirms. Every design choice below is therefore evaluated against two questions: *does it make the generated-by-default experience correct?* and *can an AI author get it wrong?* |
| 9 | + |
| 10 | +--- |
| 11 | + |
| 12 | +## TL;DR |
| 13 | + |
| 14 | +1. **Two run modes, both first-class.** *Data mode* (navigation → object): every list view of the object renders as a switcher tab; users may create personal views; the toolbar is permissive. *Interface mode* (navigation → page): an author-curated page **references** one view as its source and exposes only the controls the author enabled — filter tabs or dropdowns, a fixed (or whitelisted) visualization, selected user actions. This mirrors Airtable's Data vs Interfaces split and Power Platform's model-driven vs canvas split. |
| 15 | +2. **The iron rule: pages reference views, never restate them.** A page's `source` points at an object/view; columns, base filter, and sort are *inherited* from the view definition. The page schema carries presentation policy only — it has no field for columns, so the "page and view each declare columns, then drift" failure mode is unrepresentable. |
| 16 | +3. **`userFilters` becomes spec.** The end-user quick-filter surface (Airtable "User filters": element = `tabs | dropdown | toggle`, plus per-field config) is formalized in `ListViewSchema` and `InterfacePageConfig`. The client already implements and renders it (verified live, below); today it works only by accident of raw passthrough, with no type for authors and no Studio form. |
| 17 | +4. **Runtime visualization choice is an author-controlled whitelist.** `userActions.visualizations?: boolean | ViewType[]` at view level, `visualizations` at page level (superseding the misplaced `userFilters.elements` enum). Effective options = author whitelist ∩ types whose required field bindings resolve (kanban needs a select `groupBy`, calendar a date field, …). Data mode defaults open; interface mode defaults locked. |
| 18 | +5. **Defaults are asymmetric on purpose.** Data mode auto-derives quick filters from select/boolean fields and allows user views; interface mode is closed until the author opens it. An AI that emits *nothing* beyond objects + views + navigation gets a correct, complete system — "omission is correct" is the strongest guardrail we can give a generative author. |
| 19 | +6. **AI decision rule, encoded not implied.** Default output is objects + list views + navigation → objects. Interface pages are generated only on explicit signals (persona split, capability narrowing, portal/workspace language). The rule ships in the `objectstack-ui` skill; reference-integrity diagnostics become hard failures in the AI loop (per ADR-0033's draft gate). |
| 20 | + |
| 21 | +--- |
| 22 | + |
| 23 | +## 1. Context — what exists (verified live, 2026-06-12) |
| 24 | + |
| 25 | +The findings below come from running `examples/app-showcase` (:3000) against the |
| 26 | +objectui console (:5180), logging in, and exercising the HotCRM `crm_account` |
| 27 | +object — plus one runtime experiment: a view written through |
| 28 | +`PUT /api/v1/meta/view/:name` carrying `tabs` + `userFilters` that the spec does |
| 29 | +not declare. |
| 30 | + |
| 31 | +**Already working:** |
| 32 | + |
| 33 | +- **Auto-derived quick filters are live.** The `crm_account` list renders |
| 34 | + dropdown filters (类型 / 行业 / 是否活跃 / 更多) although the hotcrm template |
| 35 | + contains **zero** `userFilters` metadata — objectui's `ListView` derives them |
| 36 | + from the object's select/boolean fields. Selecting 行业=科技 correctly |
| 37 | + filters 5 → 2 records through the query pipeline. |
| 38 | +- **Metadata-driven `userFilters` round-trips end-to-end.** The experimental |
| 39 | + view saved with `userFilters: { element: 'dropdown', fields: [行业, 评级] }` |
| 40 | + was stored verbatim (registry validation is warn-only and stores the raw |
| 41 | + item), echoed back intact with `_diagnostics.valid: true`, and the console |
| 42 | + rendered **exactly the two configured dropdowns**, replacing the auto-derived |
| 43 | + set. The client component (`plugin-list/UserFilters.tsx`) supports all three |
| 44 | + elements (`dropdown` / `tabs` / `toggle`), lookup-backed options, counts and |
| 45 | + defaults. |
| 46 | +- **View switcher + user-created views (data mode)** work: 7 artifact views, |
| 47 | + overflow menu, "Manage all views…", runtime overlay create/delete (ADR-0005). |
| 48 | +- **Runtime visualization switching exists as a dormant component** |
| 49 | + (`plugin-list/ViewSwitcher.tsx`), gated by a `showViewSwitcher` prop that |
| 50 | + only Studio's view preview enables. |
| 51 | + |
| 52 | +**Broken or absent:** |
| 53 | + |
| 54 | +- **`ListViewSchema` has no `userFilters`.** The working client behavior has |
| 55 | + no spec type: TS authors get excess-property errors, Studio cannot render a |
| 56 | + form for it, and an AI grounded in the Zod schema will never emit it. |
| 57 | +- **View-level `tabs` don't reach the renderer.** The experiment's three |
| 58 | + filter tabs (全部 / 科技公司 / 金融公司) were stored and served but never |
| 59 | + rendered — the console's `ObjectView` forwards `listSchema.tabs` and drops |
| 60 | + `viewDef.tabs` (a one-line gap). |
| 61 | +- **`InterfacePageConfig` is schema-only.** No page in the running system |
| 62 | + carries `interfaceConfig`; nothing renders it. Its |
| 63 | + `userFilters.elements: ['grid','gallery','kanban']` enum conflates |
| 64 | + *visualization choice* with *filter element type*. |
| 65 | +- **No template exercises any of this.** `userFilters`, view `tabs`, |
| 66 | + `filterableFields`: zero occurrences across `app-showcase` and the |
| 67 | + `hotcrm`/`hr` templates — so the capability is invisible to both humans |
| 68 | + and few-shot-grounded AI authors. |
| 69 | +- **Registry validation is warn-only** (`registry.validate()` logs and stores |
| 70 | + the raw item). Tolerable for humans; for an AI loop it means a misspelled |
| 71 | + field name produces no feedback at all. |
| 72 | + |
| 73 | +## 2. Prior art |
| 74 | + |
| 75 | +| Platform | Data-mode equivalent | Interface-mode equivalent | Lesson taken | |
| 76 | +|---|---|---|---| |
| 77 | +| **Airtable** | Data tab: every view user-creatable, grid-first | Interfaces: author-curated pages; *User filters* (Elements: tabs / dropdowns), *Appearance → Visualizations* fixed by author, *User actions* toggles | The panel we are matching feature-for-feature; end users never create views inside an interface | |
| 78 | +| **Salesforce** | Object list views (user-creatable, admin-shareable, pinnable) | Lightning App Builder pages (curated regions/components) | List views remain the data asset; pages compose them. Personal vs shared views are permission-governed | |
| 79 | +| **Microsoft Power Platform** | Model-driven apps: UI *generated* from Dataverse views/forms | Canvas apps: fully curated | "Generated-by-default is correct" scales; full curation is opt-in because it carries maintenance cost | |
| 80 | +| **Notion** | Database with multiple views | Linked database views embedded in pages | Embeds are *references* to the source database — the iron rule in the wild | |
| 81 | +| **Retool (counter-example)** | — | Everything is a hand-built page | With no generated data mode, every list costs author time and drifts from schema changes — what we avoid by keeping data mode primary | |
| 82 | + |
| 83 | +## 3. Decision |
| 84 | + |
| 85 | +### 3.1 The model: three layers, navigation decides the mode |
| 86 | + |
| 87 | +| Layer | Metadata | Role | Required? | |
| 88 | +|---|---|---|---| |
| 89 | +| Data asset | `object` + its list views (ADR-0017 view entities) | Authoritative definition of columns/filter/sort; serves data mode | Yes — every object | |
| 90 | +| Entry | `app.navigation` (ADR-0019) | `type: 'object'` → data mode; `type: 'page'` → interface mode | Yes | |
| 91 | +| Presentation | `page.interfaceConfig` | Curated end-user surface; references a view, adds policy | Optional, signal-driven | |
| 92 | + |
| 93 | +The same object may appear behind both entries (ops team gets the object, |
| 94 | +business users get a page). The renderer (`plugin-list/ListView`) is shared; |
| 95 | +`ObjectView` and `PageView` are two orchestrators feeding it different schema. |
| 96 | + |
| 97 | +### 3.2 The iron rule |
| 98 | + |
| 99 | +`interfaceConfig` carries **no column, no base-filter, no sort definitions** — |
| 100 | +only: `source` (object or object+view), `userFilters`, `visualizations`, |
| 101 | +`userActions`, `addRecord`, `showRecordCount`, and cosmetic appearance. Columns |
| 102 | +and data semantics always come from the referenced view. Schema-level |
| 103 | +unrepresentability is the strongest anti-drift and anti-AI-error mechanism we |
| 104 | +have; we do not rely on lint to forbid what the type can simply not express. |
| 105 | + |
| 106 | +### 3.3 Spec changes (`@objectstack/spec`) |
| 107 | + |
| 108 | +1. **`UserFiltersSchema`** (new, `ui/view.zod.ts`), modeled on the proven |
| 109 | + objectui client type: |
| 110 | + |
| 111 | + ```ts |
| 112 | + export const UserFilterFieldSchema = z.object({ |
| 113 | + field: z.string(), // must exist on the source object |
| 114 | + label: I18nLabelSchema.optional(), |
| 115 | + type: z.enum(['select','multi-select','boolean','date-range','text']).optional(), // default: infer |
| 116 | + options: z.array(OptionSchema).optional(), // default: derive from field def |
| 117 | + showCount: z.boolean().optional(), |
| 118 | + defaultValues: z.array(z.union([z.string(), z.number(), z.boolean()])).optional(), |
| 119 | + }); |
| 120 | + |
| 121 | + export const UserFiltersSchema = z.object({ |
| 122 | + element: z.enum(['dropdown','tabs','toggle']), // Airtable "Elements" |
| 123 | + fields: z.array(UserFilterFieldSchema).optional(), // dropdown/toggle |
| 124 | + tabs: z.array(ViewTabSchema).optional(), // tabs element reuses ViewTabSchema |
| 125 | + showAllRecords: z.boolean().optional(), |
| 126 | + }); |
| 127 | + ``` |
| 128 | + |
| 129 | + Attached as `ListViewSchema.userFilters` (view-level convenience) and |
| 130 | + `InterfacePageConfigSchema.userFilters` (the primary home). |
| 131 | + `filterableFields` stays as the legacy shorthand; loaders auto-upgrade it |
| 132 | + to `userFilters.fields` entries. |
| 133 | + |
| 134 | +2. **Runtime visualization whitelist.** |
| 135 | + `UserActionsConfigSchema.visualizations?: z.union([z.boolean(), z.array(ViewTypeEnum)])` |
| 136 | + (view level) and `InterfacePageConfigSchema.visualizations` (page level, |
| 137 | + full 8-type enum). The existing `userFilters.elements` 3-type enum is kept |
| 138 | + one release as a deprecated alias and folded in. |
| 139 | + |
| 140 | +3. **Semantics, not rendering.** The spec defines *what the author allows*; |
| 141 | + capability filtering (does this object have a select field for kanban |
| 142 | + `groupBy`? a date field for calendar?) is renderer responsibility and must |
| 143 | + hide non-resolvable types rather than render with hardcoded fallbacks |
| 144 | + (`groupBy: 'status'` against an object without `status` renders garbage — |
| 145 | + observed in the current console code). |
| 146 | + |
| 147 | +### 3.4 Asymmetric defaults |
| 148 | + |
| 149 | +| | Data mode | Interface mode | |
| 150 | +|---|---|---| |
| 151 | +| Quick filters | Auto-derived from select/boolean fields (current live behavior, now blessed) | None until author configures `userFilters` | |
| 152 | +| Visualization switching | On (all capability-resolvable types); user preference persists via the per-user view-patch channel | Locked to authored visualization unless whitelisted | |
| 153 | +| User-created views | Allowed, governed by permissions + view `sharing` | Never | |
| 154 | +| Filter state | Session-scoped (URL-param sync is a later, orthogonal step) | Session-scoped | |
| 155 | + |
| 156 | +### 3.5 AI authoring rules (inherits ADR-0033's draft gate) |
| 157 | + |
| 158 | +1. **Default output**: objects + list views + navigation → objects. *No pages.* |
| 159 | + Rationale: data mode is a functional superset; a missing page costs polish, |
| 160 | + a superfluous page costs a permanently-maintained duplicate asset. The |
| 161 | + asymmetry dictates the default. |
| 162 | +2. **Generate an interface page only on explicit signals**: persona split |
| 163 | + ("销售人员看到…", customer portal), capability narrowing ("users must not |
| 164 | + change views", "only filter by industry/type"), or curation language |
| 165 | + (workspace / 工作台 / Airtable-interface-like). Ambiguity resolves to *no |
| 166 | + page*. |
| 167 | +3. **The rule ships as text in the `objectstack-ui` skill** (decision tree + |
| 168 | + the iron rule + this ADR as reference), so the constraint binds at |
| 169 | + generation time, not only at validation time. |
| 170 | +4. **Diagnostics are hard failures for AI.** The metadata write path already |
| 171 | + returns `_diagnostics`; it gains reference-integrity checks — `page.source` |
| 172 | + resolves; every `userFilters.fields[].field` and `tabs[].filter[].field` |
| 173 | + exists on the source object; kanban `groupBy` is a select. Human authors |
| 174 | + keep warn-and-store (ADR-0005 tolerance); the ADR-0033 agent loop treats |
| 175 | + `valid: false` as a failed apply and self-corrects before draft review. |
| 176 | +5. **Templates are the few-shot corpus.** `hotcrm` gains one canonical |
| 177 | + interface page (e.g. a "销售工作台" referencing `all_accounts` with |
| 178 | + industry/type dropdowns and a locked grid) and one view with filter `tabs`; |
| 179 | + `app-showcase` covers the remaining permutations. Zero examples today is |
| 180 | + why zero AI generations use these features. |
| 181 | + |
| 182 | +## 4. Non-goals |
| 183 | + |
| 184 | +- **Per-user saved filter presets in the cloud** (Airtable lets interface |
| 185 | + users pin filter values) — session scope first; a `sys_user_view_state` |
| 186 | + channel is a separate decision. |
| 187 | +- **Ad-hoc filter-builder spec for end users** — the advanced FilterBuilder |
| 188 | + remains a client feature; its output never persists as metadata. |
| 189 | +- **Page composition beyond single-source lists** (multi-widget interface |
| 190 | + pages, cross-object layouts) — covered by the existing page/component |
| 191 | + schemas; this ADR only fixes the list-page run mode. |
| 192 | +- **Back-compat machinery** — pre-launch premise; `filterableFields` and |
| 193 | + `userFilters.elements` aliases are one-release courtesies, not commitments. |
| 194 | + |
| 195 | +## 5. Consequences |
| 196 | + |
| 197 | +**Positive.** The Airtable-parity gap (in-page filter tabs / dropdowns, |
| 198 | +author-controlled visualization) closes mostly by *formalizing what already |
| 199 | +runs*; one orchestrator line, one schema block, and template examples deliver |
| 200 | +the visible feature. AI authors get a closed loop: schema they can see, |
| 201 | +diagnostics that push back, examples to imitate, and a default that is correct |
| 202 | +when they emit nothing. The two modes stop being implicit — navigation type is |
| 203 | +the single, auditable mode switch. |
| 204 | + |
| 205 | +**Negative / accepted.** Two places can now declare `userFilters` |
| 206 | +(view + page); precedence is fixed (page overrides view) and documented, but |
| 207 | +it is still a second place. Interface pages remain a thinner v1 than |
| 208 | +Airtable's (no per-user saved state). Warn-only validation persists for human |
| 209 | +paths — we accept schema-invalid human metadata surviving, as today, until |
| 210 | +ADR-0027 promotion tightens it. |
| 211 | + |
| 212 | +## 6. Implementation plan |
| 213 | + |
| 214 | +| Phase | Repo | Work | Size | |
| 215 | +|---|---|---|---| |
| 216 | +| 1 | framework | `UserFiltersSchema` + `userActions.visualizations` + `InterfacePageConfig` rework in spec; reference-integrity diagnostics | S | |
| 217 | +| 2 | objectui | `ObjectView`: forward `viewDef.tabs`; import spec types (kill the local duplicate); wire `showViewSwitcher` behind the whitelist + capability filter | S | |
| 218 | +| 3 | framework | Template examples (`hotcrm` workbench page, tabbed view); `objectstack-ui` skill decision rules | S | |
| 219 | +| 4 | objectui | `PageView` renders `interfaceConfig` (single-source list + `UserFilters` toolbar, locked toolbar policy) | M | |
| 220 | +| 5 | both | Studio authoring panel for user filters / visualizations (Airtable right-panel parity); AI loop treats diagnostics as hard failure | M | |
| 221 | + |
| 222 | +Phases 1–3 are independently shippable and deliver the user-visible feature in |
| 223 | +data mode; phase 4 activates interface mode; phase 5 completes the authoring |
| 224 | +experience. |
| 225 | + |
| 226 | +## 7. Open questions |
| 227 | + |
| 228 | +1. Should interface-mode filter state sync to URL params for shareable links |
| 229 | + (Salesforce-style) in v1, or stay purely in memory? |
| 230 | +2. Does `userFilters` on the *view* level survive long-term, or do we |
| 231 | + eventually deprecate it in favor of page-level-only once PageView ships? |
| 232 | +3. Capability filtering for visualizations: renderer-local heuristics now — |
| 233 | + does the spec eventually need explicit per-type binding declarations |
| 234 | + (`kanban.groupByField` required) surfaced as authoring-time diagnostics? |
0 commit comments