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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .changeset/16929-page-assigned-profiles-removed.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- adr-0087: registered page-assigned-profiles-removed, page-assigned-profiles-audience-to-permission-set -->
2 changes: 1 addition & 1 deletion content/docs/references/ui/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>; 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). |
Expand Down
1 change: 0 additions & 1 deletion content/docs/ui/pages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 9 additions & 6 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
//
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -880,10 +880,6 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
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)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -880,10 +880,6 @@ export const esESMetadataForms: NonNullable<TranslationData['metadataForms']> =
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)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -880,10 +880,6 @@ export const jaJPMetadataForms: NonNullable<TranslationData['metadataForms']> =
label: "モード",
helpText: "ページ上書きモード: full または slotted(record ページ用)"
},
assignedProfiles: {
label: "割り当てプロファイル",
helpText: "このページにアクセス可能なプロファイル"
},
aria: {
label: "アクセシビリティ",
helpText: "アクセシビリティ属性(ARIA ラベル、ロール)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -880,10 +880,6 @@ export const zhCNMetadataForms: NonNullable<TranslationData['metadataForms']> =
label: "模式",
helpText: "页面覆盖模式:完整(full)或插槽式(slotted),仅用于记录页。"
},
assignedProfiles: {
label: "指定配置文件",
helpText: "此页面对哪些 Profile 可用"
},
aria: {
label: "无障碍",
helpText: "无障碍标签与角色"
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/authorable-surface/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/liveness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Loading
Loading