diff --git a/.changeset/29037-view-personal-declarations-retired.md b/.changeset/29037-view-personal-declarations-retired.md new file mode 100644 index 00000000000..ba6a0e38c59 --- /dev/null +++ b/.changeset/29037-view-personal-declarations-retired.md @@ -0,0 +1,27 @@ +--- +'@objectstack/spec': minor +'@object-ui/plugin-list': minor +'@object-ui/core': minor +'@object-ui/i18n': minor +--- + +feat(spec)!: a view no longer declares itself personal — `scope: 'personal'` and `sharing.type` are retired, because nothing ever restricted a view to one user + +Clause-②: no (narrowing) + +Metadata-protocol: narrowed + +Skills-docs: none — no file under `skills/**` teaches either declaration (a `git grep` of `skills/` finds no `scope: 'personal'` and no list-view `sharing.type`; its one `sharing` block is the public form link, which this does not touch); `content/docs/ui/audience-based-interfaces.mdx` now says data mode offers per-user list preferences, not personal views. + + + +**BREAKING** accept-set narrowing on the view surface (`@objectstack/spec`), ADR-0049 enforce-or-remove. + +A view item saved with `scope: 'personal'`, or a list view saved with `sharing: { type: 'personal' }`, was accepted and served, and every user who could read the object still saw it: both switcher read paths list every view bound to the object, and nothing read either declaration as a visibility rule. The console list toolbar, the one reader of `sharing.type`, then labelled the view "Personal view". Personal named views are ruled to be a user-owned record, not view metadata, and they do not reuse either key. + +- **FROM → TO.** `{ name, object, viewKind, scope: 'personal', … }` → `{ name, object, viewKind, … }` (omit `scope`, or write `shared` for a runtime-authored view). `list: { …, sharing: { type, lockedBy } }` (and `listViews.`, an object's `listViews.`, a list view item's `config`) → `list: { …, sharing: { lockedBy } }`. +- **The one-line fix.** Delete `scope: 'personal'` and delete `sharing.type`, whatever value it holds. `os migrate meta --from 17` lists the edits for existing sources, and `--write` applies them. A stored `sys_metadata` row and an assembled artifact are converted when they are read (the `view-personal-declarations-removed` conversion), so a view saved with either declaration keeps loading and nothing a reader sees changes. +- **What the parse says.** "`view.scope: 'personal'` was removed in @objectstack/spec 18.3.0 (ADR-0049 enforce-or-remove) — nothing ever restricted a `personal` view item to one user: …" at every door that reads a view item's `scope`, and "`view.list.sharing.type` was removed in @objectstack/spec 18.3.0 (ADR-0049 enforce-or-remove) — nothing enforced it: …" at every list-view door and at `defineStack` as `STACK_SCHEMA_INVALID` / 422. TypeScript rejects both at the authoring site. +- **Residue.** `type: 'collaborative'`, the default a released toolchain wrote into every parsed `sharing` block, is accepted and stripped, so a view built before this release parses without an edit; `personal` is refused. +- **Unchanged.** `scope: 'package'` and `scope: 'shared'`; `sharing.lockedBy`; a form view's public-link `sharing` block. +- **Console.** The list toolbar no longer labels any view personal or shared; a view with `sharing.lockedBy` still shows "Locked by …". `normalizeListViewSchema` drops the legacy `sharing: { visibility, enabled }` pair instead of folding it onto the retired `type`, and the `list.sharingPersonal` / `list.sharingCollaborative` strings are gone from every locale. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 2d1c10ea9de..7b34900ed57 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1877,7 +1877,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **description** | `string \| Record` | optional | View description for documentation/tooltips | -| **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration | +| **sharing** | `{ lockedBy?: string }` | optional | View sharing configuration: who locked the view configuration | | **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities a user must ALL hold for this list view or dashboard to be served to them — names that permission sets grant through `systemPermissions`, the same list as `requiredPermissions` on an app, a navigation item or an action. Absent or empty: no audience gate. There is no any-of form: to serve one item to several audiences (legal OR admin), declare one capability and grant it to each of their permission sets. The server enforces it: a user who does not hold them all is not listed the item and is refused it by name; a view container's list views are pruned the same way, and so are an object's own list views for a user who may not edit the object. | | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | | **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) | @@ -1956,7 +1956,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **description** | `string \| Record` | optional | View description for documentation/tooltips | -| **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration | +| **sharing** | `{ lockedBy?: string }` | optional | View sharing configuration: who locked the view configuration | | **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities a user must ALL hold for this list view or dashboard to be served to them — names that permission sets grant through `systemPermissions`, the same list as `requiredPermissions` on an app, a navigation item or an action. Absent or empty: no audience gate. There is no any-of form: to serve one item to several audiences (legal OR admin), declare one capability and grant it to each of their permission sets. The server enforces it: a user who does not hold them all is not listed the item and is refused it by name; a view container's list views are pruned the same way, and so are an object's own list views for a user who may not edit the object. | | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | | **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index e95571af1cb..b95a5b4ba88 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -394,7 +394,7 @@ const result = ApiMethod.parse(data); | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **description** | `string \| Record` | optional | View description for documentation/tooltips | -| **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration | +| **sharing** | `{ lockedBy?: string }` | optional | View sharing configuration: who locked the view configuration | | **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities a user must ALL hold for this list view or dashboard to be served to them — names that permission sets grant through `systemPermissions`, the same list as `requiredPermissions` on an app, a navigation item or an action. Absent or empty: no audience gate. There is no any-of form: to serve one item to several audiences (legal OR admin), declare one capability and grant it to each of their permission sets. The server enforces it: a user who does not hold them all is not listed the item and is refused it by name; a view container's list views are pruned the same way, and so are an object's own list views for a user who may not edit the object. | | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | | **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index f5e5689d813..677578ef027 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -800,7 +800,7 @@ Map view configuration | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **description** | `string \| Record` | optional | View description for documentation/tooltips | -| **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration | +| **sharing** | `{ lockedBy?: string }` | optional | View sharing configuration: who locked the view configuration | | **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities a user must ALL hold for this list view or dashboard to be served to them — names that permission sets grant through `systemPermissions`, the same list as `requiredPermissions` on an app, a navigation item or an action. Absent or empty: no audience gate. There is no any-of form: to serve one item to several audiences (legal OR admin), declare one capability and grant it to each of their permission sets. The server enforces it: a user who does not hold them all is not listed the item and is refused it by name; a view container's list views are pruned the same way, and so are an object's own list views for a user who may not edit the object. | | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | | **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) | @@ -1026,7 +1026,7 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **type** | `Enum<'personal' \| 'collaborative'>` | optional (default: `"collaborative"`) | View ownership type | +| **type** | `never` | optional | [REMOVED] `view.list.sharing.type` was removed in @objectstack/spec 18.3.0 (ADR-0049 enforce-or-remove) — nothing enforced it: a `personal` view was listed for every user who can read the object, and `collaborative` described every view, so the key only ever drew a badge. Delete the key; `sharing.lockedBy` is unchanged. A view is visible to everyone who can read its object; personal named views are a user-owned record, not view metadata (ADR-0017). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **lockedBy** | `string` | optional | User who locked the view configuration | ### Nested Shape: `ListView.grouping` @@ -1188,7 +1188,7 @@ View filter rule | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **description** | `string \| Record` | optional | View description for documentation/tooltips | -| **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration | +| **sharing** | `{ lockedBy?: string }` | optional | View sharing configuration: who locked the view configuration | | **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities a user must ALL hold for this list view or dashboard to be served to them — names that permission sets grant through `systemPermissions`, the same list as `requiredPermissions` on an app, a navigation item or an action. Absent or empty: no audience gate. There is no any-of form: to serve one item to several audiences (legal OR admin), declare one capability and grant it to each of their permission sets. The server enforces it: a user who does not hold them all is not listed the item and is refused it by name; a view container's list views are pruned the same way, and so are an object's own list views for a user who may not edit the object. | | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | | **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) | @@ -1405,7 +1405,7 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **type** | `Enum<'personal' \| 'collaborative'>` | optional (default: `"collaborative"`) | View ownership type | +| **type** | `never` | optional | [REMOVED] `view.list.sharing.type` was removed in @objectstack/spec 18.3.0 (ADR-0049 enforce-or-remove) — nothing enforced it: a `personal` view was listed for every user who can read the object, and `collaborative` described every view, so the key only ever drew a badge. Delete the key; `sharing.lockedBy` is unchanged. A view is visible to everyone who can read its object; personal named views are a user-owned record, not view metadata (ADR-0017). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **lockedBy** | `string` | optional | User who locked the view configuration | ### Nested Shape: `ObjectListView.grouping` @@ -1770,7 +1770,7 @@ Tab configuration for multi-tab view interface | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **description** | `string \| Record` | optional | View description for documentation/tooltips | -| **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration | +| **sharing** | `{ lockedBy?: string }` | optional | View sharing configuration: who locked the view configuration | | **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities a user must ALL hold for this list view or dashboard to be served to them — names that permission sets grant through `systemPermissions`, the same list as `requiredPermissions` on an app, a navigation item or an action. Absent or empty: no audience gate. There is no any-of form: to serve one item to several audiences (legal OR admin), declare one capability and grant it to each of their permission sets. The server enforces it: a user who does not hold them all is not listed the item and is refused it by name; a view container's list views are pruned the same way, and so are an object's own list views for a user who may not edit the object. | | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | | **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) | @@ -1849,7 +1849,7 @@ Tab configuration for multi-tab view interface | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **description** | `string \| Record` | optional | View description for documentation/tooltips | -| **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration | +| **sharing** | `{ lockedBy?: string }` | optional | View sharing configuration: who locked the view configuration | | **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities a user must ALL hold for this list view or dashboard to be served to them — names that permission sets grant through `systemPermissions`, the same list as `requiredPermissions` on an app, a navigation item or an action. Absent or empty: no audience gate. There is no any-of form: to serve one item to several audiences (legal OR admin), declare one capability and grant it to each of their permission sets. The server enforces it: a user who does not hold them all is not listed the item and is refused it by name; a view container's list views are pruned the same way, and so are an object's own list views for a user who may not edit the object. | | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | | **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) | @@ -2104,8 +2104,8 @@ This schema accepts one of the following structures: | **label** | `string \| Record` | optional | Display label (supports i18n). | | **isDefault** | `boolean` | optional | Whether this is the object's default view in the switcher. | | **order** | `integer` | optional | Sort order within the object's view switcher / left rail. | -| **scope** | `Enum<'package' \| 'shared' \| 'personal'>` | optional | Identity layer (defaults to `package` for source-loaded views). | -| **owner** | `never` | optional | [REMOVED] `view.owner` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the user a `personal` view item belonged to, and nothing ever read it: the view switcher (`GET /meta/view?object=`) serves every item bound to the object without looking at `owner`, so a view marked as one user's was listed for every user who can read the object. Delete the key. Nothing restricts a view item to one user today — per-user view scoping is a parked direction (ADR-0017), not a shipped mechanism — so a view item is visible to everyone who can read its object. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **scope** | `Enum<'package' \| 'shared'>` | optional | Identity layer (defaults to `package` for source-loaded views). | +| **owner** | `never` | optional | [REMOVED] `view.owner` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the user a `personal` view item belonged to, and nothing ever read it: the view switcher (`GET /meta/view?object=`) serves every item bound to the object without looking at `owner`, so a view marked as one user's was listed for every user who can read the object. Delete the key. Nothing restricts a view item to one user — personal named views are a user-owned record, not a view item (ADR-0017) — so a view item is visible to everyone who can read its object. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **hidden** | `never` | optional | [REMOVED] `view.hidden` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it promised to hide a view item from the switcher, and nothing ever read it: `GET /meta/view?object=` and the console's view switcher list every item bound to the object, `hidden: true` included. Delete the key; to take a view out of the switcher, delete the view item itself (or stop shipping it from source). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **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). | @@ -2146,7 +2146,7 @@ This schema accepts one of the following structures: | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **description** | `string \| Record` | optional | View description for documentation/tooltips | -| **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration | +| **sharing** | `{ lockedBy?: string }` | optional | View sharing configuration: who locked the view configuration | | **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities a user must ALL hold for this list view or dashboard to be served to them — names that permission sets grant through `systemPermissions`, the same list as `requiredPermissions` on an app, a navigation item or an action. Absent or empty: no audience gate. There is no any-of form: to serve one item to several audiences (legal OR admin), declare one capability and grant it to each of their permission sets. The server enforces it: a user who does not hold them all is not listed the item and is refused it by name; a view container's list views are pruned the same way, and so are an object's own list views for a user who may not edit the object. | | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | | **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) | @@ -2198,8 +2198,8 @@ This schema accepts one of the following structures: | **label** | `string \| Record` | optional | Display label (supports i18n). | | **isDefault** | `boolean` | optional | Whether this is the object's default view in the switcher. | | **order** | `integer` | optional | Sort order within the object's view switcher / left rail. | -| **scope** | `Enum<'package' \| 'shared' \| 'personal'>` | optional | Identity layer (defaults to `package` for source-loaded views). | -| **owner** | `never` | optional | [REMOVED] `view.owner` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the user a `personal` view item belonged to, and nothing ever read it: the view switcher (`GET /meta/view?object=`) serves every item bound to the object without looking at `owner`, so a view marked as one user's was listed for every user who can read the object. Delete the key. Nothing restricts a view item to one user today — per-user view scoping is a parked direction (ADR-0017), not a shipped mechanism — so a view item is visible to everyone who can read its object. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **scope** | `Enum<'package' \| 'shared'>` | optional | Identity layer (defaults to `package` for source-loaded views). | +| **owner** | `never` | optional | [REMOVED] `view.owner` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the user a `personal` view item belonged to, and nothing ever read it: the view switcher (`GET /meta/view?object=`) serves every item bound to the object without looking at `owner`, so a view marked as one user's was listed for every user who can read the object. Delete the key. Nothing restricts a view item to one user — personal named views are a user-owned record, not a view item (ADR-0017) — so a view item is visible to everyone who can read its object. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **hidden** | `never` | optional | [REMOVED] `view.hidden` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it promised to hide a view item from the switcher, and nothing ever read it: `GET /meta/view?object=` and the console's view switcher list every item bound to the object, `hidden: true` included. Delete the key; to take a view out of the switcher, delete the view item itself (or stop shipping it from source). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **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). | @@ -2287,8 +2287,8 @@ This schema accepts one of the following structures: | **label** | `string \| Record` | optional | Display label (supports i18n). | | **isDefault** | `boolean` | optional | Whether this is the object's default view in the switcher. | | **order** | `integer` | optional | Sort order within the object's view switcher / left rail. | -| **scope** | `Enum<'package' \| 'shared' \| 'personal'>` | optional | Identity layer (defaults to `package` for source-loaded views). | -| **owner** | `never` | optional | [REMOVED] `view.owner` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the user a `personal` view item belonged to, and nothing ever read it: the view switcher (`GET /meta/view?object=`) serves every item bound to the object without looking at `owner`, so a view marked as one user's was listed for every user who can read the object. Delete the key. Nothing restricts a view item to one user today — per-user view scoping is a parked direction (ADR-0017), not a shipped mechanism — so a view item is visible to everyone who can read its object. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **scope** | `Enum<'package' \| 'shared'>` | optional | Identity layer (defaults to `package` for source-loaded views). | +| **owner** | `never` | optional | [REMOVED] `view.owner` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the user a `personal` view item belonged to, and nothing ever read it: the view switcher (`GET /meta/view?object=`) serves every item bound to the object without looking at `owner`, so a view marked as one user's was listed for every user who can read the object. Delete the key. Nothing restricts a view item to one user — personal named views are a user-owned record, not a view item (ADR-0017) — so a view item is visible to everyone who can read its object. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **hidden** | `never` | optional | [REMOVED] `view.hidden` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it promised to hide a view item from the switcher, and nothing ever read it: `GET /meta/view?object=` and the console's view switcher list every item bound to the object, `hidden: true` included. Delete the key; to take a view out of the switcher, delete the view item itself (or stop shipping it from source). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **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). | @@ -2334,7 +2334,7 @@ This schema accepts one of the following structures: | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **description** | `string \| Record` | optional | View description for documentation/tooltips | -| **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration | +| **sharing** | `{ lockedBy?: string }` | optional | View sharing configuration: who locked the view configuration | | **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities a user must ALL hold for this list view or dashboard to be served to them — names that permission sets grant through `systemPermissions`, the same list as `requiredPermissions` on an app, a navigation item or an action. Absent or empty: no audience gate. There is no any-of form: to serve one item to several audiences (legal OR admin), declare one capability and grant it to each of their permission sets. The server enforces it: a user who does not hold them all is not listed the item and is refused it by name; a view container's list views are pruned the same way, and so are an object's own list views for a user who may not edit the object. | | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | | **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) | @@ -2393,8 +2393,8 @@ This schema accepts one of the following structures: | **label** | `string \| Record` | optional | Display label (supports i18n). | | **isDefault** | `boolean` | optional | Whether this is the object's default view in the switcher. | | **order** | `integer` | optional | Sort order within the object's view switcher / left rail. | -| **scope** | `Enum<'package' \| 'shared' \| 'personal'>` | optional | Identity layer (defaults to `package` for source-loaded views). | -| **owner** | `never` | optional | [REMOVED] `view.owner` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the user a `personal` view item belonged to, and nothing ever read it: the view switcher (`GET /meta/view?object=`) serves every item bound to the object without looking at `owner`, so a view marked as one user's was listed for every user who can read the object. Delete the key. Nothing restricts a view item to one user today — per-user view scoping is a parked direction (ADR-0017), not a shipped mechanism — so a view item is visible to everyone who can read its object. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **scope** | `Enum<'package' \| 'shared'>` | optional | Identity layer (defaults to `package` for source-loaded views). | +| **owner** | `never` | optional | [REMOVED] `view.owner` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the user a `personal` view item belonged to, and nothing ever read it: the view switcher (`GET /meta/view?object=`) serves every item bound to the object without looking at `owner`, so a view marked as one user's was listed for every user who can read the object. Delete the key. Nothing restricts a view item to one user — personal named views are a user-owned record, not a view item (ADR-0017) — so a view item is visible to everyone who can read its object. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **hidden** | `never` | optional | [REMOVED] `view.hidden` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it promised to hide a view item from the switcher, and nothing ever read it: `GET /meta/view?object=` and the console's view switcher list every item bound to the object, `hidden: true` included. Delete the key; to take a view out of the switcher, delete the view item itself (or stop shipping it from source). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **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). | @@ -2480,26 +2480,25 @@ Whether `config` is a ListView (list family) or a FormView. ## ViewScope -View identity layer: package | shared | personal. +View identity layer: package | shared. ### Allowed Values * `package` * `shared` -* `personal` --- ## ViewSharing -View sharing and access configuration +View sharing configuration — who locked the view configuration ### Properties | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **type** | `Enum<'personal' \| 'collaborative'>` | optional (default: `"collaborative"`) | View ownership type | +| **type** | `never` | optional | [REMOVED] `view.list.sharing.type` was removed in @objectstack/spec 18.3.0 (ADR-0049 enforce-or-remove) — nothing enforced it: a `personal` view was listed for every user who can read the object, and `collaborative` described every view, so the key only ever drew a badge. Delete the key; `sharing.lockedBy` is unchanged. A view is visible to everyone who can read its object; personal named views are a user-owned record, not view metadata (ADR-0017). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **lockedBy** | `string` | optional | User who locked the view configuration | diff --git a/content/docs/ui/audience-based-interfaces.mdx b/content/docs/ui/audience-based-interfaces.mdx index 9054e15baba..8391a46788b 100644 --- a/content/docs/ui/audience-based-interfaces.mdx +++ b/content/docs/ui/audience-based-interfaces.mdx @@ -48,7 +48,7 @@ Each nav item supports three independent gates ([`app.zod`](/docs/references/ui/ ### 2. Hand end users a page, not the raw table -In **data mode** (`navigation → object`) users get the permissive grid: switchable views, personal views, a full toolbar. In **interface mode** (`navigation → page`) the author curates exactly what's exposed — selected columns, fixed visualisation, only the filters and actions you enabled. For end users, prefer interface-mode pages. ADR-0047's default is deliberately asymmetric: data mode is open, interface mode is closed until the author opens it. +In **data mode** (`navigation → object`) users get the permissive grid: switchable views, per-user list preferences, a full toolbar. In **interface mode** (`navigation → page`) the author curates exactly what's exposed — selected columns, fixed visualisation, only the filters and actions you enabled. For end users, prefer interface-mode pages. ADR-0047's default is deliberately asymmetric: data mode is open, interface mode is closed until the author opens it. ### 3. Curate the data surface itself diff --git a/packages/metadata-protocol/src/protocol.graft-folded-form-sections.test.ts b/packages/metadata-protocol/src/protocol.graft-folded-form-sections.test.ts index fdbef31679e..7b89a6f8d4d 100644 --- a/packages/metadata-protocol/src/protocol.graft-folded-form-sections.test.ts +++ b/packages/metadata-protocol/src/protocol.graft-folded-form-sections.test.ts @@ -526,7 +526,7 @@ describe('#20186 the save door judges a flattened overlay by the member its view it.each([ ['aria', { aria: { ariaLabel: 'Leads' } }], ['an i18n description', { description: { en: 'All leads' } }], - ['list-style sharing', { sharing: { type: 'personal' } }], + ['list-style sharing', { sharing: { lockedBy: 'admin' } }], ])('W2: %s now saves verbatim', async (_label, extra) => { const body = { ...LIST, ...extra }; expect(await storedViewBody(body.name, body)).toEqual(body); diff --git a/packages/spec/authorable-defaults/ui.json b/packages/spec/authorable-defaults/ui.json index df19866021e..93d86a4c716 100644 --- a/packages/spec/authorable-defaults/ui.json +++ b/packages/spec/authorable-defaults/ui.json @@ -113,7 +113,6 @@ "ui/UserActionsConfig:search = true", "ui/UserActionsConfig:sort = true", "ui/UserFilters:element = \"dropdown\"", - "ui/ViewSharing:type = \"collaborative\"", "ui/ViewTab:isDefault = false", "ui/ViewTab:pinned = false", "ui/ViewTab:visible = true" diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index c857b8accf3..27445fbdf28 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -1378,7 +1378,7 @@ "ui/ViewFilterRule:operator", "ui/ViewFilterRule:value", "ui/ViewSharing:lockedBy", - "ui/ViewSharing:type", + "ui/ViewSharing:type [RETIRED]", "ui/ViewTab:filter", "ui/ViewTab:icon", "ui/ViewTab:isDefault", diff --git a/packages/spec/liveness/view.json b/packages/spec/liveness/view.json index 824b9507c0d..3a1bdb3aa6c 100644 --- a/packages/spec/liveness/view.json +++ b/packages/spec/liveness/view.json @@ -145,10 +145,10 @@ }, "sharing": { "status": "live", - "verifiedAt": "2026-08-10", + "verifiedAt": "2026-11-02", "evidenceScope": "in-repo", - "evidence": "ui/packages/plugin-list/src/ListView.tsx (:2544 at objectui @11c1e71e) gates the toolbar sharing badge on schema.sharing.type, :2549 renders its tooltip from the same value and :2573 counts it when laying out the toolbar's left side; ui/packages/plugin-list/src/__tests__/ListView.test.tsx:2572 pins the lockedBy tooltip — measured objectui @11c1e71e", - "note": "RE-CITED 2026-08-10 (#7133/#7142): the old citation was the TEST ALONE, at ListView.test.tsx:2276. The test survives with its content intact — it is now packages/plugin-list/src/__tests__/ListView.test.tsx:2428, 'should display lockedBy in sharing tooltip when set' — so this was line drift, not deletion. A test is the weaker half of a liveness claim, so the production reader is cited first and the test kept as the pin. Verdict unchanged: LIVE. Supersedes the 2026-06 audit's 'sharing.lockedBy dead' line." + "evidence": "ui/packages/plugin-list/src/ListView.tsx#sharingLockedBy (the toolbar's read-only lock indicator draws `Locked by …` from schema.sharing.lockedBy, and the same helper counts it when laying out the toolbar's left side); ui/packages/plugin-list/src/__tests__/ListView.test.tsx pins it ('shows who locked the view from `sharing.lockedBy`')", + "note": "RE-CITED 2026-11-02 (#29037): the block's ownership `type` was RETIRED (ADR-0049; a `retiredKey()` tombstone with the released `collaborative` default accepted as residue, D2 `view-personal-declarations-removed`), so the badge that read it now draws only the lock: the row stays live on `lockedBy` alone, the one key its one reader reads. The sub-key cannot be drilled here (one level only); the tombstone is registered as `ui/ViewSharing:type`. Earlier: RE-CITED 2026-08-10 (#7133/#7142): the old citation was the TEST ALONE, at ListView.test.tsx:2276. The test survives with its content intact — it is now packages/plugin-list/src/__tests__/ListView.test.tsx:2428, 'should display lockedBy in sharing tooltip when set' — so this was line drift, not deletion. A test is the weaker half of a liveness claim, so the production reader is cited first and the test kept as the pin. Verdict unchanged: LIVE. Supersedes the 2026-06 audit's 'sharing.lockedBy dead' line." }, "requiredPermissions": { "status": "live", diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 46dc0b14d60..8ba55456e27 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -2689,6 +2689,179 @@ const permissionSetManagedByRemoved: MetadataConversion = { }, }; +/** + * The two inert `personal` declarations leave the view (protocol 18, #29037 — + * ADR-0049 enforce-or-remove; ruling of record 6570568131 on #28971, letter C: + * personal named views are a user-owned record, not a view item, and they do + * not reuse either key). + * + * - `scope: 'personal'` on a view item record or a flattened overlay + * (`ViewScopeSchema`, now `enumWithRetiredValues`): nothing ever restricted + * such an item to one user — both switcher read paths list every item bound + * to the object. The member is DELETED: the item lands on an absent + * `scope`, which is what the prescription tells an author to do by hand, and + * what every reader saw anyway. The conversion invents no `shared`. + * - `sharing.type` on every list payload (`ViewSharingSchema`, now a + * `retiredKey()` tombstone with the `collaborative` default accepted as + * residue): its one reader was the console list toolbar's badge, which + * claimed "Personal view" for a view everyone could read. The KEY is deleted, + * both values; `sharing.lockedBy` stays, and a block left with no key stays + * as an empty block, the choice `navigation-mode-overlay-values-removed` + * makes for its own container. + * + * Reach — every carrier of either declaration: + * + * - `scope`: the `views` collection and the assembled-manifest `viewItems` + * channel ({@link ASSEMBLED_VIEW_ITEMS_KEY}), on a record (`config` present) + * or a flattened overlay; a container declares no `scope` and is skipped; + * - `sharing.type`: every list payload {@link mapViewPayloads} reaches (a + * container's `list` / `listViews.*`, a list item's `config`, a flattened + * list overlay), the same three spellings in `viewItems`, and an object's + * `listViews.*`. No page block declares `sharing`. + * + * `PUT /meta/view` persisted both through 18.2.0, so a stored `view` row CAN + * carry them, and every artifact a released toolchain built carries + * `type: 'collaborative'` wherever a `sharing` block was authored. **Retired + * from the load path from day one**: a live author meets the parse's + * prescription, and this entry serves stored-row rehydration, assembled + * artifacts and `os migrate meta`. Idempotent by construction: a converted + * row carries neither declaration. + * + * The D3 entry `ui-view-personal-declarations-retired` carries what the edit + * cannot decide: whether a view its author meant as personal should stay as a + * view everyone sees, be deleted, or wait for the user-owned personal view. + */ +const personalViewDeclarationsRemoved: MetadataConversion = { + id: 'view-personal-declarations-removed', + toMajor: 18, + retiredFromLoadPath: true, + retiredAfter: '18.2.0', + surface: + "view.scope ('personal') — on a view item record or a flattened overlay / view.list.sharing.type / " + + 'view.listViews..sharing.type / object.listViews..sharing.type', + summary: + "the two inert `personal` declarations removed: `scope: 'personal'` (nothing restricted a view item to one " + + 'user) and `sharing.type` (it only drew a badge, and claimed "personal" for a view everyone could read). ' + + 'The `personal` member and the `type` key, whichever value it holds, are deleted; `sharing.lockedBy` is ' + + 'unchanged', + apply(stack, emit) { + const stripScope = (view: Dict, path: string): Dict => { + if (view.scope !== 'personal') return view; + if (view.list !== undefined || view.form !== undefined) return view; + if (view.listViews !== undefined || view.formViews !== undefined) return view; + emit({ from: 'personal', to: '(removed)', path: `${path}.scope` }); + const next: Dict = { ...view }; + delete next.scope; + return next; + }; + const convertList = (payload: Dict, path: string): Dict => { + const sharing = payload.sharing; + if (!isDict(sharing)) return payload; + const stripped = stripKeys(sharing, ['type'], emit, `${path}.sharing`); + return stripped === sharing ? payload : { ...payload, sharing: stripped }; + }; + + let next = mapCollection(mapCollection(stack, 'views', stripScope), ASSEMBLED_VIEW_ITEMS_KEY, stripScope); + next = mapViewPayloads(next, (payload, kind, path) => (kind === 'list' ? convertList(payload, path) : payload)); + next = mapCollection(next, ASSEMBLED_VIEW_ITEMS_KEY, (item, path) => { + if (item.viewKind !== 'list') return item; + if (isDict(item.config)) { + const config = convertList(item.config, `${path}.config`); + return config === item.config ? item : { ...item, config }; + } + // A flattened list overlay — the body IS the payload. A present but + // malformed `config` is neither shape and is left for the parse. + return item.config === undefined ? convertList(item, path) : item; + }); + return mapCollection(next, 'objects', (obj, path) => { + const listViews = obj.listViews; + if (!isDict(listViews)) return obj; + let changed = false; + const rebuilt: Dict = { ...listViews }; + for (const [name, entry] of Object.entries(listViews)) { + if (!isDict(entry)) continue; + const mapped = convertList(entry, `${path}.listViews.${name}`); + if (mapped === entry) continue; + rebuilt[name] = mapped; + changed = true; + } + return changed ? { ...obj, listViews: rebuilt } : obj; + }); + }, + fixture: { + before: { + // The assembled-manifest channel: a record a package export carried + // before this release, with both declarations. + viewItems: [ + { + name: 'vpd_lead.mine', + object: 'vpd_lead', + viewKind: 'list', + scope: 'personal', + config: { type: 'grid', columns: ['name'], sharing: { type: 'personal', lockedBy: 'usr_7' } }, + }, + ], + views: [ + // A record: the member and the key both go; `label` rides through. + { + name: 'vpd_lead.hot', + object: 'vpd_lead', + viewKind: 'list', + label: 'Hot leads', + scope: 'personal', + config: { type: 'grid', columns: ['name'], sharing: { type: 'collaborative' } }, + }, + // A live `scope` member rides through by reference. + { name: 'vpd_lead.team', object: 'vpd_lead', viewKind: 'list', scope: 'shared', config: { type: 'grid', columns: ['name'] } }, + // A flattened list overlay: its own `scope` and its `sharing.type`. + { name: 'vpd_lead.board', object: 'vpd_lead', viewKind: 'list', scope: 'personal', sharing: { type: 'personal' } }, + // A container: `sharing.type` goes from each list payload, `lockedBy` stays. + { + object: 'vpd_lead', + list: { type: 'grid', columns: ['name'], sharing: { type: 'personal', lockedBy: 'usr_9' } }, + listViews: { quiet: { type: 'grid', columns: ['name'], sharing: { lockedBy: 'usr_9' } } }, + }, + ], + objects: [ + { name: 'vpd_lead', label: 'Lead', listViews: { mine: { type: 'grid', columns: ['name'], sharing: { type: 'collaborative' } } } }, + ], + }, + after: { + viewItems: [ + { + name: 'vpd_lead.mine', + object: 'vpd_lead', + viewKind: 'list', + config: { type: 'grid', columns: ['name'], sharing: { lockedBy: 'usr_7' } }, + }, + ], + views: [ + { + name: 'vpd_lead.hot', + object: 'vpd_lead', + viewKind: 'list', + label: 'Hot leads', + config: { type: 'grid', columns: ['name'], sharing: {} }, + }, + { name: 'vpd_lead.team', object: 'vpd_lead', viewKind: 'list', scope: 'shared', config: { type: 'grid', columns: ['name'] } }, + { name: 'vpd_lead.board', object: 'vpd_lead', viewKind: 'list', sharing: {} }, + { + object: 'vpd_lead', + list: { type: 'grid', columns: ['name'], sharing: { lockedBy: 'usr_9' } }, + listViews: { quiet: { type: 'grid', columns: ['name'], sharing: { lockedBy: 'usr_9' } } }, + }, + ], + objects: [ + { name: 'vpd_lead', label: 'Lead', listViews: { mine: { type: 'grid', columns: ['name'], sharing: {} } } }, + ], + }, + // One notice per declaration removed: `scope` + `sharing.type` on the + // `viewItems` record, on the `views` record and on the overlay (six), the + // container's `list` (one), the object's list view (one). + expectedNotices: 8, + }, +}; + /** * `record:chatter` / `record:discussion` `position` converges on the * renderer's vocabulary (protocol 18, #8762 — maintainer ruling 2026-08-15: @@ -13182,8 +13355,10 @@ const objectTenancyOrganizationFieldRemoved: MetadataConversion = { * (`VIEW_ITEM_OWNER_RETIRED` / `VIEW_ITEM_HIDDEN_RETIRED`): no writer and no * reader in this framework, in objectui at its pin and at `main`, or in cloud; * both switcher read paths filter on `viewKind` + `object` and sort on `order`. - * Per-user view scoping, which `owner` was to carry, is a parked direction - * (ADR-0017, amended 2026-09-04). + * Per-user view scoping, which `owner` was to carry, is no view-item + * mechanism: personal named views are a user-owned record (#28971, letter C; + * the same ruling retired `scope: 'personal'` in + * `view-personal-declarations-removed`). * * **Retired from the load path** — both keys are `retiredKey()` tombstones on * the shared shape, so a live author is refused at parse with the @@ -13260,7 +13435,10 @@ const viewItemOwnerHiddenRemoved: MetadataConversion = { object: 'crm_lead', viewKind: 'list', label: 'My hot leads', - scope: 'personal', + // [#29037] A LIVE `scope` member: `personal` left the enum, and + // `view-personal-declarations-removed` deletes it, so the bystander + // is `shared` to keep the fixtures disjoint under whole-table replay. + scope: 'shared', owner: 'usr_7', hidden: true, config: { type: 'grid', columns: ['name'] }, @@ -13289,7 +13467,7 @@ const viewItemOwnerHiddenRemoved: MetadataConversion = { object: 'crm_lead', viewKind: 'list', label: 'My hot leads', - scope: 'personal', + scope: 'shared', config: { type: 'grid', columns: ['name'] }, }, { name: 'crm_lead.intake', object: 'crm_lead', viewKind: 'form', config: { type: 'simple' } }, @@ -15579,6 +15757,7 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [ { conversion: permissionModifyAllAllowDeleteFalseToTrue, order: 75 }, { conversion: permissionRlsTagsRemoved, order: 42 }, { conversion: permissionSetManagedByRemoved, order: 67 }, + { conversion: personalViewDeclarationsRemoved, order: 82 }, { conversion: recordChatterPositionVocabulary, order: 2 }, { conversion: recordHighlightsFieldIconRemoved, order: 10 }, { conversion: reportJoinedChartRemoved, order: 38 }, diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ViewSharing__type.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ViewSharing__type.ts new file mode 100644 index 00000000000..3d3c3fc49bd --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ViewSharing__type.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #29037 — ADR-0049 enforce-or-remove on the view sharing block's ownership +// `type` (ruling of record 6570568131 on #28971, letter C). Its one reader was +// the console list toolbar's badge, and nothing restricted a `personal` view to +// one user. Tombstoned with `retiredKey()`, the released `collaborative` +// default accepted as residue. D2: `view-personal-declarations-removed`; D3: +// `ui-view-personal-declarations-retired`. +export const entry = 'ui/ViewSharing:type'; diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-view-personal-declarations-retired.ts b/packages/spec/src/migrations/entries/semantic/18.ui-view-personal-declarations-retired.ts new file mode 100644 index 00000000000..61a232925f6 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-view-personal-declarations-retired.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #29037 (ruling of record 6570568131 on #28971, letter C) — the D3 entry of +// the `view-personal-declarations-removed` family: one entry for the two +// declarations, because they leave for one reason — nothing ever made a view +// personal — and share one replacement, the user-owned personal view the same +// ruling records. `scope: 'personal'` is a value-level retirement (no +// RETIRED_KEYS_BY_MAJOR row); `sharing.type` is a key, registered as +// `ui/ViewSharing:type`. +export const entry: SemanticMigration = { + id: 'ui-view-personal-declarations-retired', + surface: + "view.scope — the value 'personal' left ViewScopeSchema (a view item record and a flattened overlay; " + + "'package' and 'shared' are unchanged) / view.list.sharing.type — the key left ViewSharingSchema with " + + "both its values, on every list view, object listViews entry and list view item; sharing.lockedBy is " + + 'unchanged', + replacement: + 'nothing on the view: delete `scope: \'personal\'` (omit `scope`, or write `shared` for a runtime-authored ' + + 'view) and delete `sharing.type`. A view is visible to everyone who can read its object. Personal named ' + + 'views are a user-owned record, not view metadata, and do not reuse either key', + reason: + 'Neither declaration was ever enforced. Both switcher read paths list every view bound to the object, so a ' + + 'view an author marked `scope: \'personal\'` or `sharing: { type: \'personal\' }` was listed for every user ' + + 'who can read the object, and the console list toolbar badge, the one reader of `sharing.type`, told them ' + + 'it was a personal view. With `personal` refused, `type` would carry one value that describes every view, ' + + 'so the key leaves whole. The D2 conversion `view-personal-declarations-removed` makes each stored or ' + + 'existing source parse: it DELETES the `personal` member and the `type` key, which changes what no reader ' + + 'could see. What it cannot decide is what the author meant by a personal view: keep it as a view every ' + + 'reader sees, delete it, or rebuild it as a personal view once the user-owned personal view ships', + acceptanceCriteria: + 'No view item or overlay declares `scope: \'personal\'` and no list view declares `sharing.type`; each is ' + + 'refused at parse with its prescription, and TypeScript rejects each at the authoring site (a ' + + '`collaborative` value an older toolchain wrote out is accepted and stripped). Every view the conversion ' + + 'changed is kept, deleted or rebuilt by the author\'s decision. The console list toolbar no longer labels ' + + 'any view personal or shared; a view with `sharing.lockedBy` still shows who locked it.', + conversionIds: ['view-personal-declarations-removed'], +}; diff --git a/packages/spec/src/migrations/registry.ts.template b/packages/spec/src/migrations/registry.ts.template index e3d474bcb1b..9d56c03545e 100644 --- a/packages/spec/src/migrations/registry.ts.template +++ b/packages/spec/src/migrations/registry.ts.template @@ -3446,6 +3446,19 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'offered a block `chart` input until this change); it is retired from the load path, so ' + 'authors are refused at parse rather than rewritten.', }, + { + id: 'ui-view-personal-declarations-retired', + order: 117, + text: + 'It also retires the two inert `personal` declarations on a view (ADR-0049): `scope: \'personal\'` on ' + + 'a view item and the list view\'s `sharing.type`. Nothing ever restricted a view to one user, so a view ' + + 'marked personal was listed for everyone who can read its object, under a badge that said otherwise; ' + + 'personal named views are ruled to be a user-owned record, not view metadata. Both are refused at parse ' + + 'with their prescription (a `collaborative` value an older toolchain wrote out is accepted and ' + + 'stripped); the D2 conversion `view-personal-declarations-removed` deletes them from existing sources ' + + 'and stored rows, which changes nothing a reader sees. Its D3 record is the semantic entry ' + + '`ui-view-personal-declarations-retired`.', + }, { id: 'view-data-api-provider-retired', order: 95, diff --git a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts index 12f610a65ee..34c453d7156 100644 --- a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts +++ b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts @@ -92,7 +92,7 @@ import { SharingLevel } from '../security/sharing.zod'; import { DatasourceSchema } from '../data/datasource.zod'; import { TursoConfigSchema } from '../data/driver/turso.zod'; import { InterfacePageConfigSchema, PageComponentSchema, PageSchema } from '../ui/page.zod'; -import { AddRecordConfigSchema, FormFieldSchema, FormSectionSchema, FormViewSchema, ListViewSchema, NavigationModeSchema, ObjectListViewSchema, VIEW_METADATA_MEMBERS } from '../ui/view.zod'; +import { AddRecordConfigSchema, FormFieldSchema, FormSectionSchema, FormViewSchema, ListViewSchema, NavigationModeSchema, ObjectListViewSchema, VIEW_METADATA_MEMBERS, ViewScopeSchema, ViewSharingSchema } from '../ui/view.zod'; import { FlowSchema } from '../automation/flow.zod'; import { JobSchema } from '../system/job.zod'; import { TranslationItemSchema } from '../system/translation.zod'; @@ -420,6 +420,13 @@ const JOIN: Readonly> = { tombstone(FormViewSchema, 'view.form', 'drawerSide'), tombstone(FormViewSchema, 'view.form', 'drawerWidth'), ], + // #29037: `scope: 'personal'`, refused by the enum's own error map, and + // `sharing.type`, tombstoned on the sharing block (read through the residue + // pipe's `shape`). + 'view-personal-declarations-removed': [ + refusal("view.scope 'personal'", () => ViewScopeSchema.safeParse('personal')), + tombstone(ViewSharingSchema, 'view.list.sharing', 'type'), + ], // #27155: the form view `type` narrowed to the form's layout — each retired // member refused by the enum's own error map, plus the three split-only keys // tombstoned beside it. diff --git a/packages/spec/src/ui/view-item-owner-hidden-retirement.test.ts b/packages/spec/src/ui/view-item-owner-hidden-retirement.test.ts index 0224d62f3ae..1b0ac63dfda 100644 --- a/packages/spec/src/ui/view-item-owner-hidden-retirement.test.ts +++ b/packages/spec/src/ui/view-item-owner-hidden-retirement.test.ts @@ -136,7 +136,7 @@ describe('view item owner/hidden retirement — the tombstones, at every door th } it('CONTROL: the same record without the keys passes every door, live identity keys intact', () => { - const withLiveKeys = { ...RECORD, label: 'My hot leads', isDefault: true, order: 2, scope: 'personal' } as const; + const withLiveKeys = { ...RECORD, label: 'My hot leads', isDefault: true, order: 2, scope: 'shared' } as const; for (const [label, schema] of [ ['strict', ViewItemSchema], ['wire', ViewItemWireSchema], @@ -151,7 +151,7 @@ describe('view item owner/hidden retirement — the tombstones, at every door th expect(data, `${label} grows no \`owner\``).not.toHaveProperty('owner'); expect(data, `${label} grows no \`hidden\``).not.toHaveProperty('hidden'); // The live neighbours are untouched by the tombstones. - expect(data.scope, `${label} keeps \`scope\``).toBe('personal'); + expect(data.scope, `${label} keeps \`scope\``).toBe('shared'); expect(data.isDefault, `${label} keeps \`isDefault\``).toBe(true); expect(data.order, `${label} keeps \`order\``).toBe(2); } @@ -190,7 +190,7 @@ describe('view item owner/hidden retirement — the D2 conversion', () => { it('a STORED view row carrying the keys replays clean through the rehydration seam', () => { // `database-loader.ts` replays the chain over every stored row it loads; // the seam wraps a `view` row as `{ views: [row] }`. - const stored = { ...RECORD, label: 'My hot leads', scope: 'personal', owner: 'usr_7', hidden: true }; + const stored = { ...RECORD, label: 'My hot leads', scope: 'shared', owner: 'usr_7', hidden: true }; const notices: { conversionId?: string; path?: string }[] = []; const rehydrated = applyConversionsToStoredItem('view', stored, { onNotice: (n) => notices.push(n as { conversionId?: string; path?: string }), @@ -201,7 +201,7 @@ describe('view item owner/hidden retirement — the D2 conversion', () => { expect(rehydrated).not.toHaveProperty('owner'); expect(rehydrated).not.toHaveProperty('hidden'); // CONTROL: the live identity keys on the same row survive byte-for-byte. - expect(rehydrated.scope).toBe('personal'); + expect(rehydrated.scope).toBe('shared'); expect(rehydrated.label).toBe('My hot leads'); // And the rehydrated row is exactly what the write door accepts now. expect(ViewMetadataSchema.safeParse(rehydrated).success).toBe(true); diff --git a/packages/spec/src/ui/view-overlay-viewkind-arm.test.ts b/packages/spec/src/ui/view-overlay-viewkind-arm.test.ts index bef08cec393..3c2ad2713b2 100644 --- a/packages/spec/src/ui/view-overlay-viewkind-arm.test.ts +++ b/packages/spec/src/ui/view-overlay-viewkind-arm.test.ts @@ -189,7 +189,7 @@ describe('W2 — a list-legal value under a key both arms declare differently is it.each([ ['`aria` (the list view ARIA block; the form arm carries a retirement tombstone)', { aria: { ariaLabel: 'Leads' } }], ['an i18n `description` (the form arm takes a plain string only)', { description: { en: 'All leads' } }], - ['list-style `sharing` (the form arm\'s is the public-link block)', { sharing: { type: 'personal' } }], + ['list-style `sharing` (the form arm\'s is the public-link block)', { sharing: { lockedBy: 'admin' } }], ])('%s', (_label, extra) => { expect(acceptance({ ...LIST, ...extra }).branch).toBe('listOverlay'); }); diff --git a/packages/spec/src/ui/view-personal-declarations-retirement.test.ts b/packages/spec/src/ui/view-personal-declarations-retirement.test.ts new file mode 100644 index 00000000000..e3c6fd376ae --- /dev/null +++ b/packages/spec/src/ui/view-personal-declarations-retirement.test.ts @@ -0,0 +1,486 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The two inert `personal` declarations on a view are retired (#29037, + * ADR-0049 enforce-or-remove; ruling of record 6570568131 on #28971, letter C: + * personal named views are a user-owned record, not view metadata, and do not + * reuse either key). + * + * Bookkeeping shapes, pinned below: + * 1. `scope: 'personal'` is a retired MEMBER of `ViewScopeSchema` + * (`enumWithRetiredValues`), refused with its prescription at every door + * that reads a view item's `scope` (the strict record, the wire member, + * the `view` save door for a record and for a flattened overlay, the + * assembled-manifest channel, `defineViewItem`); `package` and `shared` + * stay. + * 2. `sharing.type` is a `retiredKey()` tombstone on `ViewSharingSchema`. + * `personal` is refused with the prescription at every list-view door; + * `collaborative`, the default a released toolchain wrote out, is + * accepted as residue and STRIPPED (`acceptRetiredDefaultResidue`), so no + * parse emits an ownership type any more. `lockedBy` stays. + * 3. One D2 conversion, retired from the load path: + * `view-personal-declarations-removed`; its D3 entry is + * `ui-view-personal-declarations-retired`; `ui/ViewSharing:type` is + * registered in `RETIRED_KEYS_BY_MAJOR[18]`. + * 4. Tree-scoped absence: nothing inside the declared radius still authors + * either declaration. + * + * On the assertion set: a schema refusal raises a `ZodError` whose issues carry + * `code` and `path` but no ADR-0112 `status` (that envelope belongs to the + * doors), so each schema refusal is pinned by its `path` and the + * prescription's first sentence; the `defineStack` door adds the envelope's + * `code` + `status`. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import { listRepoWalkFiles } from '../../scripts/lib/repo-walk-testkit'; +import { collectConversionNotices } from '../conversions/apply'; +import { ALL_CONVERSIONS } from '../conversions/registry'; +import { applyConversionsToStoredItem } from '../conversions/stored'; +import type { ConversionNotice } from '../conversions/types'; +import { ObjectSchema } from '../data/object.zod'; +import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; +import { defineStack } from '../stack.zod'; +import { AssembledViewArtifactSchema } from './assembled-views.zod'; +import { + ListViewSchema, + ObjectListViewSchema, + VIEW_METADATA_MEMBERS, + ViewItemSchema, + ViewItemWireSchema, + ViewMetadataSchema, + ViewSchema, + ViewScopeSchema, + ViewSharingSchema, + defineViewItem, +} from './view.zod'; + +const CONVERSION_ID = 'view-personal-declarations-removed'; +const D3_ID = 'ui-view-personal-declarations-retired'; + +const SCOPE_FIRST_SENTENCE = + "`view.scope: 'personal'` was removed in @objectstack/spec 18.3.0 (ADR-0049 enforce-or-remove) — nothing " + + 'ever restricted a `personal` view item to one user: the view switcher lists every item bound to the ' + + 'object, so a view marked personal was listed for every user who can read the object.'; +const TYPE_FIRST_SENTENCE = + '`view.list.sharing.type` was removed in @objectstack/spec 18.3.0 (ADR-0049 enforce-or-remove) — nothing ' + + 'enforced it: a `personal` view was listed for every user who can read the object, and `collaborative` ' + + 'described every view, so the key only ever drew a badge.'; +const MIGRATE_SENTENCE = 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources;'; + +type Issue = { code: string; path: PropertyKey[]; message: string }; +type Parse = { success: boolean; data?: unknown; error?: { issues: Issue[] } }; +type Door = { name: string; parse: (body: Record) => Parse; at: PropertyKey[]; read: (data: unknown) => unknown }; + +const LIST = { type: 'grid' as const, columns: ['name'] }; +const RECORD = { name: 'crm_lead.hot', object: 'crm_lead', viewKind: 'list' as const, config: LIST }; +const OVERLAY = { name: 'crm_lead.board', object: 'crm_lead', viewKind: 'list' as const }; + +/** The issues a door reports at or under `at` — a wrapper may report others of its own. */ +const issuesAt = (r: Parse, at: PropertyKey[]) => + (r.error?.issues ?? []).filter((i) => at.every((seg, k) => i.path[k] === seg)); + +type Nested = Issue & { errors?: Nested[][] }; +/** + * Every leaf issue, a union door's per-arm diagnoses included: the `view` save + * door and the assembled-manifest channel are unions, and report the member's + * refusal inside `invalid_union.errors` (paths relative to the arm). + */ +const leafIssues = (issues: readonly Nested[]): Issue[] => + issues.flatMap((i) => (i.code === 'invalid_union' && i.errors ? i.errors.flatMap((arm) => leafIssues(arm)) : [i])); + +// ─── 1. `scope: 'personal'` ───────────────────────────────────────────────── + +/** Every door that reads a view item's `scope`, with the body around the member. */ +const SCOPE_DOORS: readonly Door[] = [ + { name: 'ViewItemSchema (record)', parse: (b) => ViewItemSchema.safeParse({ ...RECORD, ...b }), at: ['scope'], read: (d) => d }, + { name: 'ViewItemWireSchema (record)', parse: (b) => ViewItemWireSchema.safeParse({ ...RECORD, ...b }), at: ['scope'], read: (d) => d }, + { name: 'view save door (record)', parse: (b) => ViewMetadataSchema.safeParse({ ...RECORD, ...b }), at: ['scope'], read: (d) => d }, + { name: 'view save door (flattened overlay)', parse: (b) => ViewMetadataSchema.safeParse({ ...OVERLAY, ...b }), at: ['scope'], read: (d) => d }, + { name: 'assembled-manifest channel', parse: (b) => AssembledViewArtifactSchema.safeParse({ ...RECORD, ...b }), at: ['scope'], read: (d) => d }, +]; + +describe("view.scope 'personal' — a retired member at every door that reads `scope`", () => { + it('the enum refuses `personal` by name and keeps `package` and `shared`', () => { + const r = ViewScopeSchema.safeParse('personal'); + expect(r.success).toBe(false); + expect(r.error!.issues[0]!.message.startsWith(SCOPE_FIRST_SENTENCE), r.error!.issues[0]!.message).toBe(true); + expect(r.error!.issues[0]!.message).toContain(MIGRATE_SENTENCE); + expect(ViewScopeSchema.options).toEqual(['package', 'shared']); + // An unrelated wrong value keeps zod's own message: the map answers only its member. + expect(ViewScopeSchema.safeParse('private').error!.issues[0]!.message).not.toContain('was removed'); + }); + + it.each(SCOPE_DOORS.map((d) => [d.name, d] as const))("%s refuses `scope: 'personal'` with its prescription", (_, door) => { + // A union door may report the member under more than one arm; every one says the same thing. + const r = door.parse({ scope: 'personal' }); + expect(r.success).toBe(false); + const issues = leafIssues((r.error?.issues ?? []) as Nested[]).filter((i) => door.at.every((seg, k) => i.path[k] === seg)); + expect(issues.length, JSON.stringify(issues)).toBeGreaterThan(0); + for (const issue of issues) expect(issue.message.startsWith(SCOPE_FIRST_SENTENCE), issue.message).toBe(true); + }); + + it.each(SCOPE_DOORS.map((d) => [d.name, d] as const))('CONTROL: %s accepts `shared` and `package` and keeps them', (_, door) => { + for (const scope of ['shared', 'package'] as const) { + const r = door.parse({ scope }); + expect(r.success, `${scope}: ${JSON.stringify(r.error?.issues)}`).toBe(true); + expect((door.read(r.data) as { scope?: string }).scope).toBe(scope); + } + }); + + it('defineViewItem refuses it, and tsc rejects it at the authoring site', () => { + const attempt = () => + defineViewItem({ + ...RECORD, + // @ts-expect-error — `personal` is no longer a member of `ViewScope`. + scope: 'personal', + }); + expect(attempt).toThrow(/`view\.scope: 'personal'` was removed/); + expect(() => defineViewItem({ ...RECORD, scope: 'shared' })).not.toThrow(); + }); +}); + +// ─── 2. `sharing.type` ────────────────────────────────────────────────────── + +/** Every door a list view's `sharing` block is parsed through, with the path the block sits at. */ +const LIST_DOORS: readonly Door[] = [ + { name: 'ListViewSchema', parse: (s) => ListViewSchema.safeParse({ ...LIST, sharing: s }), at: ['sharing'], read: (d) => (d as { sharing?: unknown }).sharing }, + { name: 'view.list', parse: (s) => ViewSchema.safeParse({ list: { ...LIST, sharing: s } }), at: ['list', 'sharing'], read: (d) => (d as { list: { sharing?: unknown } }).list.sharing }, + { + name: 'view.listViews.*', + parse: (s) => ViewSchema.safeParse({ listViews: { quiet: { ...LIST, sharing: s } } }), + at: ['listViews', 'quiet', 'sharing'], + read: (d) => (d as { listViews: { quiet: { sharing?: unknown } } }).listViews.quiet.sharing, + }, + { + name: 'object.listViews.* (ObjectListViewSchema)', + parse: (s) => ObjectListViewSchema.safeParse({ ...LIST, sharing: s }), + at: ['sharing'], + read: (d) => (d as { sharing?: unknown }).sharing, + }, + { + name: 'ViewItem list record', + parse: (s) => ViewItemSchema.safeParse({ ...RECORD, config: { ...LIST, sharing: s } }), + at: ['config', 'sharing'], + read: (d) => (d as { config: { sharing?: unknown } }).config.sharing, + }, + { + name: 'flattened list overlay', + parse: (s) => VIEW_METADATA_MEMBERS.listOverlay.safeParse({ ...OVERLAY, sharing: s }), + at: ['sharing'], + read: (d) => (d as { sharing?: unknown }).sharing, + }, +]; + +describe('view.list.sharing.type — a tombstone at every list-view door', () => { + it.each(LIST_DOORS.map((d) => [d.name, d] as const))("%s refuses `type: 'personal'` with its prescription", (_, door) => { + for (const sharing of [{ type: 'personal' }, { type: 'personal', lockedBy: 'usr_7' }]) { + const r = door.parse(sharing); + expect(r.success, JSON.stringify(sharing)).toBe(false); + const issues = issuesAt(r, door.at); + expect(issues, JSON.stringify(issues)).toHaveLength(1); + expect(issues[0]!.path).toEqual([...door.at, 'type']); + expect(issues[0]!.message.startsWith(TYPE_FIRST_SENTENCE), issues[0]!.message).toBe(true); + expect(issues[0]!.message).toContain(MIGRATE_SENTENCE); + } + }); + + it.each(LIST_DOORS.map((d) => [d.name, d] as const))( + "%s accepts `type: 'collaborative'`, the released default, as residue and strips it", + (_, door) => { + const r = door.parse({ type: 'collaborative', lockedBy: 'usr_7' }); + expect(r.success, JSON.stringify(r.error?.issues)).toBe(true); + expect(door.read(r.data)).toEqual({ lockedBy: 'usr_7' }); + }, + ); + + it.each(LIST_DOORS.map((d) => [d.name, d] as const))('CONTROL: %s keeps `lockedBy` and emits no ownership type', (_, door) => { + for (const [sharing, out] of [[{ lockedBy: 'usr_7' }, { lockedBy: 'usr_7' }], [{}, {}]] as const) { + const r = door.parse(sharing); + expect(r.success, JSON.stringify(r.error?.issues)).toBe(true); + expect(door.read(r.data)).toEqual(out); + } + }); + + it('refuses any other value with the same prescription — the key is gone, not narrowed', () => { + const r = ViewSharingSchema.safeParse({ type: 'private' }); + expect(r.success).toBe(false); + expect(r.error!.issues[0]!.message.startsWith(TYPE_FIRST_SENTENCE)).toBe(true); + }); + + it('defineStack refuses it with the ADR-0112 envelope and the prescription', () => { + let refusal: unknown; + try { + defineStack({ views: [{ object: 'crm_lead', listViews: { mine: { ...LIST, sharing: { type: 'personal' } } } }] } as never); + } catch (error) { + refusal = error; + } + expect(refusal).toMatchObject({ code: 'STACK_SCHEMA_INVALID', status: 422 }); + const issues = (refusal as { issues: { path: PropertyKey[]; message: string }[] }).issues; + expect(issues.map((i) => i.path.join('.'))).toEqual(['views.0.listViews.mine.sharing.type']); + expect(issues[0]!.message.startsWith(TYPE_FIRST_SENTENCE), issues[0]!.message).toBe(true); + expect(() => defineStack({ + views: [{ object: 'crm_lead', listViews: { mine: { ...LIST, sharing: { lockedBy: 'usr_7' } } } }], + } as never)).not.toThrow(); + }); + + it('an object authoring `listViews.*.sharing.type` is refused at the object door', () => { + const r = ObjectSchema.safeParse({ + name: 'crm_lead', + label: 'Lead', + fields: { name: { type: 'text', label: 'Name' } }, + listViews: { mine: { ...LIST, sharing: { type: 'personal' } } }, + }); + expect(r.success).toBe(false); + const issue = r.error!.issues.find((i) => i.path.join('.') === 'listViews.mine.sharing.type'); + expect(issue?.message.startsWith(TYPE_FIRST_SENTENCE), JSON.stringify(r.error!.issues)).toBe(true); + }); + + it('is registered in RETIRED_KEYS_BY_MAJOR[18] under the exact def key', () => { + expect(RETIRED_KEYS_BY_MAJOR[18]).toContain('ui/ViewSharing:type'); + }); + + it('fails tsc at the authoring site: the tombstone admits only absence', () => { + const sharing: z.input = { + // @ts-expect-error — `type` is a `retiredKey()` tombstone, typed `never`. + type: 'personal', + }; + expect(ViewSharingSchema.safeParse(sharing).success).toBe(false); + }); +}); + +// ─── 3. The D2 conversion ─────────────────────────────────────────────────── + +type Notice = Pick; +const brief = (n: ConversionNotice): Notice => ({ conversionId: n.conversionId, path: n.path, from: n.from, to: n.to }); +const ours = (n: ConversionNotice) => n.conversionId === CONVERSION_ID; + +describe(`the D2 conversion ${CONVERSION_ID}`, () => { + it('a STORED list view item record replays to a body every door accepts', () => { + const row = { ...RECORD, label: 'Hot leads', scope: 'personal', config: { ...LIST, sharing: { type: 'personal', lockedBy: 'usr_7' } } }; + expect(ViewMetadataSchema.safeParse(row).success).toBe(false); + const notices: ConversionNotice[] = []; + const out = applyConversionsToStoredItem('view', row, { onNotice: (n) => notices.push(n) }) as Record; + expect(notices.filter(ours).map(brief)).toEqual([ + { conversionId: CONVERSION_ID, path: 'views[0].scope', from: 'personal', to: '(removed)' }, + { conversionId: CONVERSION_ID, path: 'views[0].config.sharing.type', from: 'type', to: '(removed)' }, + ]); + expect(out).not.toHaveProperty('scope'); + expect(out.config).toEqual({ ...LIST, sharing: { lockedBy: 'usr_7' } }); + expect(out.label).toBe('Hot leads'); + for (const schema of [ViewItemSchema, ViewItemWireSchema, ViewMetadataSchema, AssembledViewArtifactSchema]) { + expect(schema.safeParse(out).success).toBe(true); + } + // Copy-on-write: the stored row is not mutated. + expect(row.scope).toBe('personal'); + }); + + it('a STORED flattened overlay and a STORED container convert too', () => { + const overlay = { ...OVERLAY, scope: 'personal', sharing: { type: 'personal' } }; + const outOverlay = applyConversionsToStoredItem('view', overlay) as Record; + expect(outOverlay).toEqual({ ...OVERLAY, sharing: {} }); + expect(ViewMetadataSchema.safeParse(outOverlay).success).toBe(true); + + const container = { object: 'crm_lead', list: { ...LIST, sharing: { type: 'collaborative' } }, listViews: { mine: { ...LIST, sharing: { type: 'personal', lockedBy: 'usr_9' } } } }; + const outContainer = applyConversionsToStoredItem('view', container) as typeof container; + expect(outContainer.list.sharing).toEqual({}); + expect(outContainer.listViews.mine.sharing).toEqual({ lockedBy: 'usr_9' }); + expect(ViewSchema.safeParse(outContainer).success).toBe(true); + }); + + it('reaches the assembled-manifest `viewItems` channel and an object\'s `listViews`', () => { + const { stack, notices } = collectConversionNotices( + { + viewItems: [{ ...RECORD, scope: 'personal', config: { ...LIST, sharing: { type: 'collaborative' } } }], + objects: [{ name: 'crm_lead', label: 'Lead', listViews: { mine: { ...LIST, sharing: { type: 'personal' } } } }], + }, + { includeRetired: true }, + ); + expect(notices.filter(ours).map((n) => n.path).sort()).toEqual([ + 'objects[0].listViews.mine.sharing.type', + 'viewItems[0].config.sharing.type', + 'viewItems[0].scope', + ]); + const s = stack as { viewItems: Record[]; objects: { listViews: { mine: { sharing: unknown } } }[] }; + expect(s.viewItems[0]).not.toHaveProperty('scope'); + expect(s.objects[0]!.listViews.mine.sharing).toEqual({}); + }); + + it('leaves live members and the form\'s public-link `sharing` alone', () => { + const input = { + views: [ + { ...RECORD, scope: 'shared', config: { ...LIST, sharing: { lockedBy: 'usr_7' } } }, + { object: 'crm_lead', form: { type: 'simple', sharing: { enabled: true, publicLink: 'apply' } } }, + ], + }; + const { stack, notices } = collectConversionNotices(input, { includeRetired: true }); + expect(notices.filter(ours)).toEqual([]); + expect(stack).toEqual(input); + }); + + it('is idempotent by construction: a second replay converts nothing', () => { + const { stack } = collectConversionNotices( + { views: [{ ...RECORD, scope: 'personal', config: { ...LIST, sharing: { type: 'personal' } } }] }, + { includeRetired: true }, + ); + const replay = collectConversionNotices(stack, { includeRetired: true }); + expect(replay.notices.filter(ours)).toEqual([]); + expect(replay.stack).toBe(stack); + }); + + it('is retired from the load path — the authoring funnel rewrites no live source', () => { + const input = { views: [{ ...RECORD, scope: 'personal' }] }; + const { stack, notices } = collectConversionNotices(input); + expect(notices.filter(ours)).toEqual([]); + expect(stack).toEqual(input); + }); + + it('is wired into the step-18 chain as a retired entry stamped after 18.2.0', () => { + expect(MIGRATIONS_BY_MAJOR[18]!.conversionIds).toContain(CONVERSION_ID); + const conversion = ALL_CONVERSIONS.find((c) => c.id === CONVERSION_ID); + expect(conversion).toBeDefined(); + expect(conversion!.toMajor).toBe(18); + expect(conversion!.retiredFromLoadPath).toBe(true); + expect(conversion!.retiredAfter).toBe('18.2.0'); + }); + + it(`carries the D3 entry ${D3_ID}, naming its D2 conversion`, () => { + const entries = MIGRATIONS_BY_MAJOR[18]!.semantic.filter((s) => s.id === D3_ID); + expect(entries).toHaveLength(1); + expect(entries[0]!.reason).toContain(`\`${CONVERSION_ID}\``); + expect(entries[0]!.conversionIds).toEqual([CONVERSION_ID]); + expect(entries[0]!.acceptanceCriteria.length).toBeGreaterThan(0); + }); +}); + +// ─── 4. Tree-scoped absence, inside the radius the package already declares ─── +// +// `tsc` sweeps the TYPED authoring sites; this walk covers every text file +// under the five repo roots `scripts/cross-package-test-inputs.mjs` declares +// for `@objectstack/spec#test` (mirrored in `turbo.json`), with the example +// apps' own `src/` trees. It judges two shapes: a `scope: 'personal'` pair +// (no other schema in the tree spells that pair), and a `sharing: { … }` +// literal whose own top-level keys include `type` (the form view's +// public-link `sharing` block has no `type`, so it never matches). The bound, +// stated: a block assembled by spread or a computed key, YAML, and the console +// under `ui/` (outside this radius) are outside what this walk sees. +describe('tree-scoped absence: nothing inside the declared radius still authors a personal declaration', () => { + const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + const REPO_ROOT = path.resolve(SPEC_ROOT, '../..'); + const THIS_FILE = path.relative(REPO_ROOT, fileURLToPath(import.meta.url)).split(path.sep).join('/'); + + const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts']; + const SCANNED_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx']); + const EXAMPLES_EXT = new Set(['.json', '.md', '.mdx']); + const EXAMPLE_APP_SRC_TS = /^examples\/[^/]+\/src\/.+\.ts$/; + const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); + const SCOPE_PERSONAL = /["']?\bscope["']?\s*:\s*["']personal["']/; + const TYPE_KEY = /(?:^|[\s,{])["']?type["']?\s*:/; + + /** The text of each `{ … }` literal at depth 1 inside the literal opened at `start`. */ + const ownText = (text: string, start: number): string => { + let d = 0; + let cur = ''; + for (let i = start; i < text.length; i += 1) { + const ch = text[i]; + if (ch === '{') d += 1; + else if (ch === '}') { + d -= 1; + if (d === 0) break; + } else if (d === 1) cur += ch; + } + return cur; + }; + + /** Whether the text authors `scope: 'personal'` or a `sharing` block holding `type`. */ + const judge = (raw: string): boolean => { + // Inline code spans are prose; newline-bounded, so a fenced example is still judged. + const text = raw.replace(/`[^`\n]*`/g, ''); + if (SCOPE_PERSONAL.test(text)) return true; + const opener = /["']?\bsharing["']?\s*:\s*\{/g; + for (let m = opener.exec(text); m; m = opener.exec(text)) { + if (TYPE_KEY.test(ownText(text, m.index + m[0].length - 1))) return true; + } + return false; + }; + + /** + * Structural exclusions — the retirement kit, each with its reason. ⛔ NOT an + * allowlist file (`spec-property-retirement` §4): every entry's JOB is to + * spell the retired declarations. + */ + const EXCLUDED = new Set([ + // This pin authors both to assert their refusal and their conversion. + THIS_FILE, + // The removal-version pin reads the refusal of `scope: 'personal'` on purpose. + 'packages/spec/src/shared/retired-key-removal-version.pin.test.ts', + ]); + const EXCLUDED_PREFIXES = [ + // The D2 conversion's fixture authors the pre-retirement view on purpose. + 'packages/spec/src/conversions/', + // Release-owned prose records the removal; never edited by a code PR. + 'content/docs/releases/', + ]; + const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; + + const readIfPresent = (full: string): string | undefined => { + try { + return fs.readFileSync(full, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; + return undefined; + } + }; + + it('the matcher recognises an authoring and ignores its neighbours (anti-vacuity)', () => { + expect(judge("{ name: 'crm_lead.mine', scope: 'personal', viewKind: 'list' }")).toBe(true); + expect(judge('{ "scope": "personal" }')).toBe(true); + expect(judge("list: { type: 'grid', sharing: { type: 'personal' } },")).toBe(true); + expect(judge("sharing: {\n lockedBy: 'admin',\n type: 'collaborative',\n}")).toBe(true); + expect(judge("Prose.\n\n```ts\nsharing: { type: 'personal' }\n```\n")).toBe(true); + // Neighbours that must NOT match. + expect(judge("scope: 'shared'")).toBe(false); + expect(judge("sharing: { lockedBy: 'admin' }")).toBe(false); + expect(judge("sharing: { enabled: true, publicLink: 'apply', allowAnonymous: true }")).toBe(false); + expect(judge("sharing: { rules: [{ type: 'owner' }] }")).toBe(false); + expect(judge("{ value: 'personal', label: 'Personal' }")).toBe(false); + expect(judge('a view once wrote `sharing: { type: "personal" }`.')).toBe(false); + }); + + it('no `scope: \'personal\'` and no `sharing.type` survives inside the declared radius', () => { + const offenders: string[] = []; + let visited = 0; + let mentioning = 0; + const skipDir = (name: string) => SKIPPED_DIRS.has(name) || name.startsWith('.'); + for (const root of WALK_ROOTS) { + for (const full of listRepoWalkFiles(path.join(REPO_ROOT, root), { skipDir })) { + const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); + const name = path.basename(full); + const ext = path.extname(name); + const scanned = rel.startsWith('examples/') + ? EXAMPLES_EXT.has(ext) || EXAMPLE_APP_SRC_TS.test(rel) + : SCANNED_EXT.has(ext); + if (!scanned) continue; + if (name === 'CHANGELOG.md') continue; // release prose records the removal + if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; + if (TSUP_BUNDLED_CONFIG.test(name)) continue; + visited += 1; + const text = readIfPresent(full); + if (text === undefined) continue; + if (/["']?\bsharing["']?\s*:\s*\{/.test(text)) mentioning += 1; + if (judge(text)) offenders.push(rel); + } + } + // Anti-vacuity: the walk covered the tree, and files that DO author a + // `sharing` block were really judged. + expect(visited).toBeGreaterThan(1000); + expect(mentioning).toBeGreaterThan(0); + expect(offenders, 'a personal declaration authored again means the retirement is being undone').toEqual([]); + }); +}); diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index 868ddca2fff..5a6a1c94b45 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -2777,29 +2777,15 @@ describe('groupByField — a padded field name is refused', () => { }); describe('ViewSharingSchema', () => { - it('should default to collaborative', () => { - const sharing = {}; - - const result = ViewSharingSchema.parse(sharing); - expect(result.type).toBe('collaborative'); - }); - - it('should accept personal view', () => { - const sharing = { - type: 'personal' as const, - lockedBy: 'user_123', - }; - - expect(() => ViewSharingSchema.parse(sharing)).not.toThrow(); + // [#29037] `type` is retired (ADR-0049): the block carries `lockedBy` only, + // and parsing an empty block no longer writes an ownership type. + it('emits no ownership type for an empty block', () => { + const result = ViewSharingSchema.parse({}); + expect(result).toEqual({}); }); - it('should accept collaborative view with lock', () => { - const sharing = { - type: 'collaborative' as const, - lockedBy: 'admin_user', - }; - - expect(() => ViewSharingSchema.parse(sharing)).not.toThrow(); + it('should accept a lock', () => { + expect(ViewSharingSchema.parse({ lockedBy: 'admin_user' })).toEqual({ lockedBy: 'admin_user' }); }); }); @@ -3032,9 +3018,9 @@ describe('Airtable-style ListView enhancements', () => { name: 'my_pipeline', label: 'My Pipeline', columns: ['name', 'stage'], - description: 'Personal view for tracking deals', + description: 'Pipeline view for tracking deals', sharing: { - type: 'personal', + lockedBy: 'admin', }, }; @@ -3108,7 +3094,6 @@ describe('Airtable-style ListView enhancements', () => { hiddenFields: ['internal_id', 'sys_updated_at'], fieldOrder: ['project_name', 'status', 'priority', 'budget', 'completion', 'department'], sharing: { - type: 'collaborative', lockedBy: 'admin', }, resizable: true, @@ -3144,7 +3129,7 @@ describe('Airtable-style ListView enhancements', () => { summarizeField: 'amount', columns: ['name', 'owner', 'close_date'], }, - sharing: { type: 'collaborative' }, + sharing: { lockedBy: 'admin' }, }, gallery: { type: 'gallery', diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index e89e25aad27..e515a325104 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -65,7 +65,7 @@ import { SectionGroupKeySchema, sectionGroupReferenceRefinement } from '../share import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; import { ChartTypeSchema } from './chart.zod'; import { SharingConfigSchema } from './sharing.zod'; -import { enumWithRetiredValues, retiredKey } from '../shared/retired-key'; +import { acceptRetiredDefaultResidue, enumWithRetiredValues, retiredKey } from '../shared/retired-key'; // [#19088] The decimal-places ceiling every renderer enforces, and the refusal // text that explains it. A `src/shared/` leaf that no package entry re-exports, // so the bound this row applies costs no published export — see that module's @@ -1833,17 +1833,49 @@ export const TimelineConfigSchema = lazySchema(() => strictObject({ scale: z.enum(['hour', 'day', 'week', 'month', 'quarter', 'year']).default('week').describe('Default timeline scale'), }).describe('Timeline view configuration')); +// [#29037] `sharing.type` retirement prescription (ADR-0049 enforce-or-remove; +// ruling of record 6570568131 on #28971, letter C). Declared with `//` on +// purpose: a tombstone consumes it, it is no documented surface. +// +// Measured before removal: the one reader in either tree was the console list +// toolbar's read-only badge (`ui/packages/plugin-list/src/ListView.tsx`), which +// drew "Personal view" or "Shared view" from the value. Nothing filtered a view +// by it: both switcher read paths list every view bound to the object, so a +// view marked `personal` was listed for every user who can read the object, +// under a badge that said otherwise. With `personal` refused, the key would +// carry one value that describes every view, so it leaves whole. +const VIEW_SHARING_TYPE_RETIRED = + '`view.list.sharing.type` was removed in @objectstack/spec 18.3.0 (ADR-0049 enforce-or-remove) — nothing ' + + 'enforced it: a `personal` view was listed for every user who can read the object, and `collaborative` ' + + 'described every view, so the key only ever drew a badge. Delete the key; `sharing.lockedBy` is unchanged. ' + + 'A view is visible to everyone who can read its object; personal named views are a user-owned record, ' + + 'not view metadata (ADR-0017). ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; + +// [#29037] The default the retired `sharing.type` emitted, captured as a +// literal at retirement (`acceptRetiredDefaultResidue`, the #12840 rule for a +// retired DEFAULTED key). Every view a released toolchain parsed with a +// `sharing` block carries `type: 'collaborative'` written out although no +// author wrote it; that residue is accepted and stripped, and `personal` (the +// value an author did write) keeps the tombstone's refusal. +const VIEW_SHARING_RETIRED_KEY_RESIDUE = { type: 'collaborative' } as const; + /** - * View Sharing Configuration (Airtable-style) - * Defines who can see and modify a view. + * View Sharing Configuration (Airtable-style). + * + * Carries one live key, `lockedBy`. The ownership `type` it used to declare + * (`personal` | `collaborative`) was retired (#29037, ADR-0049): nothing ever + * restricted a view to one user, and personal named views are ruled to be a + * user-owned record, not view metadata (#28971; the ADR-0017 / ADR-0131 D13 + * notes, PR #29321). */ -export const ViewSharingSchema = lazySchema(() => strictObject({ +export const ViewSharingSchema = lazySchema(() => acceptRetiredDefaultResidue(strictObject({ surface: 'this view sharing', history: VIEW_HISTORY, }, { - type: z.enum(['personal', 'collaborative']).default('collaborative').describe('View ownership type'), + type: retiredKey(VIEW_SHARING_TYPE_RETIRED), lockedBy: z.string().optional().describe('User who locked the view configuration'), -}).describe('View sharing and access configuration')); +}).describe('View sharing configuration — who locked the view configuration'), VIEW_SHARING_RETIRED_KEY_RESIDUE)); /** * Row Color Configuration (Airtable-style) @@ -3465,7 +3497,7 @@ const ListViewShapeSchema = lazySchema(() => strictObject({ /** View Metadata (Airtable-style view management) */ description: I18nLabelSchema.optional().describe('View description for documentation/tooltips'), - sharing: ViewSharingSchema.optional().describe('View sharing and access configuration'), + sharing: ViewSharingSchema.optional().describe('View sharing configuration: who locked the view configuration'), /** * [#22611] The audience gate — the platform's one capability key, with the @@ -5798,6 +5830,23 @@ export const ViewItemNameSchema = z ) .describe('Globally-unique view id, `.`.'); +// [#29037] `scope: 'personal'` retired member — the prescription it answers +// with at parse (`enumWithRetiredValues`, `../shared/retired-key.ts`). Declared +// with `//` on purpose: an enum's error map consumes it, it is no documented +// surface. Declared ABOVE `ViewScopeSchema`, which is built at module init. +// The `view-personal-declarations-removed` conversion (ADR-0087 D2, +// `conversions/registry.ts`) deletes the member from existing sources and +// stored rows. +const VIEW_SCOPE_RETIRED = { + personal: + "`view.scope: 'personal'` was removed in @objectstack/spec 18.3.0 (ADR-0049 enforce-or-remove) — nothing " + + 'ever restricted a `personal` view item to one user: the view switcher lists every item bound to the ' + + 'object, so a view marked personal was listed for every user who can read the object. Delete the value; ' + + 'a view item without `scope` from source is a `package` item, and a runtime-authored one is `shared`. ' + + 'Personal named views are a user-owned record, not a view item (ADR-0017). ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', +} as const; + /** * Identity layer for a view item — its visibility scope and ownership. * @@ -5806,20 +5855,24 @@ export const ViewItemNameSchema = z * override layer. * - `shared` — authored at runtime, visible org-wide. Creating one is gated * by the `view.manageShared` capability. - * - `personal` — the per-user layer. Per-user view scoping is a PARKED - * direction (ADR-0017, amended 2026-09-04), not a shipped - * mechanism: the item-level `owner` key that was to name the - * user was retired with `hidden` (ADR-0049 — see - * {@link VIEW_ITEM_OWNER_RETIRED}), so nothing restricts a - * `personal` item to one user today. + * + * `personal` was retired (#29037, ADR-0049 enforce-or-remove) and is refused + * at parse with {@link VIEW_SCOPE_RETIRED}'s prescription. It promised a + * per-user layer and nothing ever delivered one: the item-level `owner` key + * that was to name the user was retired with `hidden` (see + * {@link VIEW_ITEM_OWNER_RETIRED}), and both switcher read paths list every + * item bound to the object, so a `personal` item was shown to every user who + * can read the object. Per-user scoping is no longer a parked direction for + * view items: the maintainer ruled (#28971, letter C) that personal named + * views are a user-owned record, not a view item, and that they do not reuse + * this value (the ADR-0017 / ADR-0131 D13 notes, PR #29321). * * Named `scope` (not `provenance`) to avoid colliding with the loader-set * `_provenance` envelope field, which tracks a different axis * (package | org | env-forced). */ -export const ViewScopeSchema = z - .enum(['package', 'shared', 'personal']) - .describe('View identity layer: package | shared | personal.'); +export const ViewScopeSchema = enumWithRetiredValues(['package', 'shared'], VIEW_SCOPE_RETIRED) + .describe('View identity layer: package | shared.'); /** Discriminator for the kind of view a {@link ViewItemSchema} carries. */ export const ViewKindSchema = z @@ -5863,8 +5916,8 @@ const VIEW_ITEM_OWNER_RETIRED = + 'user a `personal` view item belonged to, and nothing ever read it: the view switcher ' + '(`GET /meta/view?object=`) serves every item bound to the object without looking at `owner`, so a ' + 'view marked as one user\'s was listed for every user who can read the object. Delete the key. ' - + 'Nothing restricts a view item to one user today — per-user view scoping is a parked direction ' - + '(ADR-0017), not a shipped mechanism — so a view item is visible to everyone who can read its object. ' + + 'Nothing restricts a view item to one user — personal named views are a user-owned record, not a view ' + + 'item (ADR-0017) — so a view item is visible to everyone who can read its object. ' + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; const VIEW_ITEM_HIDDEN_RETIRED = @@ -6144,9 +6197,9 @@ const VIEW_SWITCHER_VISIBILITY_GROUPS = ['private', 'team', 'organization', 'pub * in the contract. * * None of the three is per-user. A stored `view` row is environment metadata - * with no per-user scope (ADR-0017, amended: per-user view scoping is a parked - * direction), so a pin, a position or a group applies to everyone who reads - * the view. + * with no per-user scope (ADR-0017, amended: personal named views are a + * user-owned record, not view metadata — #28971, letter C), so a pin, a + * position or a group applies to everyone who reads the view. * * ⛔ Not authorable. The authoring doors ({@link ViewItemSchema}, * `ListViewSchema`) do not declare them and refuse them by name. diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index e6cb09a24da..29c12e2a404 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -86,5 +86,6 @@ "src/ui/view-item-owner-hidden-retirement.test.ts", "src/ui/view-list-filterable-fields-retirement.test.ts", "src/ui/view-list-tabs-retirement.test.ts", + "src/ui/view-personal-declarations-retirement.test.ts", "src/ui/visibility-alias-keys-retirement.test.ts" ] diff --git a/ui/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/ui/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index 8d80c0d7d00..412bda570d8 100644 --- a/ui/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/ui/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -1662,7 +1662,7 @@ const UNPUBLISHED_EXEMPTIONS: Record = { 'list-view.name': 'Unpublished by measurement (objectui#13047): read by nothing on a page `list-view` node. The node draws, queries and hands its view the same thing with and without it — the view\'s own `name` is its identity in metadata, which an embedded list has no use for.', 'list-view.sharing': - 'Unpublished by measurement (objectui#13047): `sharing.type` draws a Share button with no click handler, and the sharing config is enforced nowhere in the renderer. Publishing it would advertise a dead control (objectui#13047\'s report).', + 'Unpublished by measurement (objectui#13047): the block draws a read-only lock indicator from `sharing.lockedBy` (its ownership `type` is retired, objectstack#29037) with no click handler, and the sharing config is enforced nowhere in the renderer. Publishing it would advertise a dead control (objectui#13047\'s report).', }; /** diff --git a/ui/packages/core/src/utils/__tests__/normalize-list-view.test.ts b/ui/packages/core/src/utils/__tests__/normalize-list-view.test.ts index 49d59c99824..ded22073e0a 100644 --- a/ui/packages/core/src/utils/__tests__/normalize-list-view.test.ts +++ b/ui/packages/core/src/utils/__tests__/normalize-list-view.test.ts @@ -253,35 +253,28 @@ describe('normalizeListViewSchema (#2890)', () => { expect(out.aria).toEqual({ ariaLabel: 'canonical', role: 'grid' }); }); - it('collapses the visibility audience onto the spec ownership type', () => { - // Only `private` is personal; every wider audience is collaborative. - for (const [visibility, type] of Object.entries({ - private: 'personal', - team: 'collaborative', - organization: 'collaborative', - public: 'collaborative', - })) { - const out = normalizeListViewSchema({ viewType: 'grid', sharing: { visibility } }) as Record; - expect(out.sharing).toEqual({ type }); + // objectstack#29037: the spec's ownership `type` is retired, so the legacy + // audience pair folds onto nothing — it is dropped, and no `type` is written. + it('drops the legacy audience pair and writes no ownership type', () => { + for (const sharing of [ + { visibility: 'private' }, + { visibility: 'team' }, + { visibility: 'organization' }, + { visibility: 'public' }, + { enabled: true }, + { enabled: false }, + ]) { + const out = normalizeListViewSchema({ viewType: 'grid', sharing }) as Record; + expect(out.sharing, JSON.stringify(sharing)).toEqual({}); } }); - it('maps a bare `enabled: true` to `personal` — the badge it used to render', () => { - const out = normalizeListViewSchema({ viewType: 'grid', sharing: { enabled: true } }) as Record; - expect(out.sharing).toEqual({ type: 'personal' }); - }); - - it('leaves `enabled: false` without a type, so the share badge stays hidden', () => { - const out = normalizeListViewSchema({ viewType: 'grid', sharing: { enabled: false } }) as Record; - expect(out.sharing).toEqual({}); - }); - - it('preserves `lockedBy` and lets an explicit `type` win over `visibility`', () => { + it('preserves `lockedBy` beside the dropped pair', () => { const out = normalizeListViewSchema({ viewType: 'grid', - sharing: { type: 'collaborative', visibility: 'private', lockedBy: 'u1' }, + sharing: { visibility: 'private', enabled: true, lockedBy: 'u1' }, }) as Record; - expect(out.sharing).toEqual({ type: 'collaborative', lockedBy: 'u1' }); + expect(out.sharing).toEqual({ lockedBy: 'u1' }); }); }); diff --git a/ui/packages/core/src/utils/normalize-list-view.ts b/ui/packages/core/src/utils/normalize-list-view.ts index 863ff47fcf3..5ee8a5c92a6 100644 --- a/ui/packages/core/src/utils/normalize-list-view.ts +++ b/ui/packages/core/src/utils/normalize-list-view.ts @@ -160,18 +160,6 @@ function foldsInlineEditIntoEditInline(s: Record): boolean { return !(isRecord(s.userActions) && typeof s.userActions.editInline === 'boolean'); } -/** - * Legacy `sharing.visibility` → the spec's `ViewSharing.type`. The spec models - * two ownership kinds; objectui's four-value audience enum collapses onto them: - * only `private` is personal, everything wider is collaborative. - */ -const VISIBILITY_TO_SHARING_TYPE: Record = { - private: 'personal', - team: 'collaborative', - organization: 'collaborative', - public: 'collaborative', -}; - /** Legacy ARIA spellings → the spec's `AriaProps` keys. */ const ARIA_KEY_ALIASES: Record = { label: 'ariaLabel', @@ -407,9 +395,12 @@ function readListViewKind(value: unknown): string | undefined { * absent here as well; `ListView` reads that as on, the spec's default * (objectstack#22605). * - `aria: { label, describedBy }` → the spec's `AriaProps` - * (`{ ariaLabel, ariaDescribedBy }`), and `sharing: { visibility, enabled }` - * → the spec's `ViewSharing` (`{ type }`) — #2890 scope A step 5. `aria.live` - * survives untouched: it has no spec counterpart. + * (`{ ariaLabel, ariaDescribedBy }`) — #2890 scope A step 5. `aria.live` + * survives untouched: it has no spec counterpart. The legacy + * `sharing: { visibility, enabled }` pair is DROPPED: it folded onto the + * spec's ownership `type`, which objectstack#29037 retired (nothing ever + * restricted a view to one user), so it has no spec counterpart left. + * `lockedBy` survives. * - `filters` → `filter` (#2890 scope A step 4). A key rename only: BOTH keys * carry an ObjectQL FilterNode array (`[['stage','=','won']]`) everywhere in * objectui — every consumer passes the value straight to `$filter`. The spec @@ -569,20 +560,10 @@ export function normalizeListViewSchema(schema: T): T { next.aria = nextAria; } if (foldSharing && sharing) { + // objectstack#29037: the audience pair has no spec counterpart any more — + // the ownership `type` it folded onto is retired, and the toolbar draws + // only `lockedBy`. Drop it; never invent a retired key. const nextSharing: Record = { ...sharing }; - if (nextSharing.type === undefined) { - const visibility = typeof sharing.visibility === 'string' ? sharing.visibility : undefined; - // `enabled: true` with no audience is under-specified legacy input. It - // used to render the share badge titled "private", so it maps to - // `personal` — preserving what the user saw rather than adopting the - // spec's `collaborative` default and silently relabeling the badge. - const resolved = visibility - ? VISIBILITY_TO_SHARING_TYPE[visibility] - : sharing.enabled === true - ? 'personal' - : undefined; - if (resolved) nextSharing.type = resolved; - } delete nextSharing.visibility; delete nextSharing.enabled; next.sharing = nextSharing; diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 0f71e4f260a..1dd595f4f94 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -968,8 +968,6 @@ const ar = { pullToRefresh: "اسحب للتحديث", refreshing: "جارٍ التحديث…", share: "مشاركة", - sharingPersonal: "عرض شخصي", - sharingCollaborative: "عرض مشترك", sharingLockedBy: "مقفل بواسطة {{user}}", print: "طباعة", hideFieldsTitle: "إخفاء الحقول", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 8de6311aae0..5cc37cfe32e 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -859,8 +859,6 @@ const de = { pullToRefresh: "Zum Aktualisieren ziehen", refreshing: "Aktualisiere…", share: "Teilen", - sharingPersonal: "Persönliche Ansicht", - sharingCollaborative: "Geteilte Ansicht", sharingLockedBy: "Gesperrt von {{user}}", print: "Drucken", hideFieldsTitle: "Felder ausblenden", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 5271d7b34d2..5cd14b839af 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -1125,8 +1125,6 @@ const en = { pullToRefresh: 'Pull to refresh', refreshing: 'Refreshing…', share: 'Share', - sharingPersonal: 'Personal view', - sharingCollaborative: 'Shared view', sharingLockedBy: 'Locked by {{user}}', print: 'Print', hideFieldsTitle: 'Hide Fields', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 477ed6e6290..8b9085d7e71 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -889,8 +889,6 @@ const es = { pullToRefresh: "Tirar para actualizar", refreshing: "Actualizando…", share: "Compartir", - sharingPersonal: "Vista personal", - sharingCollaborative: "Vista compartida", sharingLockedBy: "Bloqueada por {{user}}", print: "Imprimir", hideFieldsTitle: "Ocultar campos", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index e7e9c126ee2..0b9512b3ff5 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -884,8 +884,6 @@ const fr = { pullToRefresh: "Tirer pour actualiser", refreshing: "Actualisation…", share: "Partager", - sharingPersonal: "Vue personnelle", - sharingCollaborative: "Vue partagée", sharingLockedBy: "Verrouillée par {{user}}", print: "Imprimer", hideFieldsTitle: "Masquer les champs", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index a129f8c9edd..eff3e009d82 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -859,8 +859,6 @@ const ja = { pullToRefresh: "引っ張って更新", refreshing: "更新中…", share: "共有", - sharingPersonal: "個人ビュー", - sharingCollaborative: "共有ビュー", sharingLockedBy: "{{user}} がロック中", print: "印刷", hideFieldsTitle: "フィールドを非表示", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 398a00919b3..6ea2a49591d 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -859,8 +859,6 @@ const ko = { pullToRefresh: "당겨서 새로고침", refreshing: "새로고침 중…", share: "공유", - sharingPersonal: "개인 보기", - sharingCollaborative: "공유 보기", sharingLockedBy: "{{user}} 님이 잠금", print: "인쇄", hideFieldsTitle: "필드 숨기기", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index 2fb12977eff..21008dc388b 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -884,8 +884,6 @@ const pt = { pullToRefresh: "Puxar para atualizar", refreshing: "Atualizando…", share: "Compartilhar", - sharingPersonal: "Visualização pessoal", - sharingCollaborative: "Visualização compartilhada", sharingLockedBy: "Bloqueada por {{user}}", print: "Imprimir", hideFieldsTitle: "Ocultar campos", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index 9b8f31b2ddc..160ff0bbccd 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -915,8 +915,6 @@ const ru = { pullToRefresh: "Потяните для обновления", refreshing: "Обновление…", share: "Поделиться", - sharingPersonal: "Личное представление", - sharingCollaborative: "Общее представление", sharingLockedBy: "Заблокировано: {{user}}", print: "Печать", hideFieldsTitle: "Скрыть поля", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index 89d5039f90a..a53aa36102e 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -882,8 +882,6 @@ const zh = { pullToRefresh: '下拉刷新', refreshing: '刷新中…', share: '分享', - sharingPersonal: '个人视图', - sharingCollaborative: '共享视图', sharingLockedBy: '由 {{user}} 锁定', print: '打印', hideFieldsTitle: '隐藏字段', diff --git a/ui/packages/plugin-list/src/ListView.tsx b/ui/packages/plugin-list/src/ListView.tsx index a1428d2d689..60f0fd0c36e 100644 --- a/ui/packages/plugin-list/src/ListView.tsx +++ b/ui/packages/plugin-list/src/ListView.tsx @@ -9,7 +9,7 @@ import * as React from 'react'; import { cn, Button, Input, Popover, PopoverContent, PopoverTrigger, FilterBuilder, SortBuilder, NavigationOverlay, GroupingEditor, RefreshIndicator, DataErrorState, resolveIcon, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@object-ui/components'; import type { SortItem } from '@object-ui/components'; -import { Search, Funnel, ArrowUpDown, X, EyeOff, Pencil, Group, Paintbrush, Download, Rows4, Rows3, Rows2, Share2, Lock, Printer, Plus, Trash2, CheckSquare, AlertTriangle, ShieldAlert, RotateCw, Loader2, Info, Inbox } from 'lucide-react'; +import { Search, Funnel, ArrowUpDown, X, EyeOff, Pencil, Group, Paintbrush, Download, Rows4, Rows3, Rows2, Lock, Printer, Plus, Trash2, CheckSquare, AlertTriangle, ShieldAlert, RotateCw, Loader2, Info, Inbox } from 'lucide-react'; import type { FilterBuilderRowGroup } from '@object-ui/components'; import { VALUELESS_FILTER_BUILDER_OPERATORS, isFilterValueComplete, isCurrencyCompanion } from '@object-ui/components'; import { ViewSwitcherDropdown } from './ViewSwitcher'; @@ -48,7 +48,7 @@ import { useObjectLabel, useSafeFieldLabel, createSafeTranslation, useDisplayLoc // objectui's keyed `{ key, defaultValue, params }` ref — that vocabulary lives // on the FLAT `schema.ariaLabel` and is resolved by `SchemaRenderer` instead // (objectui#5134). -import { resolveI18nLabel as resolveInlineI18nLabel, normalizeFilterOperator, PaginationConfigSchema, ViewSharingSchema } from '@objectstack/spec/ui'; +import { resolveI18nLabel as resolveInlineI18nLabel, normalizeFilterOperator, PaginationConfigSchema } from '@objectstack/spec/ui'; import type { GroupingConfig } from '@objectstack/spec/ui'; import { resolveInjectedSystemColumns, resolveSearchFieldResolution, type SearchFieldMeta } from '@objectstack/spec/data'; import { SystemFieldName } from '@objectstack/spec/system'; @@ -1267,8 +1267,6 @@ export const LIST_DEFAULT_TRANSLATIONS: Record = { 'list.sittingInQueue': 'In a queue', 'list.sittingInQueueHint': 'Show only records waiting in a queue for someone to claim them', 'list.share': 'Share', - 'list.sharingPersonal': 'Personal view', - 'list.sharingCollaborative': 'Shared view', 'list.sharingLockedBy': 'Locked by {{user}}', 'list.print': 'Print', // objectui#4462 — the Print button's tooltip/aria sentence. Borrowed from @@ -1365,20 +1363,19 @@ function readSpecDisplayPageSize(): number { const DEFAULT_LIST_DISPLAY_PAGE_SIZE = readSpecDisplayPageSize(); /** - * The ownership type a `sharing` block means when it declares none. READ from - * `@objectstack/spec`, not restated, the same read as the page size above: - * `ViewSharingSchema` declares `type` with a default, nothing on the render - * path parses the served view, so an authored `{ lockedBy }` arrives with - * `type` absent, and parsing an empty block is the spec's own answer. - */ -const SPEC_DEFAULT_SHARING_TYPE = ViewSharingSchema.parse({}).type; - -/** - * Whether the toolbar draws the sharing indicator: a declared `type` or a - * `lockedBy`. The lock keys on `lockedBy` alone and never waits on `type`. + * Who locked the view configuration, when the `sharing` block names someone — + * the one live key of the spec's `ViewSharingSchema`, and the only thing the + * toolbar's sharing indicator draws. + * + * objectstack#29037 retired the block's ownership `type` (ADR-0049): this + * indicator was its one reader, and it labelled a view "Personal view" that + * every user who can read the object was shown, because nothing restricts a + * view to one user. No view is labelled personal or shared any more; a block + * without `lockedBy` draws nothing. */ -function hasSharingIndicator(sharing: { type?: string; lockedBy?: string } | undefined): boolean { - return !!(sharing?.type || sharing?.lockedBy); +function sharingLockedBy(sharing: { lockedBy?: unknown } | undefined): string | undefined { + const lockedBy = sharing?.lockedBy; + return typeof lockedBy === 'string' && lockedBy !== '' ? lockedBy : undefined; } /** @@ -6572,35 +6569,25 @@ export const ListView = React.forwardRef(({ )} - {/* Sharing — a read-only indicator of the spec's `ViewSharingSchema` - (`type` + optional `lockedBy`), never a button. No view-sharing + {/* Sharing — a read-only lock indicator from the spec's + `ViewSharingSchema.lockedBy`, never a button: no view-sharing control or endpoint exists to open, so a clickable "Share" here was a dead button. `lockedBy` is the spec's free string ("User - who locked the view configuration"), rendered as written. A - block with `lockedBy` and no `type` shows the lock beside the - spec's default type. */} - {hasSharingIndicator(schema.sharing) && (() => { - const sharingType = schema.sharing?.type ?? SPEC_DEFAULT_SHARING_TYPE; - const sharingLabel = sharingType === 'personal' - ? t('list.sharingPersonal') - : t('list.sharingCollaborative'); - const lockedBy = schema.sharing?.lockedBy; - const lockedLabel = lockedBy ? t('list.sharingLockedBy', { user: lockedBy }) : undefined; - const hint = lockedLabel ? `${sharingLabel} · ${lockedLabel}` : sharingLabel; + who locked the view configuration"), rendered as written. The + block's ownership `type` is retired (objectstack#29037): it + claimed "Personal view" for a view everyone could read. */} + {(() => { + const lockedBy = sharingLockedBy(schema.sharing); + if (!lockedBy) return null; + const lockedLabel = t('list.sharingLockedBy', { user: lockedBy }); return ( - ); })()} @@ -6633,7 +6620,7 @@ export const ListView = React.forwardRef(({ {/* --- Separator: Print/Share/Export | Search --- */} {(() => { - const hasLeftSideItems = schema.allowPrinting || hasSharingIndicator(schema.sharing) + const hasLeftSideItems = schema.allowPrinting || sharingLockedBy(schema.sharing) !== undefined || (!toolbarFlags.compactToolbar && resolvedExportOptions && exportPermitted && exportableFormats.length > 0); return toolbarFlags.showSearch && hasLeftSideItems ? (
diff --git a/ui/packages/plugin-list/src/__tests__/ListView.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.test.tsx index 517e5113354..dc9aa3a7ea0 100644 --- a/ui/packages/plugin-list/src/__tests__/ListView.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListView.test.tsx @@ -614,9 +614,10 @@ describe('ListView', () => { expect(region).toHaveAttribute('aria-live', 'polite'); }); - it('should render share button when sharing is enabled', () => { - // Legacy `sharing: { enabled, visibility }`; the fold collapses it onto the - // spec's `{ type }`, which is what the badge below asserts. + it('draws no sharing indicator for the legacy `{ enabled, visibility }` pair', () => { + // objectstack#29037: the fold drops the legacy audience pair (the spec's + // ownership `type` it used to fold onto is retired), so a view with no + // `lockedBy` is labelled nothing — never "Shared view" or "Personal view". const schema = authored({ type: 'list-view', objectName: 'contacts', @@ -629,14 +630,11 @@ describe('ListView', () => { }); renderWithProvider(); - const shareButton = screen.getByTestId('share-button'); - expect(shareButton).toBeInTheDocument(); - // #2890: the badge shows the spec's ownership type. The legacy four-value - // audience (`team`) collapses onto it — only `private` is personal. - expect(shareButton).toHaveAttribute('title', 'Shared view'); + expect(screen.queryByTestId('sharing-locked-by')).not.toBeInTheDocument(); + expect(screen.queryByText('Shared view')).not.toBeInTheDocument(); }); - it('should not render share button when sharing is not enabled', () => { + it('should not render the sharing indicator when sharing is not declared', () => { const schema: ListViewSchema = { type: 'list-view', objectName: 'contacts', @@ -645,7 +643,7 @@ describe('ListView', () => { }; renderWithProvider(); - expect(screen.queryByTestId('share-button')).not.toBeInTheDocument(); + expect(screen.queryByTestId('sharing-locked-by')).not.toBeInTheDocument(); }); it('should show record count bar when data is loaded', async () => { @@ -2423,26 +2421,6 @@ describe('ListView', () => { }); }); - // ============================ - // sharing spec format - // ============================ - describe('sharing spec format', () => { - it('should render share button when sharing.type is set (spec format)', () => { - const schema: ListViewSchema = { - type: 'list-view', - objectName: 'contacts', - viewType: 'grid', - fields: ['name', 'email'], - sharing: { type: 'collaborative' } as any, - }; - - renderWithProvider(); - const shareButton = screen.getByTestId('share-button'); - expect(shareButton).toBeInTheDocument(); - expect(shareButton).toHaveAttribute('title', 'Shared view'); - }); - }); - // ============================ // bulkActions bar // ============================ @@ -2580,93 +2558,62 @@ describe('ListView', () => { }); // ============================ - // sharing spec format — additional tests + // sharing — the lock indicator (objectstack#29037: `sharing.type` retired) // ============================ - describe('sharing spec format — additional', () => { - it('shows the personal sharing type, and no lock owner when `lockedBy` is unset', () => { - const schema: ListViewSchema = { - type: 'list-view', - objectName: 'contacts', - viewType: 'grid', - fields: ['name', 'email'], - sharing: { type: 'personal' }, - }; - - renderWithProvider(); - const indicator = screen.getByTestId('share-button'); - expect(indicator).toHaveAttribute('title', 'Personal view'); - expect(indicator).toHaveTextContent('Personal view'); - expect(screen.queryByTestId('sharing-locked-by')).not.toBeInTheDocument(); - }); - - it('shows the lock owner from `sharing.lockedBy` beside a collaborative type', () => { + describe('sharing lock indicator', () => { + it('shows who locked the view from `sharing.lockedBy`', () => { const schema: ListViewSchema = { type: 'list-view', objectName: 'contacts', viewType: 'grid', fields: ['name', 'email'], - sharing: { type: 'collaborative', lockedBy: 'admin@example.com' }, + sharing: { lockedBy: 'admin@example.com' }, }; renderWithProvider(); - const indicator = screen.getByTestId('share-button'); - expect(indicator).toHaveAttribute('title', 'Shared view · Locked by admin@example.com'); - expect(screen.getByTestId('sharing-locked-by')).toHaveTextContent('Locked by admin@example.com'); + const indicator = screen.getByTestId('sharing-locked-by'); + expect(indicator).toHaveAttribute('title', 'Locked by admin@example.com'); + expect(indicator).toHaveTextContent('Locked by admin@example.com'); }); - it('shows no lock owner on a collaborative view without `lockedBy`', () => { + it('renders the indicator as read-only text, never a button (no dead Share button)', () => { const schema: ListViewSchema = { type: 'list-view', objectName: 'contacts', viewType: 'grid', fields: ['name', 'email'], - sharing: { type: 'collaborative' }, + sharing: { lockedBy: 'admin@example.com' }, }; renderWithProvider(); - expect(screen.getByTestId('share-button')).toHaveAttribute('title', 'Shared view'); - expect(screen.queryByTestId('sharing-locked-by')).not.toBeInTheDocument(); - }); - - it('renders the sharing indicator as read-only text, never a button (no dead Share button)', () => { - const schema: ListViewSchema = { - type: 'list-view', - objectName: 'contacts', - viewType: 'grid', - fields: ['name', 'email'], - sharing: { type: 'collaborative', lockedBy: 'admin@example.com' }, - }; - - renderWithProvider(); - const indicator = screen.getByTestId('share-button'); + const indicator = screen.getByTestId('sharing-locked-by'); expect(indicator.tagName).not.toBe('BUTTON'); expect(indicator.closest('button')).toBeNull(); expect(indicator).not.toHaveAttribute('role'); expect(indicator).not.toHaveAttribute('tabindex'); }); - // The lock keys on `lockedBy`, never on `type`: the spec defaults `type` - // to `collaborative` and the served view carries the authored body, so a - // view that writes only `lockedBy` arrives with `type` absent. + // A view served from a row older than the retirement can still carry the + // retired `type` until the conversion runs on it; the toolbar ignores it. it.each([ - ['no `type` (the spec default)', { lockedBy: 'admin@objectos.ai' }, 'Shared view'], - ['`type: collaborative`', { type: 'collaborative' as const, lockedBy: 'admin@objectos.ai' }, 'Shared view'], - ['`type: personal`', { type: 'personal' as const, lockedBy: 'admin@objectos.ai' }, 'Personal view'], - ])('shows the lock from `sharing.lockedBy` with %s', (_case, sharing, typeLabel) => { - const schema: ListViewSchema = { + ['`type: personal`', { type: 'personal', lockedBy: 'admin@objectos.ai' }], + ['`type: collaborative`', { type: 'collaborative', lockedBy: 'admin@objectos.ai' }], + ])('labels no view personal or shared, whatever %s says, and still shows the lock', (_case, sharing) => { + const schema = authored({ type: 'list-view', objectName: 'contacts', viewType: 'grid', fields: ['name', 'email'], sharing, - }; + }); renderWithProvider(); - expect(screen.getByTestId('sharing-locked-by')).toHaveTextContent('Locked by admin@objectos.ai'); - expect(screen.getByTestId('share-button')).toHaveAttribute('title', `${typeLabel} · Locked by admin@objectos.ai`); + expect(screen.getByTestId('sharing-locked-by')).toHaveAttribute('title', 'Locked by admin@objectos.ai'); + expect(screen.queryByText('Personal view')).not.toBeInTheDocument(); + expect(screen.queryByText('Shared view')).not.toBeInTheDocument(); }); - it('shows no sharing indicator for a `sharing` block with neither `type` nor `lockedBy`', () => { + it('shows no sharing indicator for a `sharing` block without `lockedBy`', () => { const schema: ListViewSchema = { type: 'list-view', objectName: 'contacts', @@ -2676,7 +2623,6 @@ describe('ListView', () => { }; renderWithProvider(); - expect(screen.queryByTestId('share-button')).not.toBeInTheDocument(); expect(screen.queryByTestId('sharing-locked-by')).not.toBeInTheDocument(); }); }); diff --git a/ui/packages/plugin-list/src/__tests__/ListViewBlock.inertKeys-13074.test.tsx b/ui/packages/plugin-list/src/__tests__/ListViewBlock.inertKeys-13074.test.tsx index 9780dbfd2b7..02d8bd8d398 100644 --- a/ui/packages/plugin-list/src/__tests__/ListViewBlock.inertKeys-13074.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListViewBlock.inertKeys-13074.test.tsx @@ -270,11 +270,11 @@ describe('objectui#13074 — `addRecord` on a page `list-view` node calls the `o }); describe('objectui#13074 — `sharing` stays inert and unpublished (CONTROL)', () => { - it('the Share button still carries no click handler: a click changes nothing on the page', async () => { - const { dataSource } = mount({ sharing: { type: 'personal' } }); + it('the lock indicator, the one thing `sharing` draws, carries no click handler: a click changes nothing on the page', async () => { + const { dataSource } = mount({ sharing: { lockedBy: 'admin' } }); await listDrawn(dataSource); await settle(); - const share = screen.getByTestId('share-button'); + const share = screen.getByTestId('sharing-locked-by'); const before = document.body.innerHTML; fireEvent.click(share); await settle(); diff --git a/ui/packages/plugin-list/src/__tests__/ListViewBlock.specInputs-13047.test.tsx b/ui/packages/plugin-list/src/__tests__/ListViewBlock.specInputs-13047.test.tsx index ec019d4430d..c0efd5175d1 100644 --- a/ui/packages/plugin-list/src/__tests__/ListViewBlock.specInputs-13047.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListViewBlock.specInputs-13047.test.tsx @@ -760,10 +760,10 @@ describe('objectui#13047 — measured NOT honoured on a page `list-view` node (h expect(container.textContent ?? '').not.toContain('open_tasks'); }); - it('`sharing`: draws a Share button that carries no click handler — the sharing config is enforced nowhere here', async () => { - const { dataSource } = mount({ sharing: { type: 'personal' } }); + it('`sharing`: draws a lock indicator that carries no click handler — the sharing config is enforced nowhere here', async () => { + const { dataSource } = mount({ sharing: { lockedBy: 'admin' } }); await loaded(dataSource); - const share = screen.getByTestId('share-button'); + const share = screen.getByTestId('sharing-locked-by'); // React attaches no handler: a click changes nothing on the page. const before = document.body.innerHTML; fireEvent.click(share); diff --git a/ui/packages/plugin-view/src/__tests__/ObjectView.namedViewProtocolKeys-8980.test.tsx b/ui/packages/plugin-view/src/__tests__/ObjectView.namedViewProtocolKeys-8980.test.tsx index e51b9659426..cbaee478ac2 100644 --- a/ui/packages/plugin-view/src/__tests__/ObjectView.namedViewProtocolKeys-8980.test.tsx +++ b/ui/packages/plugin-view/src/__tests__/ObjectView.namedViewProtocolKeys-8980.test.tsx @@ -447,7 +447,7 @@ const FAMILIES: Record> = { compactToolbar: { named: true, host: false, node: true }, allowPrinting: { named: true, host: false, node: true }, showRecordCount: { named: false, host: true, node: false }, - sharing: { named: { type: 'collaborative' }, host: { type: 'personal' }, node: { type: 'personal', lockedBy: 'node' } }, + sharing: { named: { lockedBy: 'named' }, host: { lockedBy: 'host' }, node: { lockedBy: 'node' } }, aria: { named: { ariaLabel: 'Named tasks' }, host: { ariaLabel: 'Host tasks' }, node: { ariaLabel: 'Node tasks' } }, emptyState: { named: { title: 'Named empty' }, host: { title: 'Host empty' }, node: { title: 'Node empty' } }, }, diff --git a/ui/packages/types/src/__tests__/p1-spec-alignment.test.ts b/ui/packages/types/src/__tests__/p1-spec-alignment.test.ts index 7c9875343dc..66210873713 100644 --- a/ui/packages/types/src/__tests__/p1-spec-alignment.test.ts +++ b/ui/packages/types/src/__tests__/p1-spec-alignment.test.ts @@ -206,16 +206,15 @@ describe('P1.1 ListView Spec Alignment', () => { }); // P2: Sharing / ExportOptions / Pagination protocol alignment tests - it('should accept sharing in spec format { type, lockedBy }', () => { + // objectstack#29037: the spec retired `sharing.type`; the block is `{ lockedBy }`. + it('should accept sharing in spec format { lockedBy }', () => { const schema: ListViewSchema = { type: 'list-view', objectName: 'Account', sharing: { - type: 'collaborative', lockedBy: 'admin@example.com', }, }; - expect(schema.sharing?.type).toBe('collaborative'); expect(schema.sharing?.lockedBy).toBe('admin@example.com'); });