Status: Accepted · Implemented Phases 1–4 (2026-05-30) · Phase 5 (Studio designer) deferred
Deciders: ObjectStack Protocol Architects
Builds on: ADR-0003 (package as first-class citizen), ADR-0005 (one Zod source per type, org overlay), ADR-0010 (L1/L2/L3 protection)
Consumers: @objectstack/spec, @objectstack/metadata, @objectstack/objectql, @objectstack/rest, @objectstack/platform-objects, ../objectui (runtime switcher + Studio designer)
The original view model exposed a single per-object aggregated container:
defineView({
list, // primary list view
listViews, // { all, pipeline, … } — secondary list views
formViews, // { default, … } — form views
})A view's identity was the object name, derived at backend
registration from list.data.object. All of an object's views lived in
one metadata document, registered under the bare <object> key.
This coupling broke down once we accepted two facts about the domain:
- Views inherently re-aggregate. "Object has-many view" is the data model, not an implementation cost. A frontend user defines a new view at any time — a "My hot leads" list, a personal kanban — and it must slot into the same switcher as the package-shipped views.
- A view needs its own identity, owner, and visibility. A single
aggregated container cannot carry per-view
scope(who sees it),owner(whose personal view it is), or independent create/rename/delete/permission boundaries.
You cannot model runtime, user-authored, owner-scoped views as keys inside one object-owned document. Each view must be a first-class entity.
- Make each view an independent entity with a qualified identity.
- Support runtime user-created views (created from the console, not source).
- A three-layer scope model —
package/shared/personal— matching Airtable / Salesforce list-view semantics. - An aggregating switcher: an object surfaces all its views (package + shared + personal-for-this-user) in one place.
- Zero-migration back-compat for existing
defineViewsources.
- Changing the
ListView/FormViewconfig schema. A ViewItem wraps the existing config; it does not redefine it. - Per-view physical tables. Runtime views are rows in one typed object, not bespoke storage.
A View is an independent metadata entity (ViewItem), and an Object
has-many ViewItem.
ViewItem— name<object>.<viewKey>(qualified, globally unique), anobjectforeign key, aviewKind(list|form), ascope, and the existingListView/FormViewpayload underconfig.- Three scopes (a layer axis, distinct from ADR-0010
_provenance):package— ships from*.view.tssource; immutable at runtime (not deletable / hideable / overridable in the switcher).shared— authored at runtime, org-wide; gated byview.manageShared.personal— authored at runtime, visible only to itsowner; available to any user with object read.
- Dual-read loader — existing aggregated
defineViewcontainers are auto-expanded into ViewItems at load time and still registered under the bare<object>key, so legacy consumers keep working with no source change. getViewsByObject()/GET /meta/view?object=— the aggregation query that powers the switcher.sys_view_definition— a typed system object that stores the runtimeshared+personallayers. CRUD flows through ObjectQL's generic data API (/api/v1/data/sys_view_definition); there are no bespoke per-view REST endpoints.
packages/spec/src/ui/view.zod.ts:
ViewItemNameSchema—^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$(qualified<object>.<viewKey>).ViewScopeSchema—z.enum(['package','shared','personal'])(namedscopeto avoid clashing with ADR-0010_provenance).ViewKindSchema—z.enum(['list','form']).ViewItemSchema— adiscriminatedUnion('viewKind', …): alistbranch carriesconfig: ListViewSchema, aformbranch carriesconfig: FormViewSchema, both spread...MetadataProtectionFields.defineViewItem(config)factory +ViewItem/ViewScope/ViewKindtypes.defineViewis retained (dual-read expands it).
packages/metadata/src/plugin.ts:
isAggregatedViewContainer(item)— true when an item has noviewKindbut does carrylist/form/listViews/formViews.expandViewContainer(object, container)— emits oneViewItemperlistViewsentry (then the defaultlist, deduped by structural signature so alistidentical tolistViews.allcollapses to one), then performViewsentry; flagsisDefault, clones eachconfig, assignsscope: 'package'and a qualified name.- The loader registers both the aggregated container (bare
<object>key, back-compat) and every expandedViewItem(<object>.<key>).
packages/metadata/src/metadata-manager.ts — getViewsByObject(object)
lists view, keeps v.viewKind && v.object === object, sorts by
(order, name). Returns the package layer only; REST merges runtime
rows on top.
packages/platform-objects/src/metadata/sys-view-definition.object.ts —
a sys_-prefixed system object (auto-provisioned). Columns:
name, object, view_kind, label, is_default, view_order,
scope (shared|personal), owner, hidden, config (JSON),
organization_id, state, audit fields. Unique partial index on
(name, organization_id, owner) where state='active' so a shared view
(owner NULL) and each user's personal views never collide.
Rationale for a dedicated object rather than the
sys_metadataoverlay: runtime views are data, not admin metadata customisation. They carry anownerand a visibilityscopeand are queried per-user — a personal "My hot leads" view must never pollute the global metadata registry.
packages/rest/src/rest-server.ts — GET /meta/:type gains an
?object= filter for type === 'view': returns only expanded ViewItems
bound to that object, sorted, handling both array and {items:[]}
response shapes.
packages/data-objectstack/src/index.ts — the adapter's runtime-view
CRUD (listViews / createView / updateView / deleteView) is
repointed from the legacy metadata view overlay onto
sys_view_definition via the generic data API:
listViews(object, { owner })returnsscope='shared'rows plus this user'spersonalrows (server applies org isolation; the scope filter is defence-in-depth).update/deleteresolve(object, name) → record idthrough a query, then mutate by id.viewDefRowToSpec/viewSpecToRecordmap between the flat view-spec the tab-bar consumes and the typed columns (config nested; identity hoisted).
packages/app-shell/src/views/ObjectView.tsx — the console switcher
aggregates two sources it already merged: package views via
objectDef.listViews (the dual-read aggregated container, surfaced by
MetadataProvider) and shared/personal via dataSource.listViews.
New and duplicated views default to the personal layer owned by the
creator (Airtable parity).
scope (this ADR) and _provenance (ADR-0010) are orthogonal axes:
| Axis | Values | Means |
|---|---|---|
scope |
package / shared / personal | Which layer a view belongs to and who sees it. |
_provenance |
package / org / env-forced | Where a metadata override came from and whether it is locked. |
Switcher behaviour by scope:
| Scope | Visible to | Mutable at runtime | Default-able |
|---|---|---|---|
package |
everyone | ❌ (duplicate to customise) | source-defined |
shared |
everyone in org | ✅ with view.manageShared |
✅ |
personal |
owner only |
✅ (owner) | ✅ (for owner) |
- Existing
defineViewsources — no migration required. The dual-read loader (§3.2) auto-expands them into package ViewItems while keeping the aggregated container registered. Sources compile and load unchanged. - Optional codemod
defineView → defineViewItem(scripts/codemod/view-to-viewitem.mjs) for teams that want to author independent views explicitly. It is optional precisely because dual-read makes it unnecessary for correctness. - Pre-existing runtime views that were written to the metadata
viewoverlay (beforesys_view_definitionexisted) are migrated one-time intosys_view_definition(scope='shared',owner=NULL). Seescripts/migrate/overlay-views-to-sys-view-definition.md.
- Runtime users create / own / share views without touching source.
- One coherent switcher per object across all three layers.
- Package views are protected by construction (not in the mutable set).
- Reuses the generic ObjectQL data API — no bespoke per-view endpoints.
- Two reads per object page (package meta +
sys_view_definitiondata). Both are cached; the data read 404-degrades gracefully when the object isn't provisioned. - Runtime view ownership is enforced client-side today (scope filter + server org isolation), pending row-level security (§7).
defineViewremains a supported authoring shorthand; it is now sugar over a set ofViewItems.
- Row-level security on
sys_view_definition. Owner-scoping ofpersonalviews is a client filter plus org isolation today. The long-term enforcement is an ObjectQL sharing rule (scope='shared' OR owner = $currentUser) so the server never returns another user's personal views. view.manageSharedenforcement. The permission gates the shared layer in the UI; backend enforcement on create/update ofscope='shared'rows is pending.- Studio designer (Phase 5). An Airtable-style per-object view designer (left-rail view list, single-view editor, inline rename/duplicate/delete/reorder/set-default) is deferred until the concurrent metadata-admin work merges, to avoid file conflicts.