ADR-0047: Two run modes for object UI — data views vs interface pages, user filters, and runtime visualization choice
Status: Proposed (2026-06-12)
Deciders: ObjectStack Protocol Architects
Builds on: ADR-0005 (one Zod source per type, org overlay), ADR-0017 (independent view entities, viewKind), ADR-0019 (App is the consumer-facing unit — navigation decides what users see), ADR-0027 (draft · publish lifecycle), ADR-0033 (AI is the long-term author of metadata — the design center this ADR inherits)
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
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?
- 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.
- The iron rule: pages reference views, never restate them. A page's
sourcepoints 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. userFiltersbecomes spec. The end-user quick-filter surface (Airtable "User filters": element =tabs | dropdown | toggle, plus per-field config) is formalized inListViewSchemaandInterfacePageConfig. 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.- Runtime visualization choice is an author-controlled whitelist.
userActions.visualizations?: boolean | ViewType[]at view level,visualizationsat page level (superseding the misplaceduserFilters.elementsenum). Effective options = author whitelist ∩ types whose required field bindings resolve (kanban needs a selectgroupBy, calendar a date field, …). Data mode defaults open; interface mode defaults locked. - 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.
- 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-uiskill; reference-integrity diagnostics become hard failures in the AI loop (per ADR-0033's draft gate).
The findings below come from running examples/app-showcase (:3000) against the
objectui console (:5180), logging in, and exercising the HotCRM crm_account
object — plus one runtime experiment: a view written through
PUT /api/v1/meta/view/:name carrying tabs + userFilters that the spec does
not declare.
Already working:
- Auto-derived quick filters are live. The
crm_accountlist renders dropdown filters (类型 / 行业 / 是否活跃 / 更多) although the hotcrm template contains zerouserFiltersmetadata — objectui'sListViewderives them from the object's select/boolean fields. Selecting 行业=科技 correctly filters 5 → 2 records through the query pipeline. - Metadata-driven
userFiltersround-trips end-to-end. The experimental view saved withuserFilters: { element: 'dropdown', fields: [行业, 评级] }was stored verbatim (registry validation is warn-only and stores the raw item), echoed back intact with_diagnostics.valid: true, and the console rendered exactly the two configured dropdowns, replacing the auto-derived set. The client component (plugin-list/UserFilters.tsx) supports all three elements (dropdown/tabs/toggle), lookup-backed options, counts and defaults. - View switcher + user-created views (data mode) work: 7 artifact views, overflow menu, "Manage all views…", runtime overlay create/delete (ADR-0005).
- Runtime visualization switching exists as a dormant component
(
plugin-list/ViewSwitcher.tsx), gated by ashowViewSwitcherprop that only Studio's view preview enables.
Broken or absent:
ListViewSchemahas nouserFilters. The working client behavior has no spec type: TS authors get excess-property errors, Studio cannot render a form for it, and an AI grounded in the Zod schema will never emit it.- View-level
tabsdon't reach the renderer. The experiment's three filter tabs (全部 / 科技公司 / 金融公司) were stored and served but never rendered — the console'sObjectViewforwardslistSchema.tabsand dropsviewDef.tabs(a one-line gap). InterfacePageConfigis schema-only. No page in the running system carriesinterfaceConfig; nothing renders it. ItsuserFilters.elements: ['grid','gallery','kanban']enum conflates visualization choice with filter element type.- No template exercises any of this.
userFilters, viewtabs,filterableFields: zero occurrences acrossapp-showcaseand thehotcrm/hrtemplates — so the capability is invisible to both humans and few-shot-grounded AI authors. - Registry validation is warn-only (
registry.validate()logs and stores the raw item). Tolerable for humans; for an AI loop it means a misspelled field name produces no feedback at all.
| Platform | Data-mode equivalent | Interface-mode equivalent | Lesson taken |
|---|---|---|---|
| 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 |
| 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 |
| 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 |
| Notion | Database with multiple views | Linked database views embedded in pages | Embeds are references to the source database — the iron rule in the wild |
| 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 |
| Layer | Metadata | Role | Required? |
|---|---|---|---|
| Data asset | object + its list views (ADR-0017 view entities) |
Authoritative definition of columns/filter/sort; serves data mode | Yes — every object |
| Entry | app.navigation (ADR-0019) |
type: 'object' → data mode; type: 'page' → interface mode |
Yes |
| Presentation | page.interfaceConfig |
Curated end-user surface; references a view, adds policy | Optional, signal-driven |
The same object may appear behind both entries (ops team gets the object,
business users get a page). The renderer (plugin-list/ListView) is shared;
ObjectView and PageView are two orchestrators feeding it different schema.
interfaceConfig carries no column, no base-filter, no sort definitions —
only: source (object or object+view), userFilters, visualizations,
userActions, addRecord, showRecordCount, and cosmetic appearance. Columns
and data semantics always come from the referenced view. Schema-level
unrepresentability is the strongest anti-drift and anti-AI-error mechanism we
have; we do not rely on lint to forbid what the type can simply not express.
-
UserFiltersSchema(new,ui/view.zod.ts), modeled on the proven objectui client type:export const UserFilterFieldSchema = z.object({ field: z.string(), // must exist on the source object label: I18nLabelSchema.optional(), type: z.enum(['select','multi-select','boolean','date-range','text']).optional(), // default: infer options: z.array(OptionSchema).optional(), // default: derive from field def showCount: z.boolean().optional(), defaultValues: z.array(z.union([z.string(), z.number(), z.boolean()])).optional(), }); export const UserFiltersSchema = z.object({ element: z.enum(['dropdown','tabs','toggle']), // Airtable "Elements" fields: z.array(UserFilterFieldSchema).optional(), // dropdown/toggle tabs: z.array(ViewTabSchema).optional(), // tabs element reuses ViewTabSchema showAllRecords: z.boolean().optional(), });
Attached as
ListViewSchema.userFilters(view-level convenience) andInterfacePageConfigSchema.userFilters(the primary home).filterableFieldsstays as the legacy shorthand; loaders auto-upgrade it touserFilters.fieldsentries. -
Runtime visualization whitelist.
UserActionsConfigSchema.visualizations?: z.union([z.boolean(), z.array(ViewTypeEnum)])(view level) andInterfacePageConfigSchema.visualizations(page level, full 8-type enum). The existinguserFilters.elements3-type enum is kept one release as a deprecated alias and folded in. -
Semantics, not rendering. The spec defines what the author allows; capability filtering (does this object have a select field for kanban
groupBy? a date field for calendar?) is renderer responsibility and must hide non-resolvable types rather than render with hardcoded fallbacks (groupBy: 'status'against an object withoutstatusrenders garbage — observed in the current console code).
| Data mode | Interface mode | |
|---|---|---|
| Quick filters | Auto-derived from select/boolean fields (current live behavior, now blessed) | None until author configures userFilters |
| Visualization switching | On (all capability-resolvable types); user preference persists via the per-user view-patch channel | Locked to authored visualization unless whitelisted |
| User-created views | Allowed, governed by permissions + view sharing |
Never |
| Filter state | Session-scoped (URL-param sync is a later, orthogonal step) | Session-scoped |
- Default output: objects + list views + navigation → objects. No pages. Rationale: data mode is a functional superset; a missing page costs polish, a superfluous page costs a permanently-maintained duplicate asset. The asymmetry dictates the default.
- Generate an interface page only on explicit signals: persona split ("销售人员看到…", customer portal), capability narrowing ("users must not change views", "only filter by industry/type"), or curation language (workspace / 工作台 / Airtable-interface-like). Ambiguity resolves to no page.
- The rule ships as text in the
objectstack-uiskill (decision tree + the iron rule + this ADR as reference), so the constraint binds at generation time, not only at validation time. - Diagnostics are hard failures for AI. The metadata write path already
returns
_diagnostics; it gains reference-integrity checks —page.sourceresolves; everyuserFilters.fields[].fieldandtabs[].filter[].fieldexists on the source object; kanbangroupByis a select. Human authors keep warn-and-store (ADR-0005 tolerance); the ADR-0033 agent loop treatsvalid: falseas a failed apply and self-corrects before draft review. - Templates are the few-shot corpus.
hotcrmgains one canonical interface page (e.g. a "销售工作台" referencingall_accountswith industry/type dropdowns and a locked grid) and one view with filtertabs;app-showcasecovers the remaining permutations. Zero examples today is why zero AI generations use these features.
- Per-user saved filter presets in the cloud (Airtable lets interface
users pin filter values) — session scope first; a
sys_user_view_statechannel is a separate decision. - Ad-hoc filter-builder spec for end users — the advanced FilterBuilder remains a client feature; its output never persists as metadata.
- Page composition beyond single-source lists (multi-widget interface pages, cross-object layouts) — covered by the existing page/component schemas; this ADR only fixes the list-page run mode.
- Back-compat machinery — pre-launch premise;
filterableFieldsanduserFilters.elementsaliases are one-release courtesies, not commitments.
Positive. The Airtable-parity gap (in-page filter tabs / dropdowns, author-controlled visualization) closes mostly by formalizing what already runs; one orchestrator line, one schema block, and template examples deliver the visible feature. AI authors get a closed loop: schema they can see, diagnostics that push back, examples to imitate, and a default that is correct when they emit nothing. The two modes stop being implicit — navigation type is the single, auditable mode switch.
Negative / accepted. Two places can now declare userFilters
(view + page); precedence is fixed (page overrides view) and documented, but
it is still a second place. Interface pages remain a thinner v1 than
Airtable's (no per-user saved state). Warn-only validation persists for human
paths — we accept schema-invalid human metadata surviving, as today, until
ADR-0027 promotion tightens it.
| Phase | Repo | Work | Size |
|---|---|---|---|
| 1 | framework | UserFiltersSchema + userActions.visualizations + InterfacePageConfig rework in spec; reference-integrity diagnostics |
S |
| 2 | objectui | ObjectView: forward viewDef.tabs; import spec types (kill the local duplicate); wire showViewSwitcher behind the whitelist + capability filter |
S |
| 3 | framework | Template examples (hotcrm workbench page, tabbed view); objectstack-ui skill decision rules |
S |
| 4 | objectui | PageView renders interfaceConfig (single-source list + UserFilters toolbar, locked toolbar policy) |
M |
| 5 | both | Studio authoring panel for user filters / visualizations (Airtable right-panel parity); AI loop treats diagnostics as hard failure | M |
Phases 1–3 are independently shippable and deliver the user-visible feature in data mode; phase 4 activates interface mode; phase 5 completes the authoring experience.
- Should interface-mode filter state sync to URL params for shareable links (Salesforce-style) in v1, or stay purely in memory?
- Does
userFilterson the view level survive long-term, or do we eventually deprecate it in favor of page-level-only once PageView ships? - Capability filtering for visualizations: renderer-local heuristics now —
does the spec eventually need explicit per-type binding declarations
(
kanban.groupByFieldrequired) surfaced as authoring-time diagnostics?