ADR-0047: Two run modes for object UI — data views vs interface pages, user filters, and runtime visualization choice
Status: Accepted (2026-06-12; revised 2026-06-19) — framework scope implemented (UserFiltersSchema/InterfacePageConfigSchema + the 2026-07-07 object-list narrowing, reference-integrity diagnostics); the PageView render + Studio panel phases live in objectui.
Revision (2026-06-19) — the "iron rule" is superseded. Validating the Studio config panel against Airtable showed Airtable has no "inherit from a separate view" concept: an interface page defines its data surface (columns, sort, base filter, visualizations, toolbar) directly. ObjectStack now matches this —
InterfacePageConfigcarries its owncolumns/sort/filterByand the page editor edits them in place.sourceViewis deprecated and kept only as a runtime back-compat fallback. TL;DR #2 below no longer holds; the rest of the ADR (two run modes, userFilters, visualization whitelist) stands.Amendment (2026-07-07) —
dropdownuser filters are allowed on object list views again (framework #2679 / objectui #2338). The phase-4 rollout had blanket-suppressed alluserFilterson object list views ("views" mode):ObjectListViewSchemaomitted the field, thevalidatelist-view-mode rule errored on it, and objectui'sObjectViewforceduserFilters: undefinedat render. That over-corrected against TL;DR #5 (data mode is supposed to expose quick filters). The narrowed rule: an object list view MAY carry adropdown(per-field value-chip)userFilters; only thetabspreset style stays page-only, because an object view's saved-viewViewTabBaralready owns the tab-bar role and atabsuser-filter would render a second, conflicting bar (need named presets on an object → uselistViews). Enforced by a newObjectUserFiltersSchema(element narrowed todropdown/toggle,tabs/showAllRecordsomitted), whichObjectListViewSchemanow.extends back in. 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(consoleObjectView/PageView,plugin-list), framework templates (hotcrm,app-showcase), theobjectstack-uiauthoring 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.(Superseded by the 2026-06-19 revision — see banner above.) The page now definescolumns/sort/filterBydirectly (Airtable parity). Drift is avoided not by forbidding columns on the page, but by making the page the single place they live (no second view to drift against).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 |
Airtable's User-filters control is a single tri-state selector
(None / Tabs / Dropdown). We deliberately do not mirror "none" as a
literal enum value: "none" is the ABSENCE of userFilters.
Rationale (declarative-metadata hygiene):
- Consistency. Every optional capability in the protocol is "off = key
absent" (
kanban,grouping, …). A literalelement: 'none'would be the one special case authors and tooling must learn. - No dead config.
element: 'none'would leave an object whosefields/tabsare orphaned — undefined semantics for validation, overlay merge, and AI generation. Omission has one unambiguous meaning. - Cleaner diffs / overlays. Disabling the bar is a key deletion (ADR-0005 overlay semantics), not a value mutation dragging stale sub-config along.
- Orthogonal axes. "Is there a filter bar?" (presence) and "what style?"
(
element) are independent; one enum would couple them.
toggle is deprecated; authoring offers Airtable's three. The element
enum keeps dropdown | tabs | toggle for back-compat (existing configs keep
rendering), but toggle is not an authoring choice: it overlaps tabs
(presets) and dropdown (per-field values) without adding expressive power,
needs per-field defaultValues to be useful at all, and was the least-
exercised path. A homogeneous "everything is a toggle" bar only fits all-boolean
field sets — a narrow case better served by letting field type drive the
control inside dropdown. If stackable one-click quick-filters become a
validated need, design them explicitly (à la Linear filter chips), not via the
half-spec'd toggle.
Storage and authoring UI are separate layers. The Studio editor exposes a
first-class None / Tabs / Dropdown segmented selector (the filter-mode
widget) — selecting None writes onChange(undefined), removing the key.
Authors get Airtable's explicit affordance; the protocol stays clean.
If a "disable but remember the configured fields/tabs" need ever arises, the
right shape is a separate enabled: false flag — never element: 'none'.
- 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?