Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions .changeset/view-type-page-member.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
---
"@objectstack/spec": minor
"@objectstack/lint": minor
"@objectstack/metadata-protocol": patch
---

feat(spec,lint,metadata-protocol): a `page` member on the `view` type enum — mount an already-published page on an object view (#13216)

A custom page created and published at runtime through the metadata API had no
in-protocol way to reach an end user (#13100's evidence map). App navigation is
closed to runtime content (`app.allowOrgOverride: false`), and the `view` `type`
enum — on one of the five types the platform deliberately leaves open
(`allowOrgOverride: true`, `allowRuntimeCreate: true`) — was closed over
declarative row renderers, so a published page could not be mounted as an
object's list view or tab.

Maintainer ruling 2026-08-29 (live director session, verbatim 「同意」), 方向 1:

> `view` 的 `type` 枚举新增 `page` 成员——对象的列表视图/标签页可挂载一个已发布页面。走平台**有意开着**的门(`view` 本就 `allowOrgOverride=true` + 运行时可创建),零新增授权面;设计要点:`page` 型 view 需声明 `pageName` 绑定,校验目标页面存在,渲染委托既有页面渲染器

**Zero new authorization surface, as the ruling's basis requires.** Nothing in
this change touches a metadata type's `allowOrgOverride` / `allowRuntimeCreate`
flags, adds a write door, or adds a read door. A `page` view is a `view` written
through the door `view` already opens, and it holds a NAME — the page itself is
still fetched through the page read path it already had, and still renders
through the existing page renderer, so the page's own audience gate
(`page.assignedProfiles`) rides along unchanged. Delegation is what preserves
that: a second renderer is what would have introduced a second gate.

**The binding, refused in both directions at parse.** `ListViewSchema` gains
`pageName`, declared with `SnakeCaseIdentifierSchema` — the same grammar
`PageSchema.name` carries, so the accepted set is exactly the set of strings that
could name a page. `checkListViewPageMount` then refuses:

- `type: 'page'` with no `pageName` — unlike every other view type there is no
degraded rendering to fall back to, so the view would be blank;
- `pageName` on any other view type — the accepted-and-ignored shape;
- a non-empty `columns` beside a page mount — `columns` is the one required key
on a list view, and the only truthful value for a page mount is `[]`.

The check is attached at all three list-view doors (`ListViewSchema`,
`ObjectListViewSchema`, and the flattened runtime overlay behind
`PUT /api/v1/meta/view`), with a pinned test that fails if any attachment is
dropped.

**Existence of the target page** is answered where the collection is visible:
`defineStack`'s `validateCrossReferences` refuses at build time (same
`pageNames.size > 0` policy the two other page references in that function
already use), and the new `@objectstack/lint` rule `view-page-unresolved`
(`validateViewPageRefs`) resolves it on `os validate` / `os lint` / `os compile`
**and** at the runtime publish gate. Advisory, not gating, for its nav twin's
reason: with no curated cross-package page registry, "unresolved here" cannot be
told apart from "provided by a package this stack cannot see".

Reaching the runtime publish gate needed the per-write snapshot to carry the
`pages` collection (`RuntimeStackContext.pages`, threaded through
`evaluateRuntimeAuthoringGate` and read off the live registry in
`saveMetaItem`'s gate call). That is the one-key widening `RuntimeStackContext`
documents, made when a rule that reads the collection crossed the wall — never
in advance — and the false-positive channel it closes is measured both ways in
`runtime-gate.view-page-refs.test.ts`. `pages` joined `NAME_KEYED_STACK_KEYS` in
the same edit, because a collection that is both context-filled and
write-targeted must have its finding paths name-keyed (#10064).

**Downstream note (not an accept-set narrowing).** No previously valid metadata
becomes invalid: `pageName` is a new key and `page` a new enum member, so every
refusal above can only fire on a document that could not be written before.
What does change for a downstream schema author is composition: `ListViewSchema`
now carries a refinement, and zod 4 refuses `.omit()` / key-overwriting
`.extend()` on a refined object. The unrefined shape stays module-private
(publishing it would mint a duplicate protocol def and a second full set of
ratcheted authorable-surface keys), so a consumer that derived from
`ListViewSchema` by omission should compose with `.safeExtend()` or narrow after
parsing. `FormViewSchema` has had this property since its own refinement landed,
so this is the established shape for view schemas rather than a new one.

**Deliberately out of scope**, per the same ruling: 方向 2 (registering app
navigation at publish time) is deferred to its own design card — it would
require reversing the `app.allowOrgOverride: false` authorization decision — and
with it the known limitation the ruling accepts on the record, that a page
belonging to no object still has no browse-to entry. `page` is also NOT added to
`VisualizationTypeSchema`: the switcher offers alternative ways to draw the same
rows, and a page draws none.
2 changes: 1 addition & 1 deletion content/docs/references/api/metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -791,7 +791,7 @@ Metadata query with filtering, sorting, and pagination
| **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. |
| **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. |
| **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). |
| **listViews** | `Record<string, { name?: string; label?: string \| Record<string, string>; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) |
| **listViews** | `Record<string, { name?: string; label?: string \| Record<string, string>; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| >; data?: object \| … +3 more; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) |
| **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused — mirror the value onto a stored text field and declare that. |
| **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules |
| **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). |
Expand Down
10 changes: 6 additions & 4 deletions content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1595,9 +1595,9 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own
| **name** | `string` | optional | Item name — supplied by the metadata door; for an object-scoped container it is the object name. |
| **label** | `string \| Record<string, string>` | optional | Human-readable label shown in metadata lists. |
| **object** | `string` | optional | Object this container binds to — how a stack-level `views: [...]` entry says which object its views belong to; read by `getViewsByObject()` / `GET /meta/view?object=`. |
| **list** | `{ name?: string; label?: string \| Record<string, string>; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }` | optional | |
| **list** | `{ name?: string; label?: string \| Record<string, string>; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| >; data?: object \| … +3 more; … }` | optional | |
| **form** | `{ type?: Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>; layout?: Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>; columns?: integer; title?: string; … }` | optional | |
| **listViews** | `Record<string, { name?: string; label?: string \| Record<string, string>; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }>` | optional | Additional named list views (views mode — dropdown userFilters allowed, no tabs; ADR-0047) |
| **listViews** | `Record<string, { name?: string; label?: string \| Record<string, string>; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| >; data?: object \| … +3 more; … }>` | optional | Additional named list views (views mode — dropdown userFilters allowed, no tabs; ADR-0047) |
| **formViews** | `Record<string, { type?: Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>; layout?: Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>; columns?: integer; title?: string; … }>` | optional | Additional named form views |
| **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this view. |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
Expand All @@ -1614,7 +1614,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own
| :--- | :--- | :--- | :--- |
| **name** | `string` | optional | Internal view name (lowercase snake_case) |
| **label** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
| **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | |
| **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| >` | optional (default: `"grid"`) | |
| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record<string, any> }` | optional | Data source configuration (defaults to "object" provider) |
| **columns** | `string[] \| { field: string; label?: string \| Record<string, string>; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns |
| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) |
Expand All @@ -1634,6 +1634,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own
| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration |
| **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout |
| **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer } & Record<string, any>` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout |
| **pageName** | `string` | optional | Published page this view mounts — required when `type: 'page'`, and refused on every other view type. Rendering is delegated to the existing page renderer; the page keeps its own `assignedProfiles` audience. |
| **description** | `string \| Record<string, string>` | optional | View description for documentation/tooltips |
| **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration |
| **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting |
Expand Down Expand Up @@ -1698,7 +1699,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own
| :--- | :--- | :--- | :--- |
| **name** | `string` | optional | Internal view name (lowercase snake_case) |
| **label** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
| **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | |
| **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| >` | optional (default: `"grid"`) | |
| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record<string, any> }` | optional | Data source configuration (defaults to "object" provider) |
| **columns** | `string[] \| { field: string; label?: string \| Record<string, string>; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns |
| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) |
Expand All @@ -1718,6 +1719,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own
| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration |
| **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout |
| **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer } & Record<string, any>` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout |
| **pageName** | `string` | optional | Published page this view mounts — required when `type: 'page'`, and refused on every other view type. Rendering is delegated to the existing page renderer; the page keeps its own `assignedProfiles` audience. |
| **description** | `string \| Record<string, string>` | optional | View description for documentation/tooltips |
| **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration |
| **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting |
Expand Down
Loading
Loading