From 59c78cbe5e3f4d1ccdb0c80dc1b6c65fdcb93ae9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 22:46:56 +0000 Subject: [PATCH 1/2] feat(types): retire `page.assignedProfiles` on both faces, in step with the installed spec (objectui#9409) `@objectstack/spec` 17.5.0 turned `PageSchema.assignedProfiles` into a `retiredKey()` tombstone (ADR-0090 D2 deleted the Profile concept; ADR-0049 enforce-or-remove). objectui still declared it as an authorable `string[]` described as "Profiles that can access this page", which nothing enforced. - zod: drop the `string[]` override so the spec's tombstone reaches `PageNodeSchema` by reference through `SpecPageFields`. - TS: drop `assignedProfiles` from the `Omit` list and the hand-written member, so the twin takes the spec's member by reference; rewrite the forward-compat docblock as the retirement. - Pin: invert the twins-spec-by-reference pin into a refusal pin on both faces. - Docs: mark the schema-reference row retired. - Changeset (minor, breaking authoring) plus a dated note on the pending objectui#9736 changeset whose `assignedProfiles` sentences this makes false. Claude-Session: https://claude.ai/code/session_01VhxTqosz7wn54ahqyxgERT Co-authored-by: Claude --- .../9409-retire-page-assigned-profiles.md | 35 +++++++++++ .changeset/9736-twins-spec-by-reference.md | 3 + content/docs/api/schema-reference.md | 2 +- .../twins-spec-by-reference-9736.test.ts | 63 ++++++++++++------- packages/types/src/layout.ts | 31 +++++---- packages/types/src/zod/layout.zod.ts | 11 +++- 6 files changed, 104 insertions(+), 41 deletions(-) create mode 100644 .changeset/9409-retire-page-assigned-profiles.md diff --git a/.changeset/9409-retire-page-assigned-profiles.md b/.changeset/9409-retire-page-assigned-profiles.md new file mode 100644 index 0000000000..c2996f4d92 --- /dev/null +++ b/.changeset/9409-retire-page-assigned-profiles.md @@ -0,0 +1,35 @@ +--- +'@object-ui/types': minor +--- + +**BREAKING (authoring):** `assignedProfiles` on a `page` node is now refused on both published faces (objectui#9409). + +`@objectstack/spec` 17.5.0 retired `page.assignedProfiles`. ADR-0090 D2 deleted the Profile +concept the key was named after, and under ADR-0049 enforce-or-remove the spec's `PageSchema` +now declares the key as a `retiredKey()` tombstone that refuses any value. `@object-ui/types` +still declared it as an authorable `string[]`, described as "Profiles that can access this +page". Nothing in this repository ever read the key, so a page that listed profiles stayed +open to everyone who could reach it: the key read as access control and enforced nothing. + +Both faces now take the spec's tombstone by reference, the way App `version` and Dashboard +`refreshInterval` already do: + +- **Zod mirror.** `PageNodeSchema` no longer overrides the key. An authored value, an empty + list included, fails to parse at `assignedProfiles` with the spec's own message, which + names the remedy. +- **TypeScript.** `PageNodeSchema.assignedProfiles` is now the spec's member, which admits no + value, so authoring one is a compile error. The hand-written `string[]` member is gone. +- **Docs.** The `PageNodeSchema` table in the schema reference marks the key as retired. + +What to do: delete the key. Page audience comes from permission sets. Gate the data the page +shows with the object's permission sets, and grant those sets to people through positions. + +```ts +// before: compiled and parsed, and gated nothing +const page: PageNodeSchema = { type: 'page', assignedProfiles: ['sales'] }; +// after: refused by tsc and by the validator. Delete the key. +const page: PageNodeSchema = { type: 'page' }; +``` + +This is released as `minor`, following this repository's version policy: breaking semantics +are marked `minor` and described here. diff --git a/.changeset/9736-twins-spec-by-reference.md b/.changeset/9736-twins-spec-by-reference.md index e2e64d2b0a..3b0bb3020d 100644 --- a/.changeset/9736-twins-spec-by-reference.md +++ b/.changeset/9736-twins-spec-by-reference.md @@ -38,3 +38,6 @@ spelling moves from a parse-time refusal to a compile-time refusal. This repo ma semantics `minor` rather than `major`. This change affects types only. It changes no runtime code and narrows no mirror. + +⚠️ **Dated note, 2026-09-30 — `PageNodeSchema.assignedProfiles` is no longer withheld, retired in this same release — objectui#9409.** +The sentences above saying `PageNodeSchema.assignedProfiles` is withheld from the spec projection "for forward compatibility", with its hand-written `string[]` member unchanged, no longer hold. Later in this release this repository began resolving `@objectstack/spec` 17.5.0, which retires the key as a `retiredKey()` tombstone, and objectui#9409 dropped both the omission and the `string[]` member: the twin now takes the spec's tombstone by reference, like the app and dashboard tombstones listed above, so authoring a value is a TypeScript error and a parse failure. `PageNodeSchema.slots` is still withheld as described. Everything else above is unchanged. diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index 681ee67b40..eb14d9cb9c 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -138,7 +138,7 @@ Top-level page container. Defines a full page with optional regions (header, sid | `regions` | `PageRegion[]` | Named layout regions (header, sidebar, footer). | | `children` | `SchemaNode \| SchemaNode[]` | Main page content when the page declares no regions — one node, or a list of them. Spelled `body` until objectui#6771 retired that spelling. | | `isDefault` | `boolean` | Whether this is the default page for the object. | -| `assignedProfiles` | `string[]` | Security profiles that can access this page. | +| `assignedProfiles` | *retired* | ⛔ Refused by name (objectui#9409). `@objectstack/spec` 17.5.0 retired the key: ADR-0090 D2 deleted the Profile concept it was named after, and nothing ever enforced it, so a page that listed profiles stayed open to everyone who could reach it. Both published faces take the spec's tombstone: any value is a TypeScript error and a parse failure at `assignedProfiles`. Delete the key. Page audience comes from permission sets: gate the data the page shows with the object's permission sets, and grant those sets to people through positions. | | `aria` | `AriaProps` | ARIA attributes for the page's root element: `ariaLabel` (a plain string, or an inline locale map such as `{ "en": "Orders", "fr": "Commandes" }`, resolved for the display locale) renders `aria-label`, `ariaDescribedBy` renders `aria-describedby`, and `role` renders `role`. This is the spec's inline vocabulary, not the keyed flat `ariaLabel` described under BaseSchema. The page adds no default role. | **Related:** [AppSchema](/docs/core/app-schema), [DivSchema](#divschema), [GridSchema](#gridschema) diff --git a/packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts b/packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts index d522fa0059..88d56b7946 100644 --- a/packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts +++ b/packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts @@ -16,10 +16,12 @@ * `DASHBOARD_SPEC_EXCLUDED`, `PAGE_SPEC_EXCLUDED`), plus — on the TypeScript * face only — the twin member whose hand-written type is not assignable to * the spec's (`slots` on the page, ledgered as drift in - * `zod-mirror-parity.test.ts`), plus the page's `assignedProfiles`, which - * objectstack `main` retires (a forward-compat omission, objectui#9409). The - * dashboard's `header` was the second such member until objectui#7759 group A - * dropped the omission: the twin now inherits the spec's `header` by reference. + * `zod-mirror-parity.test.ts`). The dashboard's `header` was a second such + * member until objectui#7759 group A dropped the omission: the twin now + * inherits the spec's `header` by reference. The page's `assignedProfiles` + * was a third, a forward-compat omission, until objectui#9409 retired it: + * `@objectstack/spec` 17.5.0 made it a `retiredKey()` tombstone, and both faces + * now take that tombstone by reference. * * ## Why the positive pins are TYPE equalities, not assignments * @@ -115,12 +117,41 @@ describe('spec tombstones surface on the twin as a refusal — the verdict the m expect(DashboardMirror.safeParse(dashboard).success).toBe(true); }); - it('`Page` carries no tombstone on the installed pin — its admitted keys pass both faces', () => { - // ⚠️ Recorded rather than assumed: the spec's `PageSchema` has no - // `retiredKey()` member on @objectstack/spec 17.4.0, so the page twin's pin - // is the admitted half only. - const page: PageNodeSchema = { type: 'page', source: 'pages/home.tsx', requires: ['crm'] }; - expect(PageMirror.safeParse(page).success).toBe(true); + it('Page `assignedProfiles` (objectui#9409): authoring a value is a compile error AND a parse failure AT the key', () => { + // The spec's `retiredKey()` tombstone since @objectstack/spec 17.5.0 + // (ADR-0090 D2 deleted the Profile concept). Both faces take it by + // reference, so the twin's member IS the spec's, and it admits no value. + const isSpecMember: Equal = true; + const admitsNoValue: Equal = true; + // The `@ts-expect-error` below only sticks because the key is DECLARED; + // undeclared, the index signature would absorb it as `any`. + const declared: 'assignedProfiles' extends DeclaredKeys ? true : false = true; + expect([isSpecMember, admitsNoValue, declared]).toEqual([true, true, true]); + + // @ts-expect-error — `assignedProfiles` is the spec's `retiredKey()` tombstone. + const page: PageNodeSchema = { type: 'page', assignedProfiles: ['admin'] }; + const verdict = PageMirror.safeParse(page); + expect(verdict.success).toBe(false); + // The refusal is this key's, and the only issue: the rest of the document is valid. + const issues = verdict.success ? [] : verdict.error.issues; + expect(issues.map((i) => ({ code: i.code, path: i.path }))).toEqual([ + { code: 'invalid_type', path: ['assignedProfiles'] }, + ]); + expect(issues[0]?.message).toContain('assignedProfiles'); + + // A tombstone refuses ANY value, an empty list included: the key is gone, + // not narrowed. + // @ts-expect-error — the same tombstone. + const emptyList: PageNodeSchema = { type: 'page', assignedProfiles: [] }; + const emptyVerdict = PageMirror.safeParse(emptyList); + expect(emptyVerdict.success ? [] : emptyVerdict.error.issues.map((i) => i.path)).toEqual([['assignedProfiles']]); + }); + + it('the Page control: the same document without `assignedProfiles`, and the admitted spec keys, pass both faces', () => { + const bare: PageNodeSchema = { type: 'page' }; + const admitted: PageNodeSchema = { type: 'page', source: 'pages/home.tsx', requires: ['crm'] }; + expect(PageMirror.safeParse(bare).success).toBe(true); + expect(PageMirror.safeParse(admitted).success).toBe(true); }); }); @@ -138,16 +169,4 @@ describe('the twin-only omissions keep the twin\'s own member, unwidened', () => const headerIsSpec: Equal = true; expect(headerIsSpec).toBe(true); }); - - it('Page `assignedProfiles` keeps the hand-written `string[]` whatever the spec pin declares', () => { - // A FORWARD-COMPAT omission: objectstack `main` retires the key (its input - // type becomes `undefined`), and the hand-written member would then stop - // compiling in the `extends` clause. Spelling it beside the shared list keeps - // the twin compiling against both the installed pin and `main` (the - // `Spec Main Shape Gate`). Retiring it is objectui#9409's decision, ⛔ not - // this file's, so the member must stay exactly what it was. - const assignedProfiles: Equal = true; - const declared: 'assignedProfiles' extends DeclaredKeys ? true : false = true; - expect([assignedProfiles, declared]).toEqual([true, true]); - }); }); diff --git a/packages/types/src/layout.ts b/packages/types/src/layout.ts index d1eba1b5bc..1f97e51e96 100644 --- a/packages/types/src/layout.ts +++ b/packages/types/src/layout.ts @@ -1378,8 +1378,8 @@ export interface PageNodeRegion { * mirror's `specFieldsExcept` call also reads, so both faces project the same * spec surface and move together on a pin bump. * - * TWO keys are omitted from the spec projection beyond the shared list, on - * the TypeScript face only: + * ONE key is omitted from the spec projection beyond the shared list, on the + * TypeScript face only: * - `slots` — the member below types each slot as objectui's `SchemaNode` * (which admits primitives and `null`), not the spec's page-component * shape, so it is not assignable to the spec's member and cannot sit beside @@ -1388,16 +1388,17 @@ export interface PageNodeRegion { * ⛔ not changed here. The mirror keeps validating the spec's `slots` — the * omission is type-side only, so it is spelled beside the shared list, not * inside it. - * - `assignedProfiles` — a FORWARD-COMPAT omission. On the installed spec the - * spec's member is `string[]` and the one below matches it, but objectstack - * `main` has retired the key (`retiredKey()`, so its input type is - * `undefined`), and the hand-written `string[]` is not assignable to that. - * Without this omission the twin would stop compiling at the next pin bump, - * which `Spec Main Shape Gate` measures today. Retiring the member here is - * objectui#9409's decision, which is on hold until the installed spec - * refuses the key. ⛔ It is not retired, and not widened, here. Like - * `slots`, it is spelled beside the shared list, so the mirror keeps - * validating whatever the installed spec declares. + * + * `assignedProfiles` is RETIRED (objectui#9409) and is no longer omitted. + * @objectstack/spec 17.5.0 made the spec's member a `retiredKey()` tombstone + * (ADR-0090 D2 deleted the Profile concept it was named after; ADR-0049 + * enforce-or-remove), so this interface now takes it BY REFERENCE, like App + * `version` and Dashboard `refreshInterval`: its type is the spec's, which + * admits no value, and authoring one is a `tsc` error. The zod mirror refuses + * the same value at parse with the spec's own message. The hand-written + * `string[]` member, described as "Profiles that can access this page", is + * gone: nothing in this repository ever enforced it, so it read as access + * control while gating nothing. Page audience is the permission set's. * * The other members this interface writes itself (`icon`, `object`, * `template`, `variables`, `isDefault`, `aria`, `kind`) @@ -1413,7 +1414,7 @@ export interface PageNodeRegion { * `@object-ui/components` registers `PageRenderer` under, i.e. the wire key * authored metadata carries. Nothing else in the repo pins it. */ -export interface PageNodeSchema extends BaseSchema, Omit { +export interface PageNodeSchema extends BaseSchema, Omit { type: 'page'; /** * ⛔ REFUSED BY NAME — `actions` is not a member of this node and never was @@ -1590,10 +1591,6 @@ export interface PageNodeSchema extends BaseSchema, Omit Date: Wed, 30 Sep 2026 23:36:55 +0000 Subject: [PATCH 2/2] docs(schema-reference): drop the spec version literal from the retired `assignedProfiles` row (objectui#9409) `doc-version-claims.test.ts` refuses a version literal on a doc surface that nothing in this repository re-verifies. The row now says `@objectstack/spec` retired the key, with no version number; the rest of the row is unchanged. Claude-Session: https://claude.ai/code/session_01VhxTqosz7wn54ahqyxgERT Co-authored-by: Claude --- content/docs/api/schema-reference.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index eb14d9cb9c..a38140747e 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -138,7 +138,7 @@ Top-level page container. Defines a full page with optional regions (header, sid | `regions` | `PageRegion[]` | Named layout regions (header, sidebar, footer). | | `children` | `SchemaNode \| SchemaNode[]` | Main page content when the page declares no regions — one node, or a list of them. Spelled `body` until objectui#6771 retired that spelling. | | `isDefault` | `boolean` | Whether this is the default page for the object. | -| `assignedProfiles` | *retired* | ⛔ Refused by name (objectui#9409). `@objectstack/spec` 17.5.0 retired the key: ADR-0090 D2 deleted the Profile concept it was named after, and nothing ever enforced it, so a page that listed profiles stayed open to everyone who could reach it. Both published faces take the spec's tombstone: any value is a TypeScript error and a parse failure at `assignedProfiles`. Delete the key. Page audience comes from permission sets: gate the data the page shows with the object's permission sets, and grant those sets to people through positions. | +| `assignedProfiles` | *retired* | ⛔ Refused by name (objectui#9409). `@objectstack/spec` retired the key: ADR-0090 D2 deleted the Profile concept it was named after, and nothing ever enforced it, so a page that listed profiles stayed open to everyone who could reach it. Both published faces take the spec's tombstone: any value is a TypeScript error and a parse failure at `assignedProfiles`. Delete the key. Page audience comes from permission sets: gate the data the page shows with the object's permission sets, and grant those sets to people through positions. | | `aria` | `AriaProps` | ARIA attributes for the page's root element: `ariaLabel` (a plain string, or an inline locale map such as `{ "en": "Orders", "fr": "Commandes" }`, resolved for the display locale) renders `aria-label`, `ariaDescribedBy` renders `aria-describedby`, and `role` renders `role`. This is the spec's inline vocabulary, not the keyed flat `ariaLabel` described under BaseSchema. The page adds no default role. | **Related:** [AppSchema](/docs/core/app-schema), [DivSchema](#divschema), [GridSchema](#gridschema)