diff --git a/.changeset/16929-page-assigned-profiles-removed.md b/.changeset/16929-page-assigned-profiles-removed.md new file mode 100644 index 0000000000..90876d8ba7 --- /dev/null +++ b/.changeset/16929-page-assigned-profiles-removed.md @@ -0,0 +1,36 @@ +--- +'@objectstack/spec': minor +--- + +**BREAKING** — remove `page.assignedProfiles`, and answer `profiles:` / `assignedTo:` with the permission-set route instead of correcting an author into the retired vocabulary. + +`PageSchema` carried an authorable key named for the concept **ADR-0090 D2** deleted ("The Profile concept is removed — `isProfile` deleted, not deprecated"), and the schema's own alias table rewrote an authored `profiles:` **into** it — two files from `security/permission.zod.ts`, which answers the same word with *"`profiles` is not a PermissionSet field (ADR-0090 D2: no Profile concept)"*. One word, two opposite answers, depending on which schema received it. + +It also enforced nothing. Measured across this repository and objectui at the ruling: **zero readers** — every hit was a declaration, a generated artifact, prose, a `CHANGELOG` or a round-trip test — so a page that "assigned profiles" stayed open to every caller who could reach it, while the Studio form and four locale bundles told the author it was an access list. ADR-0049 enforce-or-remove; maintainer ruling 2026-09-12. + +## FROM → TO + +| you wrote (17.4 and earlier) | write instead | +| --- | --- | +| `assignedProfiles: ['sales_manager']` on a page | delete the key. Gate the DATA the page shows with the object's permission sets, and bind those sets to people through positions (`sys_position_permission_set`) | +| `profiles: [...]` on a page (the alias corrected it into `assignedProfiles`) | the same — the alias is now a refusal naming the permission-set route, and it never accepted the key anyway | +| `assignedTo: [...]` on a page | the same | + +**The one-line fix:** delete the key; page audience is the permission set's. + +`os migrate meta --from 17` lists the mechanical edits for existing sources; apply them by hand. + +## The retirement kit + +- **A `retiredKey()` tombstone, not a bare deletion.** `PageSchema` is still parsed from the `page` metadata-type root, so there is an author to teach: `tsc` types the key `never`, and a value reaching a parse raises the prescription rather than a bare unrecognized-key report. The key therefore stays in the walked shape, which is why its liveness row stays too (as `dead`, the `rls.priority` precedent) and why the authorable-surface baseline marks it `[RETIRED]` rather than losing the line. +- **The two alias entries are gone from `aliases` and present in `guidance`.** This narrows nothing: an alias table runs only from the `unrecognized_keys` path, so `profiles:` and `assignedTo:` were *already refused* — the entries only decorated the rejection, and they decorated it with the retired word. Measured before and after on the built artifact: same `issue.code`, same `path`, different text. +- **`page.form.ts`** — the `assignedProfiles` input and its `helpText: 'Profiles that can access this page'` are removed, and with them the four locale bundles that shipped it translated (`zh-CN` 「指定配置文件」, `ja-JP`「割り当てプロファイル」, `es-ES` "Perfiles asignados"). A form input for an unwritable key is the false-compliant UI half of a retirement. +- **Three records that asserted the key WAS enforced are corrected in the same change** — one place alone only moves the lie. `liveness/page.json` graded it `live` on the strength of an objectui bridge at `react/src/spec-bridge/bridges/page.ts`, a path that does not exist in that repo (the row itself stays, regraded `dead`: the tombstone keeps the key in the walked shape, so the row remains and records why). `api/protocol.zod.ts` and `metadata-protocol`'s search-sweep comment both said the page's "own audience gate" applied at page render; it did not, and a page has no audience gate of its own. + +## What an operator with a STORED page sees + +A `sys_metadata` `page` row written before this release can carry `assignedProfiles`. Nothing breaks at read: the ADR-0087 conversion `page-assigned-profiles-removed` (protocol 18) replays on rehydration and strips the key, so the row is served canonical. `os migrate meta --stored --apply` rewrites the rows so the warn stops; the next save through `PUT /api/v1/meta/page` heals one row the way it heals any pre-protocol shape. + +⚠️ The strip is the mechanical half only. The paired D3 semantic entry `page-assigned-profiles-audience-to-permission-set` carries the judgement: which permission set a given profile name corresponds to is not derivable by a walker, so each name in a retired list has to be re-expressed as a permission set plus a position. Deleting the key **changes no behaviour and closes no hole** — the page was already open to everyone who could reach it. It stops an unkept promise from being made. + + diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index a1ce95db0b..04d2da5772 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -177,7 +177,7 @@ View filter rule | **template** | `string` | optional (default: `"default"`) | Layout template name (e.g. "header-sidebar-main") | | **regions** | `{ name: string; width?: Enum<'small' \| 'medium' \| 'large' \| 'full'>; components: object[] }[]` | optional | Layout regions (header, main, sidebar, footer) with their components. Optional — list pages use interfaceConfig, slotted pages use slots, and an empty full page falls back to the synthesized default layout. | | **isDefault** | `boolean` | optional (default: `false`) | | -| **assignedProfiles** | `string[]` | optional | | +| **assignedProfiles** | `never` | optional | [REMOVED] `page.assignedProfiles` was removed in @objectstack/spec 17.5.0 (ADR-0090 D2, ADR-0049 enforce-or-remove) — it was named for the Profile concept ADR-0090 D2 deleted, and it gated nothing: no renderer, route or metadata read door ever read the key, so a page that "assigned profiles" stayed open to every caller who could reach it. Delete the key. Page audience is the permission set's: gate the DATA the page shows with the object's permission sets, and bind those sets to people through positions (`sys_position_permission_set`) — those are the checks the runtime actually runs. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **interfaceConfig** | `{ source?: string; columns?: string[] \| object[]; sort?: object[]; filterBy?: object[]; … }` | optional | Interface-level page configuration (for Airtable-style interface pages) | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | | **kind** | `Enum<'full' \| 'slotted' \| 'html' \| 'react' \| 'jsx'>` | optional (default: `"full"`) | Page override mode. full \| slotted = structured authoring; html = author-written constrained JSX compiled (parsed, never executed) to the tree (ADR-0080; the legacy value 'jsx' is a deprecated alias), styled by the registered components' structured props plus a JSON `style` object with hsl(var(--token)) theme colors; react = real-React source executed at render by the runtime (ADR-0081), styled by inline `style` with the same token colors; it runs author JS, so it is gated by a host capability that defaults ON and is disabled server-side via the OS_PAGE_REACT=off env toggle. Do not author Tailwind classes in page source in either tier: `source` is runtime metadata the build-time Tailwind never scans, so utility classNames silently produce no CSS (ADR-0065; ADR-0080 amendment 2026-06-30). | diff --git a/content/docs/ui/pages.mdx b/content/docs/ui/pages.mdx index 6dbbb16360..94e34f601b 100644 --- a/content/docs/ui/pages.mdx +++ b/content/docs/ui/pages.mdx @@ -66,7 +66,6 @@ const homePage = { | `source` | `string` | optional | Page source text — required (and authoritative over `regions`) when `kind` is `'html'` or `'react'`. For `kind: 'html'` it is constrained JSX/HTML compiled to the tree by `@objectstack/sdui-parser` at save time (parse, never execute). For `kind: 'react'` it is real React/JSX executed at render by `@object-ui/react-runtime` (trusted tier). See [React Pages](/docs/ui/react-pages) | | `variables` | `PageVariable[]` | optional | Local state variables | | `isDefault` | `boolean` | optional | Is default page for its type | -| `assignedProfiles` | `string[]` | optional | Profiles that can access this page | ### Page Types diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 42fafd8aaf..ddf3d433d0 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -11974,12 +11974,15 @@ export class ObjectStackProtocolImplementation implements // // So a page hit surfaces to a caller exactly what `GET /meta/page` // already answers that caller — name, label, description — never - // more, and search is not a second read door. The page's own audience - // gate (`assignedProfiles`) is measured to have no backend consumer - // on the read door today; it is enforced where it is enforced now, at - // page render — the delegation posture direction 1's ruling recorded - // (a second enforcement point here would be a NEW authorization - // surface, the very thing the ruling's basis excludes). + // more, and search is not a second read door. ⚠️ A page has NO audience + // gate of its own. `page.assignedProfiles` read as one and enforced + // nothing — no backend consumer on the read door, and none at page + // render either (measured in objectui: every hit there was a + // declaration) — so it was removed under ADR-0090 D2 / ADR-0049. The + // delegation posture this sweep rests on is unaffected: what protects + // a page is the permission sets on the DATA it shows, and a second + // enforcement point here would still be a NEW authorization surface, + // the very thing the ruling's basis excludes. // // ## Matching and shape // diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index a79b93db94..b2447223e8 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -880,10 +880,6 @@ export const enMetadataForms: NonNullable = { label: "Kind", helpText: "Page override mode: full or slotted (for record pages)" }, - assignedProfiles: { - label: "Assigned Profiles", - helpText: "Profiles that can access this page" - }, aria: { label: "Aria", helpText: "Accessibility attributes (ARIA labels, roles)" diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 4ed2139bb9..0f49d4a86a 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -880,10 +880,6 @@ export const esESMetadataForms: NonNullable = label: "Modo", helpText: "Modo de anulación de página: full o slotted (para páginas record)" }, - assignedProfiles: { - label: "Perfiles asignados", - helpText: "Perfiles que pueden acceder a esta página" - }, aria: { label: "Accesibilidad", helpText: "Atributos de accesibilidad (etiquetas ARIA, roles)" diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 553cc8987d..02a47bb95f 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -880,10 +880,6 @@ export const jaJPMetadataForms: NonNullable = label: "モード", helpText: "ページ上書きモード: full または slotted(record ページ用)" }, - assignedProfiles: { - label: "割り当てプロファイル", - helpText: "このページにアクセス可能なプロファイル" - }, aria: { label: "アクセシビリティ", helpText: "アクセシビリティ属性(ARIA ラベル、ロール)" diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index fa6dab918b..8c67614f7b 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -880,10 +880,6 @@ export const zhCNMetadataForms: NonNullable = label: "模式", helpText: "页面覆盖模式:完整(full)或插槽式(slotted),仅用于记录页。" }, - assignedProfiles: { - label: "指定配置文件", - helpText: "此页面对哪些 Profile 可用" - }, aria: { label: "无障碍", helpText: "无障碍标签与角色" diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index c02460b038..c621dc628a 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -894,7 +894,7 @@ "ui/Page:_packageVersion", "ui/Page:_provenance", "ui/Page:aria", - "ui/Page:assignedProfiles", + "ui/Page:assignedProfiles [RETIRED]", "ui/Page:description", "ui/Page:icon", "ui/Page:interfaceConfig", diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index 32cf9a4958..b2203fd9f7 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -898,7 +898,7 @@ marker where the Notes cell goes, never a guess at what belongs there. | tool | the inert authoring surface is now REMOVED, not merely marked: `category`/`permissions`/`active`/`builtIn` retired 2026-07-30 (#3896 close-out) after `requiresConfirmation` set the precedent (#3715, ADR-0033 §2). `permissions` promised an invocation gate nothing enforced and `active:false` withdrew nothing — false compliance, same shape as rls.enabled. The `.strict()` ToolSchema rejects each retired key with its prescription; the `tool-inert-authoring-keys-removed` conversion strips them from authored sources | | skill | `permissions` REMOVED 2026-07 (#3704); `triggerPhrases` REMOVED 2026-07-30 (#3896 close-out sweep — phrases were never matched; activation is `triggerConditions` + the agent's `skills[]` + /skill-name pinning) | | dataset | `measures.certified` (declared-but-unenforced governance flag) REMOVED in 16.0 (#2377) | -| page | fully live + one planned | +| page | live + one planned; dead `assignedProfiles` REMOVED 2026-09-12 (ADR-0090 D2 + ADR-0049 — a per-page audience list named for the concept D2 deleted, with zero readers in either repo, so the page was open to everyone who could reach it). The row stays because `retiredKey` keeps the key in the walked shape (the `rls.priority` precedent). Its prior `live` verdict is the #12516 class twice over: the objectui bridge it cited never existed (lit control — two sibling objectui citations in the same file resolve), and the entry carried no `verifiedAt`, so nothing ever re-asked | | view | list/form drilled via `children` (#2998 Track B); list.{responsive,performance} + form.{defaultSort,aria} REMOVED 2026-07-30 (#3896 close-out sweep — list aria/data stay live); **form.data was that sweep's one CORRECTION** — the removal attempt broke the build (`defineForm` writes `data.provider='schema'` onto every metadata form, `metadata-protocol` serves it), so it stands `live` with re-verified evidence; form.{buttons,defaults} live (framework#1894 / #2998); audit-era DEAD lines superseded by re-verification. **The dead set is six, not the four removals above**: #4534 (the last #4001 batch, batch 6e) declared three CONTAINER-level keys this row had never classified — `name` and `label`, both `dead`, and `object`, `live`. All three are properties of the `views: [...]` *container*, not of a view: `name` is dead as a BODY key because the live one is the `sys_metadata` row column the door supplies, and `label` is container display metadata with no reader. Neither is `authorWarn`'d and both are deliberately KEPT — the platform's own writers send `name` (artifact-shipped containers, the metadata-validation sweep), so tombstoning it would reject shapes we write ourselves. `object` is the container's object binding, and it was *stripped on every parse* until #4534 declared it. Separately, the level-2 dead residue (userActions.buttons, addRecord.mode/formView, tabs[].order) is noted on parents and is **not** in the counts — one drill level only **#9340**: `list.map` declared — the eighth visualization block (`ListMapConfigSchema`), keys mirroring objectui plugin-map's documented read set. FLIPPED `planned` → `live` 2026-08-24 (#11442): objectui#5908 landed `resolveListMapConfig`, which merges the view-level `map` block over the legacy `options.map` bag before `ListView.tsx`'s `case 'map'` forwards it into `ObjectMap`, with the same merged config also feeding the visualization-switcher's capability gate so a view binding coordinates only in the spec block is no longer filtered out of `allowedVisualizations` either (objectui#5042) | | report | dataset-bound (ADR-0021); the aria/performance LEDGER entries were stale — the keys left the schema in the report-liveness close-out; deleted 2026-07-30 as hygiene. Audit-era `chart` DEAD superseded (framework#1890 / #3441) | | dashboard | ADR-0021 dataset widgets (#3251; DashboardWidgetSchema `.strict()`); `aria`/`performance` (and widget `performance` + PerformanceConfigSchema) REMOVED 2026-07-30 (#3896 close-out sweep — no renderer applied any of them); audit-era `globalFilters`/`dateRange` DEAD superseded (framework#2501) **#4956**: `widgets` DRILLED — the row jumps 20 → 41 classified because all 22 widget-level keys enter the count at once. They had never been classified at all: the entry carried one blanket `live` plus a `note` asserting they were classified "in the DashboardWidgetSchema subtree", and no such subtree existed in any of the 28 ledger files. That gap, not any evidence, is what carried `widgets[].responsive` through the #3896 sweep that removed both its sibling `widgets[].performance` and its literal namesake `view.responsive` — `view` is drilled, so `list.responsive` got asked and went out. New dead 6 = `responsive` (retired #4876/#4995, tombstone keeps the row) + `colorVariant` + `actionUrl`/`actionType`/`actionIcon` + `aria`. The action trio is the sharpest: no renderer draws a per-widget action button at all (every `actionUrl` read in DashboardRenderer is scoped to `header.actions[]`), yet `validate-dashboard-action-refs.ts` enforces reference integrity on it and its docblock calls it "the per-widget button" — a lint guarding an affordance that does not exist. `requiresService` is the counter-example worth remembering: dead by every objectui measurement, and LIVE server-side (`filterDashboardForUser`, ADR-0057 D10) — judging a widget key from the renderer repo alone would have retired an enforced gate. `compareTo` is `live` on ONE path only (inline object-provider charts); on the ADR-0021 dataset path the string arms are dropped and `{ offset }` throws in the executor. **#6774** moves the row 33/8 → 34/7: `colorVariant` CORRECTED dead → live 2026-08-09, the enforce leg of #5010 ruling B landing from the renderer side (objectui#3359 / PR objectui#3799, absorbed by pin `09987b68`). Worth reading beside `requiresService` above, because it is the same lesson from the other end — that row warns against judging a widget key from the renderer repo alone, and this one is a `dead` verdict that was correct in this repo AND correct in the renderer repo on the day it was measured, and stopped being either when a cross-repo decision was implemented. A ledger row is a claim with a timestamp; `verifiedAt` is what makes the claim re-askable. It also empties the dashboard warn set, so the author-side lint now says nothing about any widget key — `dashboard` stays in the lint's TYPE_COLLECTIONS all the same (the `webhook`/`email_template` resolved state). **#17385** DRILLS `widgets.chartConfig` — 14 per-key verdicts where the row had carried one blanket `live`, re-measured against `.objectui-sha` pin `53ded82bf7a4`: 12 live (the nine chrome keys `chartConfigPresentation` lowers, plus `xAxis`/`yAxis`/`series`, whose PRESENTATION merges onto the derived bindings while `ChartAxis.field` and `ChartSeries.name` are dropped so membership stays with the dataset) and dead 2 — `type`, which parses and does nothing because the widget's own `type` owns the chart family, and `aria`, which has no reader on either face. Both are pinned as NEGATIVES in objectui, which is what makes them re-askable rather than merely asserted. ⚠️ The drill made SIX containers one level further down visible for the first time (`xAxis`/`yAxis`/`series`/`annotations`/`interaction`/`aria`, 39 child keys); they are RECORDED, not drilled — fanning this row's verdicts down over them would manufacture verdicts, and the evidence work is a separate measurement. Note the cell's previous last stated position (`34/7`) had already drifted one `dead` behind the generated artifact before this change; the counts columns are generated and are the authority | diff --git a/packages/spec/liveness/page.json b/packages/spec/liveness/page.json index ac07f5d10e..b180f98f01 100644 --- a/packages/spec/liveness/page.json +++ b/packages/spec/liveness/page.json @@ -52,14 +52,15 @@ "evidence": "objectui: packages/components/src/renderers/layout/page.tsx:290 branches on schema.regions, :197-204 resolves the named slots (header/sidebar/main/aside/footer) and appends the remainder below main at :211, and :157 flattens their components for the JSX-source path — measured objectui @11c1e71e", "note": "region → component tree rendering (header/main/sidebar/footer). RE-CITED 2026-08-10 (#7133/#7142): same split-citation pruning as `template` — at objectui @11c1e71e the containers.tsx half names `regions` only in a comment (:798, 'page:section — thin wrapper used inside regions'). Verdict unchanged: LIVE. Component-level liveness stays unclassified (undrilled container, #4956); one component key carries a verdict OUTSIDE this file: `components[].responsive` is a retiredKey tombstone (#11027, ADR-0049 D2 — dead on a two-repo measurement; see the file _note)." }, + "assignedProfiles": { + "status": "dead", + "verifiedAt": "2026-09-12", + "note": "REMOVED 2026-09-12 (ADR-0090 D2 + ADR-0049 enforce-or-remove; maintainer ruling 2026-09-12, decision batch #121 item 2, verbatim \u300c\u540c\u610f\u300d) \u2014 tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-18 page-assigned-profiles-removed conversion. BOTH halves of the prior `live` row were false: the objectui bridge it cited, react/src/spec-bridge/bridges/page.ts, does not exist in that repo and neither does any spec-bridge directory (lit control: two sibling objectui citations in this same file resolve), and the key had zero readers in either repo \u2014 no renderer, route or metadata read door \u2014 so a page that assigned profiles was open to every caller who could reach it. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); page audience is the permission set\u2019s \u2014 gate the DATA with the object\u2019s permission sets and bind those sets to people through positions." + }, "isDefault": { "status": "live", "note": "default-page selection for an object/page-type — objectui page resolution." }, - "assignedProfiles": { - "status": "live", - "note": "profile-scoped page audience; objectui bridges it (react/src/spec-bridge/bridges/page.ts) to PageLayout.assignedProfiles. End-to-end enforcement not independently verified (necessary-not-sufficient, ADR-0054)." - }, "interfaceConfig": { "status": "live", "note": "the entire Airtable-parity list surface (source/columns/filterBy/appearance/userFilters/userActions/addRecord/buttons/recordAction/...) — objectui app-shell/src/views/InterfaceListPage.tsx + PageView.tsx. Verified in browser during the dogfood (grid+kanban+filters)." diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index a1f162e7da..20850147af 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -38,7 +38,7 @@ for both corollaries. | `tool` | 13 | 1 | 0 | 0 | 0 | 14 | | `skill` | 16 | 0 | 0 | 1 | 0 | 17 | | `dataset` | 27 | 0 | 0 | 0 | 0 | 27 | -| `page` | 23 | 0 | 0 | 0 | 1 | 24 | +| `page` | 22 | 0 | 0 | 1 | 1 | 24 | | `view` | 78 | 0 | 0 | 11 | 0 | 89 | | `report` | 21 | 0 | 0 | 0 | 0 | 21 | | `dashboard` | 45 | 0 | 0 | 10 | 0 | 55 | @@ -63,4 +63,4 @@ for both corollaries. | `batch_endpoints` | 5 | 0 | 0 | 2 | 0 | 7 | | `route_generation` | 0 | 0 | 0 | 4 | 0 | 4 | | `realtime_subscription` | 0 | 0 | 0 | 6 | 0 | 6 | -| **total** | **863** | **5** | **1** | **95** | **10** | **974** | +| **total** | **862** | **5** | **1** | **96** | **10** | **974** | diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 5265288c5b..78496f3f29 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -2233,9 +2233,13 @@ export const SearchAllHitSchema = lazySchema(() => z.object({ * state only, org-scoped through the same registry-derived predicate the * REST `/meta` read doors use), so a page hit surfaces exactly what * `GET /api/v1/meta/page` would have answered the same caller — never more. - * Opening the hit goes through the existing page routes/renderer, where the - * page's own audience gate (`assignedProfiles`) applies unchanged; the - * search response is not a second read door. + * Opening the hit goes through the existing page routes/renderer, which is + * where whatever protects the page applies — unchanged by this surface, and + * NOT a page-level audience gate: a page has none. `page.assignedProfiles` + * was the key that read as one, and it was removed (ADR-0090 D2 / ADR-0049) + * precisely because nothing anywhere ever enforced it. What protects a page + * is the permission sets on the DATA it shows. The search response is not a + * second read door either way. * * NOT a member of {@link SearchAllHitSchema}'s array: page hits live in the * sibling `pages` array so an existing consumer iterating `hits` (every one diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 99a9f87f3e..d21a66aa3a 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -9496,6 +9496,59 @@ const listViewSortStringClauseToArray: MetadataConversion = { }, }; +/** + * `page.assignedProfiles` removed (protocol 18, ADR-0090 D2 / ADR-0049). + * + * The key named the Profile concept ADR-0090 D2 deleted, and it enforced + * nothing anywhere: measured across this repository and objectui, every hit was + * a declaration, a generated artifact, prose or a round-trip test — no renderer, + * route or metadata read door ever read it. A page that "assigned profiles" + * therefore stayed open to every caller who could reach it, which is the + * declared-not-enforced class ADR-0049 retires. Maintainer ruling 2026-09-12. + * + * **A strip, not a rewrite.** There is no lossless target: page audience is the + * permission set's, and which set a given page's profile list corresponds to is + * a judgement no walker can make. So the mechanical half deletes the key (here) + * and the judgement half is a D3 semantic TODO + * (`page-assigned-profiles-audience-to-permission-set`) that names the route. + * The two halves are deliberate: a conversion that silently dropped an audience + * declaration with no TODO would read as "handled". + * + * ⚠️ Coverage boundary: this walks `stack.pages[]` ({@link mapPages}), the one + * collection the key was authorable on. `assignedProfiles` has no nested + * spelling — it was a top-level `PageSchema` key — so there is no second site. + */ +const pageAssignedProfilesRemoved: MetadataConversion = { + id: 'page-assigned-profiles-removed', + toMajor: 18, + retiredFromLoadPath: true, + surface: 'page.assignedProfiles', + summary: + "page key 'assignedProfiles' removed (ADR-0090 D2 deleted the Profile concept it was named " + + 'for, and no renderer, route or read door ever enforced it — the page stayed open to ' + + 'everyone; ADR-0049 enforce-or-remove)', + apply(stack, emit) { + return mapPages(stack, (page, path) => stripKeys(page, ['assignedProfiles'], emit, path)); + }, + fixture: { + before: { + pages: [ + // The authored shape: a page whose author believed the list gated it. + { name: 'deal_desk', label: 'Deal Desk', assignedProfiles: ['admin', 'sales_manager'] }, + // A page with nothing to strip keeps its identity (copy-on-write). + { name: 'team_home', label: 'Team Home', isDefault: true }, + ], + }, + after: { + pages: [ + { name: 'deal_desk', label: 'Deal Desk' }, + { name: 'team_home', label: 'Team Home', isDefault: true }, + ], + }, + expectedNotices: 1, + }, +}; + export const CONVERSIONS_BY_MAJOR: Readonly> = { 11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename], 13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition], @@ -9595,6 +9648,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly.allowRead` and ' + + 'the field-level bits), and bind each set to the people who should hold it through a position ' + + '(`sys_position_permission_set`). There is no per-page audience key to move the list into, ' + + 'and ADR-0090 D2 deleted the Profile concept the old list was written in, so each name in a ' + + 'retired `assignedProfiles` list has to be re-expressed as a permission set + position pair.', + reason: + 'The D2 conversion `page-assigned-profiles-removed` STRIPS the key mechanically, but the strip ' + + 'is not the whole migration and must not read as one: the author who wrote the list was ' + + 'declaring an intent ("only these people see this page") that the platform never honoured. ' + + 'Measured at the ruling: zero readers in this repository and zero in objectui — no renderer, ' + + 'route or metadata read door consulted the key — so the page has been open to every caller ' + + 'who could reach it for as long as the key existed. Deleting it therefore changes no ' + + 'behaviour and closes no hole; it makes an unkept promise stop being made. Which permission ' + + 'set corresponds to a given profile name is a judgement no walker can derive, which is why ' + + 'this is a TODO rather than a rewrite.', + acceptanceCriteria: + 'No page metadata carries `assignedProfiles` (the D2 conversion ' + + '`page-assigned-profiles-removed` strips it from authored sources on a chain replay; ' + + '`os migrate meta --stored` covers rows already at rest). For every page that carried one, ' + + 'each name in the old list resolves to a permission set held by the intended people through ' + + 'a position, and a caller OUTSIDE that audience, signed in, is refused the data the page ' + + 'reads — verified against the running deployment, not against the metadata alone. A caller ' + + 'who was previously outside an `assignedProfiles` list and could nonetheless open the page ' + + 'is the pre-existing state, not a regression introduced by the removal.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 6b5d3af334..d071e3b655 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5470,7 +5470,19 @@ const step18: MigrationStep = { + 'multi-key form becomes one entry per key in the same order. A string that does not parse as ' + 'that grammar — the `\'-field\'` dialect above all — is left alone and meets the door instead: ' + 'that dialect belongs to `RecordRelatedListProps.sort`, never reaches ' - + '`convertSortToQueryParams`, and retiring it was NOT ruled.', + + '`convertSortToQueryParams`, and retiring it was NOT ruled. ' + + 'It also removes `page.assignedProfiles` (ADR-0090 D2 / ADR-0049 enforce-or-remove; ' + + 'maintainer ruling 2026-09-12 \u300c\u540c\u610f\u300d). The key was authorable on the published ' + + '`PageSchema` and named for the Profile concept ADR-0090 D2 deleted, while the schema\'s own ' + + 'alias table CORRECTED an authored `profiles:` into it — two files from ' + + '`security/permission.zod.ts` answering the same word with "no Profile concept". Measured ' + + 'across this repository and objectui it had zero readers, so a page that "assigned ' + + 'profiles" was open to every caller who could reach it. It is a retiredKey tombstone on ' + + '`PageSchema` — the def is still parsed from the `page` root, so there is an author to ' + + 'teach — and the two alias entries became refusals naming the permission-set route. The ' + + 'D2 conversion STRIPS the key — there is no lossless target, because which permission set ' + + 'a given profile name corresponds to is a judgement no walker can make, which is what the ' + + 'paired D3 semantic entry is for.', conversionIds: [ 'field-malformed-scale-precision-removed', 'record-chatter-position-vocabulary', @@ -5499,6 +5511,7 @@ const step18: MigrationStep = { 'turso-config-timeout-to-timeout-ms', 'view-page-mount-removed', 'list-view-sort-string-clause-to-array', + 'page-assigned-profiles-removed', ], semantic: [ // One file per entry under `entries/semantic/`, concatenated here sorted by @@ -9420,6 +9433,36 @@ const step18: MigrationStep = { + 'bytes before and after — the retirement removes a false claim, not ' + 'behaviour.', }, + { + id: 'page-assigned-profiles-audience-to-permission-set', + surface: '`page.assignedProfiles` — the per-page audience list (REMOVED)', + replacement: + "the object's permission sets, bound to people through positions. The page shows DATA; gate " + + 'that data with the permission sets on the objects it reads (`objects..allowRead` and ' + + 'the field-level bits), and bind each set to the people who should hold it through a position ' + + '(`sys_position_permission_set`). There is no per-page audience key to move the list into, ' + + 'and ADR-0090 D2 deleted the Profile concept the old list was written in, so each name in a ' + + 'retired `assignedProfiles` list has to be re-expressed as a permission set + position pair.', + reason: + 'The D2 conversion `page-assigned-profiles-removed` STRIPS the key mechanically, but the strip ' + + 'is not the whole migration and must not read as one: the author who wrote the list was ' + + 'declaring an intent ("only these people see this page") that the platform never honoured. ' + + 'Measured at the ruling: zero readers in this repository and zero in objectui — no renderer, ' + + 'route or metadata read door consulted the key — so the page has been open to every caller ' + + 'who could reach it for as long as the key existed. Deleting it therefore changes no ' + + 'behaviour and closes no hole; it makes an unkept promise stop being made. Which permission ' + + 'set corresponds to a given profile name is a judgement no walker can derive, which is why ' + + 'this is a TODO rather than a rewrite.', + acceptanceCriteria: + 'No page metadata carries `assignedProfiles` (the D2 conversion ' + + '`page-assigned-profiles-removed` strips it from authored sources on a chain replay; ' + + '`os migrate meta --stored` covers rows already at rest). For every page that carried one, ' + + 'each name in the old list resolves to a permission set held by the intended people through ' + + 'a position, and a caller OUTSIDE that audience, signed in, is refused the data the page ' + + 'reads — verified against the running deployment, not against the metadata alone. A caller ' + + 'who was previously outside an `assignedProfiles` list and could nonetheless open the page ' + + 'is the pre-existing state, not a regression introduced by the removal.', + }, { id: 'plugin-auto-restart-never-reinitialised', surface: @@ -14148,6 +14191,26 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // which is a different key on a different surface and has always rendered. D2: // `view-page-mount-removed`. 'ui/ObjectListView:pageName', + // ADR-0090 D2 (no Profile concept) + ADR-0049 enforce-or-remove; maintainer + // ruling 2026-09-12, decision batch #121 item 2, verbatim 「同意」. + // `Page.assignedProfiles` was an authorable key named for the concept ADR-0090 D2 + // deleted, and it gated nothing: measured across this repository and objectui, + // every hit was a declaration, a generated artifact, prose or a round-trip test — + // no renderer, route or metadata read door ever read it, so a page that "assigned + // profiles" stayed open to every caller who could reach it. `PageSchema` is reachable + // from the `page` metadata-type root, so the key is NOT deleted from the shape: it + // stays as a `retiredKey()` tombstone that carries the prescription, which is why the + // key keeps its authorable-surface line (marked `[RETIRED]`) and its liveness row (as + // `dead`). Authoring it is a `tsc` error and a parse error; there is no `guidance` + // entry for it, because a guidance entry only ever runs from the + // `unrecognized_keys` path and the shape still declares this key. The two alias + // entries that steered an + // authored `profiles:` / `assignedTo:` INTO this retired vocabulary became + // refusals naming the permission-set route in the same change. Page audience is + // the permission set's: the object's permission sets gate the DATA, and positions + // bind those sets to people. D2: `page-assigned-profiles-removed`; D3 semantic: + // `page-assigned-profiles-audience-to-permission-set`. + 'ui/Page:assignedProfiles', // #11027 — ADR-0049 enforce-or-remove (maintainer ruling 2026-08-22, ruled B: // retire + repair the redirect texts in the same change). The LAST carrier of // the `ResponsiveConfig` layout block, and the destination the diff --git a/packages/spec/src/ui/page.form.ts b/packages/spec/src/ui/page.form.ts index b4f6db878c..ec4b09f098 100644 --- a/packages/spec/src/ui/page.form.ts +++ b/packages/spec/src/ui/page.form.ts @@ -153,7 +153,10 @@ export const pageForm = defineForm({ fields: [ { field: 'isDefault', helpText: 'Set as default page for this page type' }, { field: 'kind', helpText: 'Page override mode: full or slotted (for record pages)' }, - { field: 'assignedProfiles', widget: 'string-tags', helpText: 'Profiles that can access this page' }, + // `assignedProfiles` input removed with the key (ADR-0090 D2 / ADR-0049): a form + // input for an unwritable key is the false-compliant UI half of a retirement, and + // its helpText shipped translated into four locale bundles. Page audience is the + // permission set's; there is nothing to author here. { field: 'aria', type: 'composite', helpText: 'Accessibility attributes (ARIA labels, roles)' }, ], }, diff --git a/packages/spec/src/ui/page.test.ts b/packages/spec/src/ui/page.test.ts index 9dd4c60086..708dfdaa4e 100644 --- a/packages/spec/src/ui/page.test.ts +++ b/packages/spec/src/ui/page.test.ts @@ -191,7 +191,6 @@ describe('PageSchema', () => { }, ], isDefault: true, - assignedProfiles: ['admin', 'sales_user'], }); expect(page.object).toBe('account'); @@ -298,15 +297,51 @@ describe('PageSchema', () => { expect(page.type).toBe('utility'); }); - it('should accept page with profile assignments', () => { - const page = PageSchema.parse({ + // [#16929] `assignedProfiles` was REMOVED (ADR-0090 D2 / ADR-0049): it was named + // for a deleted concept and no renderer, route or read door ever enforced it. + // This test used to assert the schema ACCEPTED it. The three pins below replace + // it, and they assert the ENVELOPE of the refusal — code and path — plus the + // prescription's load-bearing clause, never the whole sentence. + it('refuses `assignedProfiles` at the tombstone and prescribes the permission-set route', () => { + const result = PageSchema.safeParse({ name: 'custom_page', label: 'Custom Page', regions: [], assignedProfiles: ['admin', 'sales_manager', 'sales_rep'], }); - expect(page.assignedProfiles).toHaveLength(3); + expect(result.success).toBe(false); + // The tombstone is `z.never().optional()`, so the refusal is located AT the + // key rather than reported as an unrecognized key on the page. + const issue = result.error!.issues.find((i) => i.path[0] === 'assignedProfiles')!; + expect(issue).toBeDefined(); + expect(issue.path).toEqual(['assignedProfiles']); + expect(issue.message).toMatch(/`page\.assignedProfiles` was removed.*permission sets/s); + // The prescription names the tool sentence the house pin governs. + expect(issue.message).toContain('os migrate meta --from 17'); + }); + + it('answers `profiles:` with the permission-set route, not the retired key', () => { + const result = PageSchema.safeParse({ name: 'deal_desk', label: 'Deal Desk', profiles: ['admin'] }); + + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.code === 'unrecognized_keys')!; + expect(issue).toBeDefined(); + // The alias used to CORRECT the author into the retired vocabulary. It must + // not name it any more, in either direction. + expect(issue.message).toContain('no Profile concept'); + expect(issue.message).toContain('permission sets'); + expect(issue.message).not.toContain('assignedProfiles'); + }); + + it('answers `assignedTo:` with the same sentence as `profiles:`', () => { + const result = PageSchema.safeParse({ name: 'deal_desk', label: 'Deal Desk', assignedTo: ['admin'] }); + + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.code === 'unrecognized_keys')!; + expect(issue).toBeDefined(); + expect(issue.message).toContain('no Profile concept'); + expect(issue.message).not.toContain('assignedProfiles'); }); it('should accept page with custom template', () => { diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index 3c4942e3cb..d41a0783f9 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -665,6 +665,37 @@ export function checkPageSourceCompleteness( * - 'PageDashboard' (PascalCase) * - 'Settings Page' (spaces) */ +/** + * The `page.assignedProfiles` tombstone prescription (ADR-0090 D2, ADR-0049). + * + * Carried by a `retiredKey()` tombstone on the shape, not by a `guidance` entry: + * `PageSchema` is reachable from the `page` metadata-type root, so an author can + * still write the key and there is someone to teach. Body prose states the key's + * fate; the closing sentence states a property of `os migrate meta` and nothing + * about the key (the house sentence, pinned in + * `shared/retired-key-migrate-sentence.test.ts`). + */ +const PAGE_ASSIGNED_PROFILES_RETIRED = + '`page.assignedProfiles` was removed in @objectstack/spec 17.5.0 (ADR-0090 D2, ADR-0049 ' + + 'enforce-or-remove) — it was named for the Profile concept ADR-0090 D2 deleted, and it ' + + 'gated nothing: no renderer, route or metadata read door ever read the key, so a page that ' + + '"assigned profiles" stayed open to every caller who could reach it. Delete the key. Page ' + + "audience is the permission set's: gate the DATA the page shows with the object's permission " + + 'sets, and bind those sets to people through positions (`sys_position_permission_set`) — ' + + 'those are the checks the runtime actually runs. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.'; + +/** + * The wrong-layer pointer `profiles` / `assignedTo` now carry. Deliberately the + * same answer `security/permission.zod.ts` gives the word `profiles`: one word, + * one answer, whichever schema receives it. + */ +const PAGE_AUDIENCE_WRONG_LAYER = + '`profiles` / `assignedTo` are not page keys (ADR-0090 D2: no Profile concept). Page ' + + "audience is the permission set's — gate the DATA the page shows with the object's " + + 'permission sets, and bind those sets to people through positions ' + + '(`sys_position_permission_set`), never on the page itself.'; + export const PageSchema = lazySchema(() => strictObject({ surface: 'this page', history: PAGE_HISTORY, @@ -677,7 +708,6 @@ export const PageSchema = lazySchema(() => strictObject({ components: 'regions', children: 'regions', state: 'variables', params: 'variables', vars: 'variables', config: 'interfaceConfig', interface: 'interfaceConfig', - profiles: 'assignedProfiles', assignedTo: 'assignedProfiles', default: 'isDefault', jsx: 'source', html: 'source', code: 'source', content: 'source', dependencies: 'requires', plugins: 'requires', @@ -702,23 +732,31 @@ export const PageSchema = lazySchema(() => strictObject({ route: '`route` is not a page key — a page is routed by its `name` (lowercase snake_case). Rename the page rather than declaring a path.', path: '`path` is not a page key — a page is routed by its `name` (lowercase snake_case).', url: '`url` is not a page key — a page is routed by its `name`. To link OUT to an address, use a navigation node on the app.', - // ⛔ Neither prescription below may name `assignedProfiles` as the way to gate - // a page. The key is still authorable on this schema — nothing here changes what - // the schema accepts — but it gates NOTHING, so prescribing it handed the author - // a capability the runtime does not deliver, at parse time, which is Prime - // Directive #10's exact prohibition. Measured 2026-09-10: zero readers in this - // repo (every hit is a declaration, a generated artifact, prose, or this - // schema's own round-trip test) and zero readers in objectui at `3fbdd4a2d` - // (three hits — a docs table row, `packages/types/src/layout.ts` and - // `packages/types/src/zod/layout.zod.ts` — every one a declaration; lit controls - // `visibleWhen` 308 files and `PageSchema` 94 files prove the instrument fired). - // `liveness/page.json` still grades it `live` on the strength of an objectui - // bridge at `react/src/spec-bridge/bridges/page.ts` — a path that does not exist - // in that repo, while two sibling citations in the same ledger file resolve. - // It is also named for the concept ADR-0090 D2 removed, which - // `security/permission.zod.ts` states to authors three times over. - // ⛔ The key's own disposition (keep / rename / remove) needs a ruling and is - // tracked in #16929; this correction deliberately does not pre-empt it. + // ── The retired page-audience vocabulary (ADR-0090 D2, ADR-0049 enforce-or-remove). + // + // `assignedProfiles` was an authorable key named for the Profile concept + // ADR-0090 D2 deleted, and it gated nothing: measured across this repository + // and objectui, no renderer, route or read door ever read it, so a page that + // "assigned profiles" stayed open to everyone who could reach it — the + // declared-not-enforced shape ADR-0049 exists to close. It is REMOVED: a + // `retiredKey()` tombstone in the shape below carries the prescription, and + // the strip for existing sources and stored rows is the protocol-18 + // `page-assigned-profiles-removed` conversion. It is deliberately NOT a + // `guidance` entry here — the tombstone is the stronger channel (`tsc` as + // well as the parse), and a guidance entry for a key the shape declares is + // dead code `alias-integrity.test.ts` would flag. + // + // `profiles` and `assignedTo` were ALIASES into that vocabulary: an author + // writing `profiles:` was corrected INTO the retired word, two files away + // from `security/permission.zod.ts` answering the same word with "no Profile + // concept". They are refusals now, and they point where page audience really + // lives — the permission set. + profiles: PAGE_AUDIENCE_WRONG_LAYER, + assignedTo: PAGE_AUDIENCE_WRONG_LAYER, + // ⛔ Neither prescription below may name a page-level audience key as the way + // to gate a page: there is none. Prescribing one hands the author a capability + // the runtime does not deliver, at parse time, which is Prime Directive #10's + // exact prohibition. visibleWhen: 'page-level conditional rendering does not exist — put `visibleWhen` on the COMPONENT inside a region', permissions: 'a page is not permission-gated by a field — gate the DATA it shows with the object\'s permission sets (which is what actually protects the records)', }, @@ -768,7 +806,16 @@ export const PageSchema = lazySchema(() => strictObject({ /** Activation */ isDefault: z.boolean().default(false), - assignedProfiles: z.array(z.string()).optional(), + // `assignedProfiles` REMOVED (ADR-0090 D2 / ADR-0049) — it named the concept + // D2 deleted and nothing anywhere enforced it, so a page that "assigned + // profiles" was open to everyone who could reach it. Page audience is the + // permission set's. A `retiredKey()` tombstone rather than a bare deletion: + // `PageSchema` is still parsed from the `page` metadata-type root, so there + // IS an author to warn — `tsc` types the key `never` and a value reaching a + // parse raises the prescription instead of a bare unrecognized-key report. + // The key therefore stays in the walked shape, which is why its liveness row + // stays too (the `rls.priority` precedent). + assignedProfiles: retiredKey(PAGE_ASSIGNED_PROFILES_RETIRED), /** Interface Page Configuration (Airtable Interface parity) */ interfaceConfig: InterfacePageConfigSchema.optional()