From dfab92f7fa39c6b7ef94553cc24c5a64c38a56e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 07:50:29 +0000 Subject: [PATCH 01/11] wip(spec): remove page.assignedProfiles, retarget aliases, correct the false records Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH --- content/docs/ui/pages.mdx | 1 - packages/metadata-protocol/src/protocol.ts | 15 ++-- packages/spec/liveness/page.json | 4 - packages/spec/src/api/protocol.zod.ts | 10 ++- packages/spec/src/conversions/registry.ts | 54 ++++++++++++++ .../18.ui__Page__assignedProfiles.ts | 17 +++++ ...ned-profiles-audience-to-permission-set.ts | 33 +++++++++ packages/spec/src/migrations/registry.ts | 15 +++- packages/spec/src/ui/page.form.ts | 5 +- packages/spec/src/ui/page.test.ts | 41 +++++++++- packages/spec/src/ui/page.zod.ts | 74 ++++++++++++++----- 11 files changed, 230 insertions(+), 39 deletions(-) create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__Page__assignedProfiles.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.page-assigned-profiles-audience-to-permission-set.ts 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 2c62711d60..b0b7e238ab 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -11943,12 +11943,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/spec/liveness/page.json b/packages/spec/liveness/page.json index ac07f5d10e..e7accbfa37 100644 --- a/packages/spec/liveness/page.json +++ b/packages/spec/liveness/page.json @@ -56,10 +56,6 @@ "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/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 1c779b0a87..56f4e4a8f0 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -9086,6 +9086,59 @@ const viewPageMountRemoved: 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], @@ -9182,6 +9235,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` (`os migrate meta --from 17` lists the strips; ' + + '`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 61f2b17819..d05e97583a 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5436,7 +5436,19 @@ const step18: MigrationStep = { 'both keys rather than rewriting `type` to `\'grid\'`: `type` defaults to `grid` in the ' + 'schema, so deleting it lands the row on exactly what it already rendered without this ' + 'registry guessing a view type. The surviving page mount is the app navigation item ' + - '(`PageNavItem.pageName`), untouched.', + '(`PageNavItem.pageName`), untouched. ' + + '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. `PageSchema` is a strictObject, so ' + + 'the key leaves the shape and its prescription lives in that schema\'s guidance table; 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', @@ -5462,6 +5474,7 @@ const step18: MigrationStep = { 'memory-persistence-auto-save-interval-to-ms', 'turso-config-timeout-to-timeout-ms', 'view-page-mount-removed', + 'page-assigned-profiles-removed', ], semantic: [ // One file per entry under `entries/semantic/`, concatenated here sorted by 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..4943c33a12 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,49 @@ 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` 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); + const issue = result.error!.issues[0]!; + expect(issue.code).toBe('unrecognized_keys'); + expect(issue.path).toEqual([]); + 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: 'p', label: 'P', profiles: ['admin'] }); + + expect(result.success).toBe(false); + const issue = result.error!.issues[0]!; + expect(issue.code).toBe('unrecognized_keys'); + // 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: 'p', label: 'P', assignedTo: ['admin'] }); + + expect(result.success).toBe(false); + const issue = result.error!.issues[0]!; + expect(issue.code).toBe('unrecognized_keys'); + 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 680ad6cc2a..0b080d04a7 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -659,6 +659,36 @@ export function checkPageSourceCompleteness( * - 'PageDashboard' (PascalCase) * - 'Settings Page' (spaces) */ +/** + * The `page.assignedProfiles` tombstone prescription (ADR-0090 D2, ADR-0049). + * + * The key is gone from the shape, so it arrives here through the + * `unrecognized_keys` path like any other undeclared key — `guidance` is what + * turns that report into the upgrade. 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 18 (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, @@ -671,7 +701,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', @@ -696,23 +725,28 @@ 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. Removed here; the + // strip for existing sources and stored rows is the protocol-18 + // `page-assigned-profiles-removed` conversion. + // + // `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. + assignedProfiles: PAGE_ASSIGNED_PROFILES_RETIRED, + 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)', }, @@ -762,7 +796,9 @@ export const PageSchema = lazySchema(() => strictObject({ /** Activation */ isDefault: z.boolean().default(false), - assignedProfiles: z.array(z.string()).optional(), + // `assignedProfiles` removed (ADR-0090 D2 / ADR-0049) — see the guidance + // table above: it named a deleted concept and no reader ever enforced it. + // Page audience is the permission set's; the refusal carries the route. /** Interface Page Configuration (Airtable Interface parity) */ interfaceConfig: InterfacePageConfigSchema.optional() From ad02b0da24d6259ea714324dba070f068d4f4171 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 07:54:15 +0000 Subject: [PATCH 02/11] wip(spec): switch page.assignedProfiles to the retiredKey tombstone route Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH --- .../16929-page-assigned-profiles-removed.md | 36 +++++++++++++++ packages/spec/liveness/page.json | 5 +++ packages/spec/src/migrations/registry.ts | 44 +++++++++++++++++++ packages/spec/src/ui/page.test.ts | 10 +++-- packages/spec/src/ui/page.zod.ts | 35 ++++++++++----- 5 files changed, 114 insertions(+), 16 deletions(-) create mode 100644 .changeset/16929-page-assigned-profiles-removed.md diff --git a/.changeset/16929-page-assigned-profiles-removed.md b/.changeset/16929-page-assigned-profiles-removed.md new file mode 100644 index 0000000000..aa3891b95a --- /dev/null +++ b/.changeset/16929-page-assigned-profiles-removed.md @@ -0,0 +1,36 @@ +--- +'@objectstack/spec': major +--- + +**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 is deleted: a strict deletion takes the key out of the walked shape, so a surviving row would be an orphan). `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/packages/spec/liveness/page.json b/packages/spec/liveness/page.json index e7accbfa37..b180f98f01 100644 --- a/packages/spec/liveness/page.json +++ b/packages/spec/liveness/page.json @@ -52,6 +52,11 @@ "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." diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index d05e97583a..1ed75775cb 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -9027,6 +9027,35 @@ 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` (`os migrate meta --from 17` lists the strips; ' + + '`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: @@ -13480,6 +13509,21 @@ 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 a + // `strictObject`, so the key is deleted from the shape and the prescription lives + // in that schema's `guidance` table; 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.test.ts b/packages/spec/src/ui/page.test.ts index 4943c33a12..f6cb2f0f84 100644 --- a/packages/spec/src/ui/page.test.ts +++ b/packages/spec/src/ui/page.test.ts @@ -302,7 +302,7 @@ describe('PageSchema', () => { // 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` and prescribes the permission-set route', () => { + it('refuses `assignedProfiles` at the tombstone and prescribes the permission-set route', () => { const result = PageSchema.safeParse({ name: 'custom_page', label: 'Custom Page', @@ -311,9 +311,11 @@ describe('PageSchema', () => { }); expect(result.success).toBe(false); - const issue = result.error!.issues[0]!; - expect(issue.code).toBe('unrecognized_keys'); - expect(issue.path).toEqual([]); + // 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'); diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index 0b080d04a7..388c9ee934 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -662,11 +662,12 @@ export function checkPageSourceCompleteness( /** * The `page.assignedProfiles` tombstone prescription (ADR-0090 D2, ADR-0049). * - * The key is gone from the shape, so it arrives here through the - * `unrecognized_keys` path like any other undeclared key — `guidance` is what - * turns that report into the upgrade. 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`). + * 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 18 (ADR-0090 D2, ADR-0049 ' @@ -731,16 +732,19 @@ export const PageSchema = lazySchema(() => strictObject({ // 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. Removed here; the - // strip for existing sources and stored rows is the protocol-18 - // `page-assigned-profiles-removed` conversion. + // 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. - assignedProfiles: PAGE_ASSIGNED_PROFILES_RETIRED, profiles: PAGE_AUDIENCE_WRONG_LAYER, assignedTo: PAGE_AUDIENCE_WRONG_LAYER, // ⛔ Neither prescription below may name a page-level audience key as the way @@ -796,9 +800,16 @@ export const PageSchema = lazySchema(() => strictObject({ /** Activation */ isDefault: z.boolean().default(false), - // `assignedProfiles` removed (ADR-0090 D2 / ADR-0049) — see the guidance - // table above: it named a deleted concept and no reader ever enforced it. - // Page audience is the permission set's; the refusal carries the route. + // `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() From 41f9c40786a55b86c09ecf70d8ea175ecaa09e12 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 08:15:39 +0000 Subject: [PATCH 03/11] chore(spec): regenerate the surface, docs, liveness counts and the four locale bundles Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH --- content/docs/references/ui/page.mdx | 2 +- .../src/apps/translations/en.metadata-forms.generated.ts | 4 ---- .../src/apps/translations/es-ES.metadata-forms.generated.ts | 4 ---- .../src/apps/translations/ja-JP.metadata-forms.generated.ts | 4 ---- .../src/apps/translations/zh-CN.metadata-forms.generated.ts | 4 ---- packages/spec/authorable-surface/ui.json | 2 +- packages/spec/liveness/README.md | 2 +- packages/spec/liveness/state-counts.md | 4 ++-- 8 files changed, 5 insertions(+), 21 deletions(-) diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index a1ce95db0b..dac920d1f1 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 18 (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/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 375063a67f..18323535fc 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -892,7 +892,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 96a3db1ef8..5c644fd1b7 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -873,7 +873,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) | diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index 06956d2da1..26916020b3 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` | 77 | 0 | 0 | 11 | 0 | 88 | | `report` | 21 | 0 | 0 | 0 | 0 | 21 | | `dashboard` | 34 | 0 | 0 | 8 | 0 | 42 | @@ -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** | **850** | **5** | **1** | **93** | **10** | **959** | +| **total** | **849** | **5** | **1** | **94** | **10** | **959** | From 3a1be112ffd05a78377fbd01db3df6c97a518785 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 08:25:07 +0000 Subject: [PATCH 04/11] test(spec): select the unrecognized-key issue rather than issues[0] Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH --- packages/spec/src/ui/page.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/spec/src/ui/page.test.ts b/packages/spec/src/ui/page.test.ts index f6cb2f0f84..708dfdaa4e 100644 --- a/packages/spec/src/ui/page.test.ts +++ b/packages/spec/src/ui/page.test.ts @@ -322,11 +322,11 @@ describe('PageSchema', () => { }); it('answers `profiles:` with the permission-set route, not the retired key', () => { - const result = PageSchema.safeParse({ name: 'p', label: 'P', profiles: ['admin'] }); + const result = PageSchema.safeParse({ name: 'deal_desk', label: 'Deal Desk', profiles: ['admin'] }); expect(result.success).toBe(false); - const issue = result.error!.issues[0]!; - expect(issue.code).toBe('unrecognized_keys'); + 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'); @@ -335,11 +335,11 @@ describe('PageSchema', () => { }); it('answers `assignedTo:` with the same sentence as `profiles:`', () => { - const result = PageSchema.safeParse({ name: 'p', label: 'P', assignedTo: ['admin'] }); + const result = PageSchema.safeParse({ name: 'deal_desk', label: 'Deal Desk', assignedTo: ['admin'] }); expect(result.success).toBe(false); - const issue = result.error!.issues[0]!; - expect(issue.code).toBe('unrecognized_keys'); + 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'); }); From 626ca348337be009e933d8ce9badb16680d3f005 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 09:32:57 +0000 Subject: [PATCH 05/11] chore(spec): regenerate after the origin/main merge Discharges the os-regen deferral the merge commit recorded. Restores the `ui/ObjectKanbanProps:quickAdd [RETIRED]` baseline marker the textual merge dropped, and fixes the rationale concatenation where both sides appended a paragraph to step18. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH --- packages/spec/authorable-surface/ui.json | 2 +- packages/spec/src/migrations/registry.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 18323535fc..8665a210b4 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -780,7 +780,7 @@ "ui/ObjectKanbanProps:grouping", "ui/ObjectKanbanProps:limit", "ui/ObjectKanbanProps:objectName", - "ui/ObjectKanbanProps:quickAdd", + "ui/ObjectKanbanProps:quickAdd [RETIRED]", "ui/ObjectKanbanProps:swimlaneField", "ui/ObjectKanbanProps:titleField", "ui/ObjectListView:addRecord", diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index e3cf54f347..8106528528 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5448,7 +5448,7 @@ const step18: MigrationStep = { 'was wrong. A retiredKey tombstone on `ObjectKanbanPropsSchema` with one D2 conversion that ' + 'is a pure lossless DELETE (the key never had an effect to preserve) scoped by component ' + '`type`: `quickAdd` stays LIVE on the `kanban-ui` block, where a React host supplies the ' + - 'runtime slot, and the ruling keeps it there deliberately.', + 'runtime slot, and the ruling keeps it there deliberately. ' + '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 ' + From f361aa611f9bfcb1f25e6c984a8fce2ac82f0717 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 10:57:55 +0000 Subject: [PATCH 06/11] chore(changeset): grade the page.assignedProfiles retirement `minor` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The director seat ruled option B (decision batch 125 item 1, maintainer verbatim 「同意」): the retirement ships under the launch-window convention, and 18.0.0 is cut as a planned act rather than as a side effect of one p2 retirement. Execution clause, quoted: > PR 17835: changeset level `minor`, the `**BREAKING**` banner and the > ADR-0087 entry stay as written; the guard that held the PR is > satisfied by the level; the spec seat finishes review and merges. So this is one token on one line. The `**BREAKING**` banner and the `adr-0087: registered` disposition marker are deliberately untouched: during the launch window the bump level is not the carrier of breaking-ness, those two are, and `check-adr-0087-registration.mjs` still judges this changeset on the banner alone (its verdict line moves from `[major+BREAKING]` to `[BREAKING]`, not to "nothing to look at"). Claude-Session: https://claude.ai/code/session_01EfsizFDgAcEjpwv4oM3WGT Co-authored-by: Claude --- .changeset/16929-page-assigned-profiles-removed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/16929-page-assigned-profiles-removed.md b/.changeset/16929-page-assigned-profiles-removed.md index aa3891b95a..22006334c2 100644 --- a/.changeset/16929-page-assigned-profiles-removed.md +++ b/.changeset/16929-page-assigned-profiles-removed.md @@ -1,5 +1,5 @@ --- -'@objectstack/spec': major +'@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. From c3aa1f6fce2c753dd8360040d4e0e4a752ee18de Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 12:19:57 +0000 Subject: [PATCH 07/11] chore(spec): regenerate after the origin/main merge `os-regen-merge.sh` step 4. The merge driver exited 0 while dropping main's side of two generated artifacts; regenerating from the merged tree restores them: authorable-surface/ui.json + ui/Action:execution + ui/CalendarConfig:allDayField liveness/state-counts.md counts re-derived (total 959 -> 973) This branch's own deliverable is unaffected: `ui/Page:assignedProfiles [RETIRED]` is still present, and the `page` row still reads 22 live / 1 dead / 24 total. Claude-Session: https://claude.ai/code/session_01EfsizFDgAcEjpwv4oM3WGT Co-authored-by: Claude --- packages/spec/authorable-surface/ui.json | 2 ++ packages/spec/liveness/state-counts.md | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 8665a210b4..c621dc628a 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -25,6 +25,7 @@ "ui/Action:disabled", "ui/Action:errorMessage", "ui/Action:execute [RETIRED]", + "ui/Action:execution", "ui/Action:icon", "ui/Action:label", "ui/Action:locations", @@ -167,6 +168,7 @@ "ui/BulkActionParam:placeholder", "ui/BulkActionParam:required", "ui/BulkActionParam:type", + "ui/CalendarConfig:allDayField", "ui/CalendarConfig:colorField", "ui/CalendarConfig:endDateField", "ui/CalendarConfig:startDateField", diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index 26916020b3..df897158ca 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -30,7 +30,7 @@ for both corollaries. | `object` | 51 | 0 | 0 | 0 | 1 | 52 | | `field` | 90 | 0 | 0 | 1 | 2 | 93 | | `flow` | 34 | 0 | 0 | 6 | 0 | 40 | -| `action` | 43 | 0 | 0 | 3 | 2 | 48 | +| `action` | 44 | 0 | 0 | 3 | 2 | 49 | | `hook` | 19 | 0 | 0 | 3 | 0 | 22 | | `permission` | 36 | 0 | 0 | 6 | 0 | 42 | | `position` | 12 | 0 | 0 | 0 | 0 | 12 | @@ -41,7 +41,7 @@ for both corollaries. | `page` | 22 | 0 | 0 | 1 | 1 | 24 | | `view` | 77 | 0 | 0 | 11 | 0 | 88 | | `report` | 21 | 0 | 0 | 0 | 0 | 21 | -| `dashboard` | 34 | 0 | 0 | 8 | 0 | 42 | +| `dashboard` | 45 | 0 | 0 | 10 | 0 | 55 | | `webhook` | 19 | 0 | 0 | 0 | 0 | 19 | | `query` | 16 | 0 | 0 | 5 | 0 | 21 | | `datasource` | 30 | 0 | 0 | 0 | 0 | 30 | @@ -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** | **849** | **5** | **1** | **94** | **10** | **959** | +| **total** | **861** | **5** | **1** | **96** | **10** | **973** | From 89421dc4026eb2281c4f57ab6c0ca07d84a7216e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 12:22:27 +0000 Subject: [PATCH 08/11] fix(spec): correct the tombstone version and two false mechanism records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contract review FAIL, three grounds. This commit closes F2 and F3 and the in-file half of F1; the merge commits before it close F1's other half. F1 — `retired-key-migrate-sentence.test.ts` was red on this branch. The semantic entry's `acceptanceCriteria` spelled the pin's marker, `os migrate meta --from 17`, in a backticked code span, and the pin then requires the house sentence anchored at that marker and final in its literal. It is an acceptance criterion, not a tombstone prescription, so the sentence cannot be literal-final there. Dropped the marker instead and named the conversion that does the work. Every other semantic entry in the tree avoids the marker the same way (measured: this was the only `--from ` occurrence under `entries/semantic/`); the sibling `--stored` spelling it keeps does not match the marker. F2 — the tombstone named a version that will not exist. The string names the npm package and `shared/retired-key.ts` defines the field as the version that removed the key; under the launch-window `minor` route this ships as 17.5.0, not 18. The `18` tracked `toMajor`, which is chain bookkeeping, so the sentence conflated two different facts. The sibling that took the same route, `view.pageName`, spells `17.5.0`. The three pre-existing `spec 17` tombstones in this file are NOT touched. F3 — two shipped records described the route this PR did not take. The retired-keys entry said the key is deleted from the shape with the prescription in the `guidance` table; it is a `retiredKey()` tombstone and there is no guidance entry for it. The changeset said the liveness row is deleted three paragraphs after correctly saying it stays as `dead`; the row exists and reads `dead`. Both now describe what is built. Both ship, which is why they were FAIL grounds. `migrations/registry.ts` is regenerated from the corrected entry, not hand-edited. Claude-Session: https://claude.ai/code/session_01EfsizFDgAcEjpwv4oM3WGT Co-authored-by: Claude --- .changeset/16929-page-assigned-profiles-removed.md | 2 +- .../retired-keys/18.ui__Page__assignedProfiles.ts | 11 ++++++++--- ...assigned-profiles-audience-to-permission-set.ts | 3 ++- packages/spec/src/migrations/registry.ts | 14 ++++++++++---- packages/spec/src/ui/page.zod.ts | 2 +- 5 files changed, 22 insertions(+), 10 deletions(-) diff --git a/.changeset/16929-page-assigned-profiles-removed.md b/.changeset/16929-page-assigned-profiles-removed.md index 22006334c2..90876d8ba7 100644 --- a/.changeset/16929-page-assigned-profiles-removed.md +++ b/.changeset/16929-page-assigned-profiles-removed.md @@ -25,7 +25,7 @@ It also enforced nothing. Measured across this repository and objectui at the ru - **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 is deleted: a strict deletion takes the key out of the walked shape, so a surviving row would be an orphan). `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. +- **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 diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__Page__assignedProfiles.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__Page__assignedProfiles.ts index fe50a6ac01..a6a84b5503 100644 --- a/packages/spec/src/migrations/entries/retired-keys/18.ui__Page__assignedProfiles.ts +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__Page__assignedProfiles.ts @@ -6,9 +6,14 @@ // 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 a -// `strictObject`, so the key is deleted from the shape and the prescription lives -// in that schema's `guidance` table; the two alias entries that steered an +// 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 diff --git a/packages/spec/src/migrations/entries/semantic/18.page-assigned-profiles-audience-to-permission-set.ts b/packages/spec/src/migrations/entries/semantic/18.page-assigned-profiles-audience-to-permission-set.ts index 2cfacd2992..48fdcd6dfb 100644 --- a/packages/spec/src/migrations/entries/semantic/18.page-assigned-profiles-audience-to-permission-set.ts +++ b/packages/spec/src/migrations/entries/semantic/18.page-assigned-profiles-audience-to-permission-set.ts @@ -23,7 +23,8 @@ export const entry: SemanticMigration = { + '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` (`os migrate meta --from 17` lists the strips; ' + '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 ' diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 0816417fd4..63cd45e6b8 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -9386,7 +9386,8 @@ const step18: MigrationStep = { + '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` (`os migrate meta --from 17` lists the strips; ' + '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 ' @@ -14084,9 +14085,14 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // 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 a - // `strictObject`, so the key is deleted from the shape and the prescription lives - // in that schema's `guidance` table; the two alias entries that steered an + // 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 diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index e61971de04..d41a0783f9 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -676,7 +676,7 @@ export function checkPageSourceCompleteness( * `shared/retired-key-migrate-sentence.test.ts`). */ const PAGE_ASSIGNED_PROFILES_RETIRED = - '`page.assignedProfiles` was removed in @objectstack/spec 18 (ADR-0090 D2, ADR-0049 ' + '`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 ' From 0ad96a8fc7a840325ac728e2171412d4e5c392f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 12:31:15 +0000 Subject: [PATCH 09/11] chore(spec): regenerate the page reference for the corrected tombstone `content/docs/references/ui/page.mdx` projects the tombstone prescription verbatim, so the F2 version correction moves one table row. Generated, not hand-edited: `pnpm --filter @objectstack/spec gen:docs` after a full build, and `check:generated` named this as the one stale artifact of 15. Claude-Session: https://claude.ai/code/session_01EfsizFDgAcEjpwv4oM3WGT Co-authored-by: Claude --- content/docs/references/ui/page.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index dac920d1f1..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** | `never` | optional | [REMOVED] `page.assignedProfiles` was removed in @objectstack/spec 18 (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. | +| **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). | From 158ad6ec87975d1192d8e19bce0b837d88d8b390 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 13:06:15 +0000 Subject: [PATCH 10/11] chore(spec): regenerate after the second origin/main merge Discharges the merge commit's os-regen deferral. `migrations/registry.ts` conflicted textually because both sides appended an entry; it is GENERATED from `migrations/entries/`, so it was resolved by taking main's side in the merge and re-deriving it here from the union of both sides' entry files. Both are present afterwards: this branch's `ui/Page:assignedProfiles` (carrying the corrected tombstone prose) and main's four `system/LoggingConfig` / `HttpDestinationConfig` duration-key entries. `conversions/registry.ts` is hand-written and was resolved by hand, both intents kept, main's `listViewSortStringClauseToArray` first and this branch's `pageAssignedProfilesRemoved` after it, in the const block and in the protocol-18 array alike. Nothing of either side was dropped, renamed or reordered. `liveness/state-counts.md` re-derived on the merged tree. Claude-Session: https://claude.ai/code/session_01EfsizFDgAcEjpwv4oM3WGT Co-authored-by: Claude --- packages/spec/liveness/state-counts.md | 4 +- packages/spec/src/migrations/registry.ts | 50 ++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index df897158ca..20850147af 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -39,7 +39,7 @@ for both corollaries. | `skill` | 16 | 0 | 0 | 1 | 0 | 17 | | `dataset` | 27 | 0 | 0 | 0 | 0 | 27 | | `page` | 22 | 0 | 0 | 1 | 1 | 24 | -| `view` | 77 | 0 | 0 | 11 | 0 | 88 | +| `view` | 78 | 0 | 0 | 11 | 0 | 89 | | `report` | 21 | 0 | 0 | 0 | 0 | 21 | | `dashboard` | 45 | 0 | 0 | 10 | 0 | 55 | | `webhook` | 19 | 0 | 0 | 0 | 0 | 19 | @@ -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** | **861** | **5** | **1** | **96** | **10** | **973** | +| **total** | **862** | **5** | **1** | **96** | **10** | **974** | diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 6b5d3af334..f9319c4c74 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -9420,6 +9420,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 +14178,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 From 4d55c069c409df8ee29ed9aebdf2efaed2ced6f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 13:45:32 +0000 Subject: [PATCH 11/11] fix(spec): restore step18's two hand-edited lines the merge resolution dropped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `migrations/registry.ts` is generated only BETWEEN its `os-generated` markers; `step18.conversionIds` and `step18.rationale` are hand-authored append regions outside them. Resolving the merge conflict by taking main's side and regenerating therefore restored every entries-derived row and silently dropped both of this branch's hand-edits, which no generator reproduces. The consequence was not cosmetic: without `'page-assigned-profiles-removed'` in `step18.conversionIds` the 17 -> 18 hop stops applying the conversion at all, so a replayed page keeps `assignedProfiles`. `migrations.test.ts`'s chain-replay composability gate caught it — "expected { pages: [ {...3}, {...3} ] } to deeply equal { pages: [ {...2}, {...3} ] }", the key still present after the chain. That gate is the reason a clean merge is not a working merge. Both intents are kept, main's first: the conversion id list carries `list-view-sort-string-clause-to-array` then `page-assigned-profiles-removed`, and the rationale carries main's list-view sort paragraph then this branch's `page.assignedProfiles` one. Re-running `gen:migration-registry` afterwards is byte-identical, which is the proof these lines sit outside the generated regions. Claude-Session: https://claude.ai/code/session_01EfsizFDgAcEjpwv4oM3WGT Co-authored-by: Claude --- packages/spec/src/migrations/registry.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index f9319c4c74..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