From 89fed2cfb0f1cd0d51cd05ec57e6a593d3a4662e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 12:41:29 +0000 Subject: [PATCH 01/11] feat(spec): app-scoped record pages (PageSchema.apps) refused per app at the three default-record-page doors Claude-Session: https://claude.ai/code/session_01ADHkmSGMLCTCPxZfV3GqHr Co-authored-by: Claude --- .../src/protocol-save-door-refusals.ts | 127 +++++++++++------- packages/objectql/src/default-record-page.ts | 64 ++++++++- packages/objectql/src/registry.ts | 109 ++++++++++----- packages/spec/src/stack.zod.ts | 59 +++++--- packages/spec/src/ui/default-record-page.ts | 75 ++++++++--- packages/spec/src/ui/page.zod.ts | 64 ++++++++- 6 files changed, 368 insertions(+), 130 deletions(-) diff --git a/packages/metadata-protocol/src/protocol-save-door-refusals.ts b/packages/metadata-protocol/src/protocol-save-door-refusals.ts index d3afe993a50..b4936140f2c 100644 --- a/packages/metadata-protocol/src/protocol-save-door-refusals.ts +++ b/packages/metadata-protocol/src/protocol-save-door-refusals.ts @@ -204,13 +204,21 @@ export async function defaultRecordPageSaveRefusal( verb: 'saved' | 'published' | 'restored' = 'saved', ): Promise<(Error & { code: 'DEFAULT_RECORD_PAGE_CONFLICT'; status: 422 }) | undefined> { if (type !== 'page') return undefined; + // Every slot the page holds: its object's default (no app), and the record + // page of its object inside each app its `apps` names (ruling A on + // objectstack#28979) — each judged by the one reading, `defaultRecordPageObject`. + const slots: Array<{ object: string; app?: string }> = []; const object = defaultRecordPageObject(item); - if (object === undefined) return undefined; + if (object !== undefined) slots.push({ object }); + const apps = (item as { apps?: unknown } | null | undefined)?.apps; + if (Array.isArray(apps)) { + for (const app of new Set(apps.filter((a): a is string => typeof a === 'string'))) { + const scoped = defaultRecordPageObject(item, app); + if (scoped !== undefined) slots.push({ object: scoped, app }); + } + } + if (slots.length === 0) return undefined; - // `shipped`: a page a package ships through the registry, which no save - // can edit; a stored row (bound to a package or not) is editable here. - type Holder = { page: string; packageId?: string; shipped: boolean }; - const holders: Holder[] = []; let records: any[] = []; try { // [ADR-0131 D6] A save lands environment-wide, so the pages it meets @@ -221,52 +229,75 @@ export async function defaultRecordPageSaveRefusal( } const stored = this.storedOverlayEntries({ type }, records); const storedNames = new Set(stored.map((entry) => entry.name)); - for (const entry of stored) { - if (entry.name === saveName || defaultRecordPageObject(entry.data) !== object) continue; - holders.push(entry.packageId - ? { page: entry.name, packageId: entry.packageId, shipped: false } - : { page: entry.name, shipped: false }); - } const registry = this.engine.registry; - if (typeof registry?.defaultRecordPageHoldersOf === 'function') { - const registered = registry.defaultRecordPageHoldersOf(object) as Array< - { kind: 'package'; packageId: string; page: string } | { kind: 'environment'; page: string } - >; - for (const holder of registered) { - if (holder.page === saveName || storedNames.has(holder.page)) continue; - holders.push(holder.kind === 'package' - ? { page: holder.page, packageId: holder.packageId, shipped: true } - : { page: holder.page, shipped: false }); + + // `shipped`: a page a package ships through the registry, which no save + // can edit; a stored row (bound to a package or not) is editable here. + type Holder = { page: string; packageId?: string; shipped: boolean }; + for (const { object, app } of slots) { + const holders: Holder[] = []; + for (const entry of stored) { + if (entry.name === saveName || defaultRecordPageObject(entry.data, app) !== object) continue; + holders.push(entry.packageId + ? { page: entry.name, packageId: entry.packageId, shipped: false } + : { page: entry.name, shipped: false }); } + if (typeof registry?.defaultRecordPageHoldersOf === 'function') { + const registered = (app === undefined + ? registry.defaultRecordPageHoldersOf(object) + : registry.defaultRecordPageHoldersOf(object, app)) as Array< + { kind: 'package'; packageId: string; page: string } | { kind: 'environment'; page: string } + >; + for (const holder of registered) { + if (holder.page === saveName || storedNames.has(holder.page)) continue; + holders.push(holder.kind === 'package' + ? { page: holder.page, packageId: holder.packageId, shipped: true } + : { page: holder.page, shipped: false }); + } + } + if (holders.length === 0) continue; + + const seen = new Set(); + const unique = holders.filter((h) => { + const key = `${h.packageId ?? ''}|${h.page}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + const describe = (h: Holder) => + h.packageId ? `page '${h.page}' of package '${h.packageId}'` : `page '${h.page}', authored in this system`; + const named = unique.map(describe); + const others = named.length === 1 ? `${named[0]} already is` : `${named.join(' and ')} already are`; + const message = app === undefined + ? `Default record page conflict: page '${saveName}' cannot be ${verb} as the default record page of object ` + + `'${object}': ${others}. ` + + (unique.some((h) => h.shipped) + ? `Save '${saveName}' with \`isDefault: false\`: a page a package ships stays the default unless the ` + + `deployment is composed without that package's page.` + : `Save '${saveName}' with \`isDefault: false\`, or first save ${unique.map((h) => `'${h.page}'`).join(' and ')} ` + + `with \`isDefault: false\` to make '${saveName}' the default instead.`) + + ` At most one \`isDefault: true\` record page may exist for an object ` + + `in a deployment: the record view opens that page with no tie-break.` + : `Default record page conflict: page '${saveName}' cannot be ${verb} as the record page of object ` + + `'${object}' in app '${app}': ${others}. ` + + (unique.some((h) => h.shipped) + ? `Save '${saveName}' without '${app}' in \`apps\`: a page a package ships stays that app's page unless the ` + + `deployment is composed without that package's page.` + : `Save '${saveName}' without '${app}' in \`apps\`, or first save ${unique.map((h) => `'${h.page}'`).join(' and ')} ` + + `without it to make '${saveName}' that app's page instead.`) + + ` At most one record page may name an app in \`apps\` for an object: that app's record view ` + + `opens it with no tie-break.`; + const err = new Error(message) as Error & { + code: 'DEFAULT_RECORD_PAGE_CONFLICT'; status: 422; object: string; app?: string; holders: readonly Holder[]; + }; + err.code = 'DEFAULT_RECORD_PAGE_CONFLICT'; + err.status = 422; + err.object = object; + if (app !== undefined) err.app = app; + err.holders = unique; + return err; } - if (holders.length === 0) return undefined; - - const seen = new Set(); - const unique = holders.filter((h) => { - const key = `${h.packageId ?? ''}|${h.page}`; - if (seen.has(key)) return false; - seen.add(key); - return true; - }); - const describe = (h: Holder) => - h.packageId ? `page '${h.page}' of package '${h.packageId}'` : `page '${h.page}', authored in this system`; - const named = unique.map(describe); - const others = named.length === 1 ? `${named[0]} already is` : `${named.join(' and ')} already are`; - const remedy = unique.some((h) => h.shipped) - ? `Save '${saveName}' with \`isDefault: false\`: a page a package ships stays the default unless the ` - + `deployment is composed without that package's page.` - : `Save '${saveName}' with \`isDefault: false\`, or first save ${unique.map((h) => `'${h.page}'`).join(' and ')} ` - + `with \`isDefault: false\` to make '${saveName}' the default instead.`; - const err = new Error( - `Default record page conflict: page '${saveName}' cannot be ${verb} as the default record page of object ` - + `'${object}': ${others}. ${remedy} At most one \`isDefault: true\` record page may exist for an object ` - + `in a deployment: the record view opens that page with no tie-break.`, - ) as Error & { code: 'DEFAULT_RECORD_PAGE_CONFLICT'; status: 422; object: string; holders: readonly Holder[] }; - err.code = 'DEFAULT_RECORD_PAGE_CONFLICT'; - err.status = 422; - err.object = object; - err.holders = unique; - return err; + return undefined; } /** diff --git a/packages/objectql/src/default-record-page.ts b/packages/objectql/src/default-record-page.ts index 508f9a49e1e..658e389b8db 100644 --- a/packages/objectql/src/default-record-page.ts +++ b/packages/objectql/src/default-record-page.ts @@ -68,7 +68,18 @@ * unrefused when the deployment restarts. A hot install of that package is * refused (the stored page is already in the registry then). * - ⛔ A downgrade. `OS_METADATA_COLLISION=warn` softens the ADR-0048 Phase 1 - * namespace gate only. + * namespace gate only. * + * ## The app-scoped record page + * + * A record page may name the apps whose routes open it for its object + * (`PageSchema.apps`; maintainer ruling A on objectstack#28979, record + * 6573557633). The rule above holds one level down, per app: at most one + * record page per object per app, refused here by the same holders, claims + * and overlay rules, keyed by the SLOT ({@link RecordPageSlot}) instead of by + * the object alone. The deployment's default is the slot with no app; it and + * an app's slot never meet. "The record page of object X inside app A" is + * `@objectstack/spec/ui`'s `defaultRecordPageObject(page, A)`, the reading + * `defineStack` and the metadata save door ask too. */ import { defaultRecordPageObject, misplacedDefaultPageRefusal } from '@objectstack/spec/ui'; @@ -78,10 +89,48 @@ export type DefaultRecordPageHolder = | { readonly kind: 'package'; readonly packageId: string; readonly page: string } | { readonly kind: 'environment'; readonly page: string }; -/** One default record page a manifest declares. */ -export interface DeclaredDefaultRecordPage { - /** The object the page is the default record page of. */ +/** + * One record-page slot: an object's deployment default (`app` absent), or the + * record page an app's routes open for an object (`app` set). + */ +export interface RecordPageSlot { + /** The object whose record view the slot chooses the page of. */ readonly object: string; + /** The app the slot is scoped to; absent for the deployment's default. */ + readonly app?: string; +} + +/** The registry's key for a slot — the object alone for the default, so the default's keys are unchanged. */ +export function recordPageSlotKey(slot: RecordPageSlot): string { + return slot.app === undefined ? slot.object : JSON.stringify([slot.object, slot.app]); +} + +/** + * Every slot `item` holds: its object's default when it is one, and one slot + * per distinct string entry of its `apps` that `defaultRecordPageObject` places + * (a record page bound to an object). Reads an unparsed body. + */ +export function recordPageSlotsOf(item: unknown): RecordPageSlot[] { + const slots: RecordPageSlot[] = []; + const object = defaultRecordPageObject(item); + if (object !== undefined) slots.push({ object }); + const apps = (item as { apps?: unknown } | null | undefined)?.apps; + if (Array.isArray(apps)) { + for (const app of new Set(apps.filter((a): a is string => typeof a === 'string'))) { + const scoped = defaultRecordPageObject(item, app); + if (scoped !== undefined) slots.push({ object: scoped, app }); + } + } + return slots; +} + +/** Whether `item` holds `slot`. */ +export function holdsRecordPageSlot(item: unknown, slot: RecordPageSlot): boolean { + return defaultRecordPageObject(item, slot.app) === slot.object; +} + +/** One record-page slot a manifest declares a page for. */ +export interface DeclaredDefaultRecordPage extends RecordPageSlot { /** The page's name. */ readonly page: string; } @@ -100,7 +149,8 @@ function pageName(item: unknown): string | undefined { } /** - * Every default record page a manifest declares, read from the SAME sources + * Every record-page slot a manifest declares a page for — each default record + * page, and each app a scoped record page names — read from the SAME sources * the engine's registration seams read: the manifest's own `pages` and each * nested `plugins[]` entry's (a nested plugin contributes under its parent * package — `ObjectQL.registerPlugin`), one level deep, arrays only. In @@ -113,9 +163,9 @@ export function declaredDefaultRecordPages(manifest: unknown): DeclaredDefaultRe const pages = (source as { pages?: unknown }).pages; if (!Array.isArray(pages)) return; for (const item of pages) { - const object = defaultRecordPageObject(item); const page = pageName(item); - if (object !== undefined && page !== undefined) out.push({ object, page }); + if (page === undefined) continue; + for (const slot of recordPageSlotsOf(item)) out.push({ ...slot, page }); } }; read(manifest); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 33da967d8ab..bb63d6098f8 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -81,8 +81,12 @@ import { defaultRecordPageHolderKey, describeDefaultRecordPageHolder, type DefaultRecordPageHolder, + holdsRecordPageSlot, + recordPageSlotKey, + recordPageSlotsOf, + type RecordPageSlot, } from './default-record-page.js'; -import { defaultRecordPageObject, misplacedDefaultPageRefusal } from '@objectstack/spec/ui'; +import { misplacedDefaultPageRefusal } from '@objectstack/spec/ui'; // [#14553] The ONE resolution of "does this nav group id exist?" and the ONE // wording of the diagnostic when it does not. `os build` calls the same two // (`checkNavContributionGroups`), so the compile-time door and this read-time @@ -2647,6 +2651,11 @@ export class SecurityCatalogNameConflictError extends Error { export interface DefaultRecordPageConflict { /** The object both pages are the default record page of. */ readonly object: string; + /** + * The app both pages are the record page of `object` inside (`PageSchema.apps`, + * ruling A on objectstack#28979); absent for the deployment's default. + */ + readonly app?: string; /** The page whose registration this refusal stopped. */ readonly incoming: DefaultRecordPageHolder; /** The page that already holds the object's default record page. */ @@ -2693,16 +2702,30 @@ export class DefaultRecordPageConflictError extends Error { const [first] = conflicts; const lines = conflicts.map( (c) => - `${describeDefaultRecordPageHolder(c.incoming)} cannot be the default record page of object ` + - `"${c.object}": ${describeDefaultRecordPageHolder(c.existing)} already is`, - ); - super( - `Default record page conflict: ${lines.join('; ')}. Keep one default per object: set ` + - `\`isDefault: false\` on one of the two pages, or compose the deployment without the other ` + - `package's page. At most one \`isDefault: true\` record page may exist for an object in a ` + - `deployment: the record view opens that page with no tie-break, so with two the page would be ` + - `chosen by registration order.`, + c.app === undefined + ? `${describeDefaultRecordPageHolder(c.incoming)} cannot be the default record page of object ` + + `"${c.object}": ${describeDefaultRecordPageHolder(c.existing)} already is` + : `${describeDefaultRecordPageHolder(c.incoming)} cannot be the record page of object ` + + `"${c.object}" in app "${c.app}": ${describeDefaultRecordPageHolder(c.existing)} already is`, ); + const remedies: string[] = []; + if (conflicts.some((c) => c.app === undefined)) { + remedies.push( + `Keep one default per object: set \`isDefault: false\` on one of the two pages, or compose the ` + + `deployment without the other package's page. At most one \`isDefault: true\` record page may ` + + `exist for an object in a deployment: the record view opens that page with no tie-break, so with ` + + `two the page would be chosen by registration order.`, + ); + } + if (conflicts.some((c) => c.app !== undefined)) { + remedies.push( + `Keep one page per object per app: remove the app from \`apps\` on one of the two pages, or ` + + `compose the deployment without the other package's page. At most one record page may name an ` + + `app in \`apps\` for an object: that app's record view opens it with no tie-break, so with two ` + + `the page would be chosen by registration order.`, + ); + } + super(`Default record page conflict: ${lines.join('; ')}. ${remedies.join(' ')}`); this.name = 'DefaultRecordPageConflictError'; this.conflicts = conflicts; this.object = first.object; @@ -3714,6 +3737,8 @@ export class SchemaRegistry { * its holders' original order. */ private noteDefaultRecordPageClaims(packageId: string): void { + // Keyed by slot (`recordPageSlotKey`): an object's default, or one app's + // page of it. Every slot is put back the same way. this.restorePoint?.note(`defaultPageClaims\u0000${packageId}`, () => { const saved = new Map>(); for (const [object, byPackage] of this.defaultRecordPageClaims) saved.set(object, new Map(byPackage)); @@ -3789,8 +3814,10 @@ export class SchemaRegistry { // ========================================== /** - * Object → package → the page that package declared as the object's default - * record page at install, recorded by {@link installPackage} once its + * Slot → package → the page that package declared for the slot at install — + * a slot is an object's default record page, keyed by the object alone, or + * the record page of an object inside one app (`PageSchema.apps`, ruling A + * on objectstack#28979), keyed by `recordPageSlotKey` — recorded by {@link installPackage} once its * declarations pass {@link refuseDefaultRecordPageConflicts}, forgotten by * {@link uninstallPackage}. * @@ -3820,7 +3847,7 @@ export class SchemaRegistry { * onto an overlay of a packaged page), and is named as that package's page. * A disabled package is still installed, and its pages still hold. */ - private defaultRecordPageSlots(object: string): Array<{ holder: DefaultRecordPageHolder; bare: boolean }> { + private defaultRecordPageSlots(slot: RecordPageSlot): Array<{ holder: DefaultRecordPageHolder; bare: boolean }> { const found = new Map(); const add = (holder: DefaultRecordPageHolder, bare: boolean) => { found.set(defaultRecordPageHolderKey(holder), { holder, bare }); @@ -3832,7 +3859,7 @@ export class SchemaRegistry { const stamped = (item as { _packageId?: unknown } | null | undefined)?._packageId; if (sep < 0) { bareNames.add(key); - if (defaultRecordPageObject(item) !== object) continue; + if (!holdsRecordPageSlot(item, slot)) continue; const packaged = typeof stamped === 'string' && stamped !== '' && stamped !== 'sys_metadata' && !isTenantAuthored(item); add(packaged ? { kind: 'package', packageId: stamped, page: key } : { kind: 'environment', page: key }, true); @@ -3845,10 +3872,10 @@ export class SchemaRegistry { } } for (const { packageId, page, item } of composites) { - if (bareNames.has(page) || defaultRecordPageObject(item) !== object) continue; + if (bareNames.has(page) || !holdsRecordPageSlot(item, slot)) continue; add({ kind: 'package', packageId, page }, false); } - for (const [packageId, page] of this.defaultRecordPageClaims.get(object) ?? []) { + for (const [packageId, page] of this.defaultRecordPageClaims.get(recordPageSlotKey(slot)) ?? []) { if (bareNames.has(page)) continue; add({ kind: 'package', packageId, page }, false); } @@ -3865,9 +3892,13 @@ export class SchemaRegistry { * An environment item under a page's name stands in for that page, so a * name appears once, as the bare slot's verdict, whatever the slots it * shadows say. See {@link defaultRecordPageSlots}. + * + * With `app` passed, the holders of the record page of `object` inside that + * app instead (`PageSchema.apps`, ruling A on objectstack#28979) — the slot + * the save door meets an app-scoped page with. */ - defaultRecordPageHoldersOf(object: string): DefaultRecordPageHolder[] { - return this.defaultRecordPageSlots(object).map(({ holder }) => holder); + defaultRecordPageHoldersOf(object: string, app?: string): DefaultRecordPageHolder[] { + return this.defaultRecordPageSlots(app === undefined ? { object } : { object, app }).map(({ holder }) => holder); } /** @@ -3876,8 +3907,8 @@ export class SchemaRegistry { * the package's own pages and the bare-slot item of the same name (the * environment's overlay of the very page being registered). */ - private otherDefaultRecordPageHolders(object: string, page: string, packageId: string): DefaultRecordPageHolder[] { - return this.defaultRecordPageSlots(object) + private otherDefaultRecordPageHolders(slot: RecordPageSlot, page: string, packageId: string): DefaultRecordPageHolder[] { + return this.defaultRecordPageSlots(slot) .filter(({ holder, bare }) => { if (holder.kind === 'package' && holder.packageId === packageId) return false; return !(bare && holder.page === page); @@ -3908,20 +3939,21 @@ export class SchemaRegistry { if (misplaced.length > 0) throw new MisplacedDefaultPageError(selfId, misplaced); if (selfId === undefined) return; const conflicts: DefaultRecordPageConflict[] = []; - // object → the first page this manifest declares for it. + // slot → the first page this manifest declares for it. const firstDeclared = new Map(); - for (const { object, page } of declaredDefaultRecordPages(manifest)) { + for (const { page, ...slot } of declaredDefaultRecordPages(manifest)) { const incoming: DefaultRecordPageHolder = { kind: 'package', packageId: selfId, page }; - const first = firstDeclared.get(object); + const key = recordPageSlotKey(slot); + const first = firstDeclared.get(key); if (first === undefined) { - firstDeclared.set(object, page); - for (const existing of this.otherDefaultRecordPageHolders(object, page, selfId)) { - conflicts.push({ object, incoming, existing }); + firstDeclared.set(key, page); + for (const existing of this.otherDefaultRecordPageHolders(slot, page, selfId)) { + conflicts.push({ ...slot, incoming, existing }); } } else if (first !== page) { - // Two pages of one manifest for one object. One page declared twice + // Two pages of one manifest for one slot. One page declared twice // under one name is one page (the stack contract refuses the repeat). - conflicts.push({ object, incoming, existing: { kind: 'package', packageId: selfId, page: first } }); + conflicts.push({ ...slot, incoming, existing: { kind: 'package', packageId: selfId, page: first } }); } } if (conflicts.length > 0) throw new DefaultRecordPageConflictError(conflicts); @@ -3934,11 +3966,12 @@ export class SchemaRegistry { private recordDefaultRecordPageClaims(packageId: string, manifest: ObjectStackManifest): void { this.noteDefaultRecordPageClaims(packageId); this.forgetDefaultRecordPageClaims(packageId); - for (const { object, page } of declaredDefaultRecordPages(manifest)) { - let byPackage = this.defaultRecordPageClaims.get(object); + for (const { page, ...slot } of declaredDefaultRecordPages(manifest)) { + const key = recordPageSlotKey(slot); + let byPackage = this.defaultRecordPageClaims.get(key); if (!byPackage) { byPackage = new Map(); - this.defaultRecordPageClaims.set(object, byPackage); + this.defaultRecordPageClaims.set(key, byPackage); } if (!byPackage.has(packageId)) byPackage.set(packageId, page); } @@ -6745,14 +6778,16 @@ export class SchemaRegistry { // prints at `os validate` and at save. const refusal = misplacedDefaultPageRefusal(item); if (refusal !== undefined) throw new MisplacedDefaultPageError(packageId, [{ page: baseName, refusal }]); - const object = defaultRecordPageObject(item); - if (object !== undefined) { - const holders = this.otherDefaultRecordPageHolders(object, baseName, packageId); - if (holders.length > 0) { - const incoming: DefaultRecordPageHolder = { kind: 'package', packageId, page: baseName }; - throw new DefaultRecordPageConflictError(holders.map((existing) => ({ object, incoming, existing }))); + // Every slot the page holds: its object's default, and one per app its + // `apps` names (ruling A on objectstack#28979). + const incoming: DefaultRecordPageHolder = { kind: 'package', packageId, page: baseName }; + const conflicts: DefaultRecordPageConflict[] = []; + for (const slot of recordPageSlotsOf(item)) { + for (const existing of this.otherDefaultRecordPageHolders(slot, baseName, packageId)) { + conflicts.push({ ...slot, incoming, existing }); } } + if (conflicts.length > 0) throw new DefaultRecordPageConflictError(conflicts); } // ADR-0010 §3.7 — translate the author-facing `protection` block diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 5cd30ed57b7..38dc952dd9f 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -3179,36 +3179,63 @@ function collectDuplicateEntryErrors(config: ObjectStackDefinition): string[] { * `origins` labels each page's position for the finding (`stack.pages[3]`); * {@link collectComposedDefaultRecordPageErrors} reuses the reading over the * pages a composition brings together, with its own labels. + * + * The same rule holds one level down, per app (ruling A on #28979): two record + * pages for one object that both name the same app in `apps` are two pages for + * that app's record view, refused here with the same counting and naming. The + * default and an app's page are different slots and never meet. */ function duplicateDefaultRecordPageFindings( entries: Iterable<{ readonly page: unknown; readonly origin: string; readonly holder?: string }>, scope: string, ): string[] { - // object → page identity → { name, origins }. The identity is the page name, - // or `holder|name` where the caller says which package an entry lands in. - const byObject = new Map>(); + // slot → page identity → { name, origins }. A slot is an object's default + // (no app), or an object's record page inside one app (ruling A on #28979: + // `apps`, one page per object per app — the default's rule one level down, + // read by the same `defaultRecordPageObject` with the app passed). The + // identity is the page name, or `holder|name` where the caller says which + // package an entry lands in. + type Slot = { object: string; app?: string }; + const slots = new Map }>(); for (const { page, origin, holder } of entries) { - const object = defaultRecordPageObject(page); - if (object === undefined) continue; const name = readEntryName(page); if (name === undefined) continue; - const pages = byObject.get(object) ?? new Map(); - const key = holder === undefined ? name : `${holder}|${name}`; - const found = pages.get(key) ?? { name, origins: [] }; - found.origins.push(origin); - pages.set(key, found); - byObject.set(object, pages); + const held: Slot[] = []; + const object = defaultRecordPageObject(page); + if (object !== undefined) held.push({ object }); + const apps: unknown = (page as { apps?: unknown } | null | undefined)?.apps; + if (Array.isArray(apps)) { + for (const app of new Set(apps.filter((a): a is string => typeof a === 'string'))) { + const scoped = defaultRecordPageObject(page, app); + if (scoped !== undefined) held.push({ object: scoped, app }); + } + } + for (const slot of held) { + const slotKey = JSON.stringify([slot.object, slot.app ?? null]); + const entry = slots.get(slotKey) ?? { slot, pages: new Map() }; + const key = holder === undefined ? name : `${holder}|${name}`; + const found = entry.pages.get(key) ?? { name, origins: [] }; + found.origins.push(origin); + entry.pages.set(key, found); + slots.set(slotKey, entry); + } } const errors: string[] = []; - for (const [object, pages] of byObject) { + for (const { slot: { object, app }, pages } of slots.values()) { if (pages.size < 2) continue; const named = [...pages.values()].map(({ name, origins }) => `'${name}' (${joinDeclarationOrigins(origins)})`); + const count = pages.size === 2 ? 'two' : String(pages.size); errors.push( - `Object '${object}' has ${pages.size === 2 ? 'two' : pages.size} default record pages in ${scope}: ` + - `${joinDeclarationOrigins(named)}. Set \`isDefault: false\` on all but one of them. At most one ` + - `record page may be \`isDefault: true\` for an object in a deployment: the record view opens that ` + - `page with no tie-break, so with two the choice would fall to registration order.`, + app === undefined + ? `Object '${object}' has ${count} default record pages in ${scope}: ` + + `${joinDeclarationOrigins(named)}. Set \`isDefault: false\` on all but one of them. At most one ` + + `record page may be \`isDefault: true\` for an object in a deployment: the record view opens that ` + + `page with no tie-break, so with two the choice would fall to registration order.` + : `Object '${object}' has ${count} record pages for app '${app}' in ${scope}: ` + + `${joinDeclarationOrigins(named)}. Remove '${app}' from \`apps\` on all but one of them. At most one ` + + `record page may name an app in \`apps\` for an object: the app's record view opens that page with ` + + `no tie-break, so with two the choice would fall to registration order.`, ); } return errors; diff --git a/packages/spec/src/ui/default-record-page.ts b/packages/spec/src/ui/default-record-page.ts index c13d1283b6b..9bc8fb5cac9 100644 --- a/packages/spec/src/ui/default-record-page.ts +++ b/packages/spec/src/ui/default-record-page.ts @@ -26,6 +26,19 @@ * overlay by name) is the same page, not a second default, and every door lets * it through. (Maintainer ruling A on objectstack#23015, record 6134056653.) * + * ## The app-scoped record page + * + * A record page may also name the apps whose routes open it for its object + * (`PageSchema.apps`; maintainer ruling A on objectstack#28979, record + * 6573557633). Inside one of those apps (`/apps///record/`) + * that page opens; everywhere else, Setup included, the deployment's one + * default record page still opens. The rule is the default's, one level down: + * at most one record page per object PER APP, refused at the same doors with + * the same mechanism, naming both pages. The default and an app's page are two + * different slots and coexist; no precedence ladder joins them. The one reading + * is {@link defaultRecordPageObject} with the app passed: "the object this page + * is the record page of, inside that app". + * * ## Why it lives in the spec * * The three doors live in three packages, and the stack contract cannot import @@ -61,21 +74,30 @@ export const PAGE_TYPE_DEFAULT = 'record' as const; * spelling (`default`, `objectName`, `pageType`) is refused by the page schema * itself and decides nothing here. * + * With `app` passed, the question is the app's slot instead (ruling A on + * objectstack#28979): the object `page` is the record page of INSIDE that + * app — a record page bound to an object whose `apps` lists `app` (a string + * entry equal to it). `isDefault` is not read then: the deployment's default + * and an app's page are two slots, and one page may hold both. A door that + * judges every slot a page holds asks once with no app and once per string + * entry of the page's `apps`. + * * Reads a parsed page and an unparsed one alike, so a door can ask it of a * registry item or a stored row without parsing first. */ -export function defaultRecordPageObject(page: unknown): string | undefined { +export function defaultRecordPageObject(page: unknown, app?: string): string | undefined { if (!page || typeof page !== 'object' || Array.isArray(page)) return undefined; const p = page as Record; - if (p.isDefault !== true) return undefined; + if (app === undefined ? p.isDefault !== true : !(Array.isArray(p.apps) && p.apps.includes(app))) return undefined; const type = p.type === undefined ? PAGE_TYPE_DEFAULT : p.type; if (type !== 'record') return undefined; return typeof p.object === 'string' && p.object !== '' ? p.object : undefined; } /** - * The refusal of `isDefault: true` on a page {@link defaultRecordPageObject} - * does not place, or `undefined` when the page may carry what it declares. + * The refusal of `isDefault: true` — or of an `apps` scope — on a page + * {@link defaultRecordPageObject} does not place, or `undefined` when the page + * may carry what it declares. * * `isDefault` has one meaning: the default record page of the object a record * page binds. On any other page nothing reads it — an app opens on its first @@ -88,26 +110,43 @@ export function defaultRecordPageObject(page: unknown): string | undefined { * `@objectstack/objectql`'s registry for the pages a package registers * without a parse. * + * `apps` (ruling A on objectstack#28979) is placed exactly where `isDefault` + * is — it chooses the record page an app's routes open for the page's object — + * so it is refused on the same pages, by the same doors, through this one + * function. The `isDefault` sentence comes first when a page carries both. + * * Reads a parsed page and an unparsed one alike, by the same declared keys - * {@link defaultRecordPageObject} reads. The sentence names the release that - * first refused the flag (`@objectstack/spec 18`): the retired D2 entry - * `page-is-default-off-record-page-removed` converts data at rest, and the - * removal-version pin holds every such prescription to the release its - * `retiredAfter` derives. + * {@link defaultRecordPageObject} reads. The `isDefault` sentence names the + * release that first refused the flag (`@objectstack/spec 18`): the retired D2 + * entry `page-is-default-off-record-page-removed` converts data at rest, and + * the removal-version pin holds every such prescription to the release its + * `retiredAfter` derives. The `apps` sentence names no release: the key never + * shipped without its placement rule. */ export function misplacedDefaultPageRefusal(page: unknown): string | undefined { if (!page || typeof page !== 'object' || Array.isArray(page)) return undefined; const p = page as Record; - if (p.isDefault !== true || defaultRecordPageObject(page) !== undefined) return undefined; const type = p.type === undefined ? PAGE_TYPE_DEFAULT : p.type; + const placed = type === 'record' && typeof p.object === 'string' && p.object !== ''; + if (placed) return undefined; + if (p.isDefault === true) { + if (type === 'record') { + return '`isDefault: true` is refused on a record page bound to no `object` since @objectstack/spec 18: it makes a record page the ' + + 'default record page of the object it binds, the page that object\'s record view opens. Bind the ' + + 'page (`object: \'\'`), or delete `isDefault`.'; + } + return `\`isDefault: true\` is refused on a \`type: '${String(type)}'\` page since @objectstack/spec 18: it marks the default record page ` + + 'of an object, and nothing reads it on any other page. A page does not choose where an app opens — an ' + + 'app\'s landing page is its first navigation item (by `order`), so to land on this page put its entry ' + + 'first in the app\'s `navigation`; the root landing follows the app\'s own `isDefault`. Delete ' + + '`isDefault` from this page.'; + } + if (p.apps === undefined) return undefined; if (type === 'record') { - return '`isDefault: true` is refused on a record page bound to no `object` since @objectstack/spec 18: it makes a record page the ' - + 'default record page of the object it binds, the page that object\'s record view opens. Bind the ' - + 'page (`object: \'\'`), or delete `isDefault`.'; + return '`apps` is refused on a record page bound to no `object`: it names the apps whose routes open this page ' + + 'for the object it binds, and this page binds none. Bind the page (`object: \'\'`), or delete `apps`.'; } - return `\`isDefault: true\` is refused on a \`type: '${String(type)}'\` page since @objectstack/spec 18: it marks the default record page ` - + 'of an object, and nothing reads it on any other page. A page does not choose where an app opens — an ' - + 'app\'s landing page is its first navigation item (by `order`), so to land on this page put its entry ' - + 'first in the app\'s `navigation`; the root landing follows the app\'s own `isDefault`. Delete ' - + '`isDefault` from this page.'; + return `\`apps\` is refused on a \`type: '${String(type)}'\` page: it names the apps whose routes open a record page ` + + 'for its object, and nothing reads it on any other page. To show this page in an app, give it an entry in ' + + 'that app\'s `navigation`. Delete `apps` from this page.'; } diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index 7ddc0699d72..fb97d8ed68e 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -1698,16 +1698,25 @@ export const PageTemplateSchema = lazySchema(() => enumWithRetiredValues( * The sentence is {@link misplacedDefaultPageRefusal}'s, the one the registry * door prints for a page a package registers without a parse. * + * `apps` (ruling A on objectstack#28979) is placed where `isDefault` is — it + * chooses the record page an app's routes open for the page's object — so the + * same check refuses it on the same pages, at `apps`, in the same function's + * sentence for that key. + * * Exported for the reason {@link checkPageSourceCompleteness} is, and * attached by identifier — pinned in `object-refinement-check-exports.test.ts`. */ export function checkPageDefaultPlacement( - page: { isDefault?: unknown; type?: unknown; object?: unknown }, + page: { isDefault?: unknown; apps?: unknown; type?: unknown; object?: unknown }, ctx: z.RefinementCtx, ): void { - const refusal = misplacedDefaultPageRefusal(page); - if (refusal === undefined) return; - ctx.addIssue({ code: 'custom', path: ['isDefault'], message: refusal }); + // Each key is judged on its own, so a page carrying both misplaced keys is + // told about both, each at its own path, in the one sentence per key the + // registry door prints too. + const isDefaultRefusal = misplacedDefaultPageRefusal({ ...page, apps: undefined }); + if (isDefaultRefusal !== undefined) ctx.addIssue({ code: 'custom', path: ['isDefault'], message: isDefaultRefusal }); + const appsRefusal = misplacedDefaultPageRefusal({ ...page, isDefault: undefined }); + if (appsRefusal !== undefined) ctx.addIssue({ code: 'custom', path: ['apps'], message: appsRefusal }); } /** @@ -1875,6 +1884,25 @@ export function checkPageMainRegionWidth( }); } +/** + * The `apps` list names each app once: a repeat is the same slot twice and + * decides nothing, so it is refused at the repeat (ruling A on + * objectstack#28979). Attached to the array, so a `.shape.apps` mirror keeps it. + */ +function checkPageAppsUnique(apps: readonly string[], ctx: z.RefinementCtx): void { + const seen = new Set(); + apps.forEach((app, i) => { + if (seen.has(app)) { + ctx.addIssue({ + code: 'custom', + path: [i], + message: `App '${app}' is listed twice in \`apps\`. Each app is one slot: list it once.`, + }); + } + seen.add(app); + }); +} + export const PageSchema = lazySchema(() => strictObject({ surface: 'this page', history: PAGE_HISTORY, @@ -2023,6 +2051,34 @@ export const PageSchema = lazySchema(() => strictObject({ */ isDefault: z.boolean().default(false) .describe('Whether this record page is the default record page of its `object` — the page the record view opens. At most one `isDefault: true` record page may exist for an object in a deployment: a second is refused (by defineStack / os validate, at package registration and at metadata save), naming both pages. Saving a page under the existing default\'s own name is that page, not a second default. Refused on any other page — a page that is not a record page bound to an object: nothing reads it there. An app opens on its first navigation item, and the root landing follows the app\'s own isDefault.'), + /** + * App scope — the apps whose routes open this record page for its `object` + * (maintainer ruling A on objectstack#28979, record 6573557633). + * + * Inside an app this list names, the record view of the page's object + * (`/apps///record/`) opens this page. Everywhere else — + * every other app, Setup included — the deployment's one `isDefault` record + * page still opens: ruling A on objectstack#23015 (one default per object per + * deployment) is unchanged, and no precedence ladder is added. The default + * and an app's page are two slots, and one page may hold both. + * + * **At most one record page per object per app.** Two record pages for the + * same object that both name the same app are refused at the doors that + * refuse a second default — `defineStack` / `composeStacks`, package + * registration, metadata save — naming both pages, through the same reading + * (`defaultRecordPageObject(page, app)`, `default-record-page.ts`). + * + * Placed where `isDefault` is: refused on a page that is not a record page + * bound to an object ({@link checkPageDefaultPlacement}). Entries are app + * names (`AppSchema.name`), non-empty and unique. + * + * Declared ahead of its renderer (ADR-0049): the console's page assignment + * reads the route's app in the ui follow-up; until it does, the console + * opens the default record page in every app, as before the key existed. + */ + // Renderer half: the console's usePageAssignment (ui lane follow-up of #28979). + apps: z.array(SnakeCaseIdentifierSchema).min(1).superRefine(checkPageAppsUnique).optional() + .describe("[PLANNED — not yet rendered] The apps whose routes open this record page for its `object`: inside an app named here, the object's record view (`/apps///record/`) opens this page; everywhere else, Setup included, the object's one `isDefault` record page still opens, so the default and an app's page coexist. App names, non-empty and unique. At most one record page per object per app: a second page naming the same app for the same object is refused (by defineStack / os validate, at package registration and at metadata save), naming both pages. Refused on any page that is not a record page bound to an object. Until the console reads it, every app opens the default record page."), // `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 From 3b39a5e6d53d2550b63b810213465393e709c0f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 12:49:20 +0000 Subject: [PATCH 02/11] chore(spec): regenerate artifacts and ledgers for PageSchema.apps Claude-Session: https://claude.ai/code/session_01ADHkmSGMLCTCPxZfV3GqHr Co-authored-by: Claude --- content/docs/references/ui/page.mdx | 1 + packages/spec/authorable-surface/ui.json | 1 + packages/spec/dropped-refinements.baseline.json | 7 ++++++- packages/spec/liveness/page.json | 6 ++++++ packages/spec/liveness/state-counts/page.md | 2 +- .../system/metadata-form-zod-reconciliation.test.ts | 10 ++++++++++ 6 files changed, 25 insertions(+), 2 deletions(-) diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index 985d5c0b0d6..f5baeec5fbd 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -227,6 +227,7 @@ View filter rule | **template** | `Enum<'default' \| 'header-sidebar-main' \| 'three-column' \| 'dashboard'>` | optional (default: `"default"`) | Layout template — 'default', 'header-sidebar-main', 'three-column' or 'dashboard'. Each places its own regions (PAGE_TEMPLATE_SLOTS); a region the template does not place, or a second region filling the same slot, is refused at the region's name. | | **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`) | Whether this record page is the default record page of its `object` — the page the record view opens. At most one `isDefault: true` record page may exist for an object in a deployment: a second is refused (by defineStack / os validate, at package registration and at metadata save), naming both pages. Saving a page under the existing default's own name is that page, not a second default. Refused on any other page — a page that is not a record page bound to an object: nothing reads it there. An app opens on its first navigation item, and the root landing follows the app's own isDefault. | +| **apps** | `string[]` | optional | [PLANNED — not yet rendered] The apps whose routes open this record page for its `object`: inside an app named here, the object's record view (`/apps///record/`) opens this page; everywhere else, Setup included, the object's one `isDefault` record page still opens, so the default and an app's page coexist. App names, non-empty and unique. At most one record page per object per app: a second page naming the same app for the same object is refused (by defineStack / os validate, at package registration and at metadata save), naming both pages. Refused on any page that is not a record page bound to an object. Until the console reads it, every app opens the default record page. | | **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 (named in a position's `permissionSets`) — those are the checks the runtime actually runs. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **interfaceConfig** | `{ source?: string; columns?: string[] \| object[]; sort?: object[]; filterBy?: (object \| object)[]; … }` | optional | Interface-level page configuration (for Airtable-style interface pages) | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index be773fe3492..2e14e9d3bea 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -1051,6 +1051,7 @@ "ui/Page:_packageId", "ui/Page:_packageVersion", "ui/Page:_provenance", + "ui/Page:apps", "ui/Page:aria", "ui/Page:assignedProfiles [RETIRED]", "ui/Page:description", diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 8c18e5acf05..85d37a56a78 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -3,7 +3,7 @@ "measured": { "zod": "4.4.3", "publishedSchemasWithDroppedRefinements": 248, - "droppedRefinementSites": 988, + "droppedRefinementSites": 993, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, @@ -115,6 +115,7 @@ "manifest.objects.element.membershipKeys", "manifest.objects.element.membershipKeys.valueType", "manifest.pages.element", + "manifest.pages.element.apps", "manifest.pages.element.slots.header.options[0].type", "manifest.permissions.element.fields.valueType", "manifest.permissions.element.objects.valueType.out.out", @@ -259,6 +260,7 @@ "data.options[1].manifest.objects.element.membershipKeys", "data.options[1].manifest.objects.element.membershipKeys.valueType", "data.options[1].manifest.pages.element", + "data.options[1].manifest.pages.element.apps", "data.options[1].manifest.pages.element.slots.header.options[0].type", "data.options[1].manifest.permissions.element.fields.valueType", "data.options[1].manifest.permissions.element.objects.valueType.out.out", @@ -398,6 +400,7 @@ "options[1].manifest.objects.element.membershipKeys", "options[1].manifest.objects.element.membershipKeys.valueType", "options[1].manifest.pages.element", + "options[1].manifest.pages.element.apps", "options[1].manifest.pages.element.slots.header.options[0].type", "options[1].manifest.permissions.element.fields.valueType", "options[1].manifest.permissions.element.objects.valueType.out.out", @@ -477,6 +480,7 @@ "data.packages.element.options[1].manifest.objects.element.membershipKeys", "data.packages.element.options[1].manifest.objects.element.membershipKeys.valueType", "data.packages.element.options[1].manifest.pages.element", + "data.packages.element.options[1].manifest.pages.element.apps", "data.packages.element.options[1].manifest.pages.element.slots.header.options[0].type", "data.packages.element.options[1].manifest.permissions.element.fields.valueType", "data.packages.element.options[1].manifest.permissions.element.objects.valueType.out.out", @@ -1840,6 +1844,7 @@ "ui/Page": { "sites": [ "", + "apps", "interfaceConfig.emptyState.link.url", "record.filter.element", "record.filter.element.options[0]", diff --git a/packages/spec/liveness/page.json b/packages/spec/liveness/page.json index 33fc213648f..fa49b4eaf9e 100644 --- a/packages/spec/liveness/page.json +++ b/packages/spec/liveness/page.json @@ -131,6 +131,12 @@ "status": "live", "note": "the default record page of the object a record page binds — objectui page resolution (the one reading every door judges by is defaultRecordPageObject, default-record-page.ts). On any other page it is REFUSED (#23610, ruling 6190536588 Q1 A; checkPageDefaultPlacement and the registry door, one sentence misplacedDefaultPageRefusal): nothing read it there — an app opens on its first navigation item and the root landing follows the app's own isDefault. LOAD PATH (P0 after #23610): an installed or stored page still carrying the flag replays the retired D2 entry page-is-default-off-record-page-removed on read — stored rows, the artifact doors, and the kernel's package registration door for a body that declares its protocol range (ObjectQL.convertPackageBodyForward) — so the kernel builds with an OS_METADATA_CONVERTED notice; the authoring, save and publish doors keep refusing." }, + "apps": { + "status": "planned", + "verifiedAt": "2026-11-02", + "evidence": "packages/spec/src/ui/default-record-page.ts#defaultRecordPageObject (with the app passed: the object a record page is the page of inside that app — the one reading defineStack, the registry's package door and the metadata save door ask); packages/spec/src/ui/page.zod.ts#checkPageDefaultPlacement (refuses `apps` on a page that is not a record page bound to an object)", + "note": "PLANNED, the renderer half pending — ruling A on #28979 (record 6573557633): inside an app `apps` names, the object's record view opens this page; elsewhere, Setup included, the object's one isDefault page still opens. The refusal half is enforced now: two record pages for one object naming the same app are refused by defineStack / composeStacks, the objectql registry's package door and item seam, and the metadata save door (DEFAULT_RECORD_PAGE_CONFLICT, naming both pages). Measured on objectstack main @2f7977cd: the console's page assignment (ui usePageAssignment / appObjectScope.declaredRecordPage) reads no app, so every app opens the default record page, as before the key existed. Flip to `live` when the ui follow-up reads the route's app first, citing the read point." + }, "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). [#23005] This blanket `live` verdict covers `interfaceConfig.emptyState` too: the list page's own empty state, the list view's EmptyStateSchema by reference. It was declared ahead of its renderer, contract first, with the `[PLANNED — not yet rendered]` marker on its describe; the renderer half, objectstack-ai/objectui#12270 (objectui b434f5f47), is inside the console pin from objectui b9814b334, and the marker is dropped. Measured there: InterfaceListPage hands ListView `cfg.emptyState ?? view.emptyState` (InterfaceListPage.tsx:509), so the page's own empty state wins over the source view's. `page/interfaceConfig` is a recorded undrilled container (undrilled-containers.baseline.json), so the walk reads no row for the member. The reading before objectui#12270 (first cited by the Studio-form omit row for `interfaceConfig.emptyState` in packages/spec/src/system/metadata-form-zod-reconciliation.test.ts, which #23568 removed once the renderer half shipped; recorded here by #23434): objectui @7af0ab3 packages/app-shell/src/views/InterfaceListPage.tsx#schema handed ListView the source view's `emptyState` only; at objectui @b9814b334 the same symbol hands `cfg.emptyState ?? view.emptyState`." diff --git a/packages/spec/liveness/state-counts/page.md b/packages/spec/liveness/state-counts/page.md index 2bbef13717d..dbda954e61d 100644 --- a/packages/spec/liveness/state-counts/page.md +++ b/packages/spec/liveness/state-counts/page.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `page` | 34 | 0 | 0 | 1 | 9 | 44 | +| `page` | 34 | 0 | 0 | 1 | 10 | 45 | diff --git a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts index 10220b34c84..5986db980b4 100644 --- a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts +++ b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts @@ -354,6 +354,16 @@ const LEDGER: ReadonlyArray = [ key: 'record', why: "planned, not yet rendered — the binding's one-row query is validated at parse and refused on every page that is not `type: 'app'`, but no renderer mounts it as the page's `record` until the console's page-root binding lands. A form control offered today would let an author bind a row no predicate can read yet", }, + // #28979 — a record page's app scope lands spec-first the same way: + // `planned` in the liveness ledger, read by no console until the ui card + // that makes the page assignment read the route's app. + { + kind: 'omit', + type: 'page', + path: ROOT_PATH, + key: 'apps', + why: "planned, not yet rendered — the app scope is validated at parse, refused off a record page bound to an object, and a second page for one object in one app is refused at every door, but no console opens the scoped page in its app until the page assignment reads the route's app. A form control offered today would let an author scope a page no route opens yet", + }, // #28022 — an app's per-audience landing list lands spec-first the same way: // `planned` in the liveness ledger, read by no console until the landing // redirect card. Offering it in the app form first would be the UI half of From 16a6b30531bf9ea82a6277ebce57483e9493043e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 12:50:31 +0000 Subject: [PATCH 03/11] test(spec): pin PageSchema.apps and the per-app refusal at defineStack and composeStacks Claude-Session: https://claude.ai/code/session_01ADHkmSGMLCTCPxZfV3GqHr Co-authored-by: Claude --- .../spec/src/stack-app-record-page.test.ts | 231 ++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 packages/spec/src/stack-app-record-page.test.ts diff --git a/packages/spec/src/stack-app-record-page.test.ts b/packages/spec/src/stack-app-record-page.test.ts new file mode 100644 index 00000000000..11497d24470 --- /dev/null +++ b/packages/spec/src/stack-app-record-page.test.ts @@ -0,0 +1,231 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The app-scoped record page — `PageSchema.apps` (maintainer ruling A on + * #28979, record 6573557633). + * + * A record page names the apps whose routes open it for its object. Inside + * one of them that page opens; everywhere else, Setup included, the object's + * one `isDefault` record page still opens. The rule is the default's one level + * down: at most one record page per object PER APP, refused by `defineStack` + * and `composeStacks` the way a second default is (the registry's package door + * and the metadata save door are pinned in their own packages), naming both + * pages. The default and an app's page are two slots and coexist. + * + * Refusals assert the ADR-0112 envelope (`code` + `status`), the issue count + * and the named subjects; every refusal row has a control that differs from + * it by one key alone. + */ +import { describe, expect, it } from 'vitest'; +import { composeStacks, defineStack } from './stack.zod'; +import { defaultRecordPageObject, misplacedDefaultPageRefusal } from './ui/default-record-page'; +import { PageSchema } from './ui/page.zod'; + +type Refusal = Error & { code?: string; status?: number; issues?: readonly unknown[] }; + +const manifest = (id: string) => ({ + id, + namespace: id.split('.').pop()!.replace(/-/g, ''), + name: id.split('.').pop()!, + version: '1.0.0', + type: 'module' as const, +}); + +/** A record page of `object`; `apps` and `isDefault` are the keys a case varies. */ +const recordPage = (name: string, object: string, extra: Record = {}) => ({ + name, + label: name, + type: 'record' as const, + object, + regions: [], + ...extra, +}); + +const refusalOf = (fn: () => unknown): Refusal | undefined => { + try { + fn(); + return undefined; + } catch (e) { + return e as Refusal; + } +}; + +const stack = (pages: unknown[], id = 'com.example.arp') => ({ manifest: manifest(id), pages }) as any; + +const issuesOf = (value: unknown) => { + const r = PageSchema.safeParse(value); + return r.success ? [] : r.error.issues.map((i) => ({ code: i.code, path: i.path.join('.'), message: i.message })); +}; + +describe('PageSchema.apps — the key', () => { + it('a record page bound to an object takes `apps`, alone or beside `isDefault`', () => { + expect(issuesOf(recordPage('member', 'sys_user', { apps: ['duly'] }))).toEqual([]); + expect(issuesOf(recordPage('member', 'sys_user', { apps: ['duly', 'crm'], isDefault: true }))).toEqual([]); + expect(PageSchema.parse(recordPage('member', 'sys_user', { apps: ['duly'] })).apps).toEqual(['duly']); + }); + + it('absent is unchanged — the parse adds nothing', () => { + expect(PageSchema.parse(recordPage('member', 'sys_user')).apps).toBeUndefined(); + }); + + it('an empty list is refused (a scope with no app decides nothing)', () => { + expect(issuesOf(recordPage('member', 'sys_user', { apps: [] })).map((i) => [i.code, i.path])).toEqual([['too_small', 'apps']]); + }); + + it('an app listed twice is refused at the repeat', () => { + const issues = issuesOf(recordPage('member', 'sys_user', { apps: ['duly', 'crm', 'duly'] })); + expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', 'apps.2']]); + expect(issues[0]!.message).toContain("App 'duly' is listed twice in `apps`"); + }); + + it('an entry that is not an app name (lowercase snake_case) is refused', () => { + expect(issuesOf(recordPage('member', 'sys_user', { apps: ['Duly App'] })).map((i) => i.path)).toEqual(['apps.0']); + }); + + it.each([ + ['a home page', { name: 'pg', label: 'P', type: 'home', apps: ['duly'] }, "`type: 'home'`"], + ['an app page', { name: 'pg', label: 'P', type: 'app', apps: ['duly'] }, "`type: 'app'`"], + ['a record page bound to no object', { name: 'pg', label: 'P', type: 'record', apps: ['duly'] }, 'bound to no `object`'], + ])('is refused on %s, at `apps`, in the registry door\'s sentence', (_label, page, subject) => { + const issues = issuesOf(page); + expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', 'apps']]); + expect(issues[0]!.message).toContain(subject); + expect(issues[0]!.message).toBe(misplacedDefaultPageRefusal(page)); + }); + + it('a misplaced page carrying both keys is told about both, each at its own path', () => { + const issues = issuesOf({ name: 'pg', label: 'P', type: 'home', isDefault: true, apps: ['duly'] }); + expect(issues.map((i) => i.path)).toEqual(['isDefault', 'apps']); + }); + + it('the describe carries the planned marker and no tracker number', () => { + const description = PageSchema.shape.apps.description ?? ''; + expect(description).toMatch(/^\[PLANNED — not yet rendered\] /); + expect(description).not.toMatch(/#\d{3,}/); + }); +}); + +describe('defaultRecordPageObject(page, app) — the one reading of an app\'s slot', () => { + it('names the object of a record page scoped to the app', () => { + expect(defaultRecordPageObject(recordPage('member', 'sys_user', { apps: ['duly'] }), 'duly')).toBe('sys_user'); + }); + + it('answers undefined for an app the page does not name, and for the default slot when not isDefault', () => { + const page = recordPage('member', 'sys_user', { apps: ['duly'] }); + expect(defaultRecordPageObject(page, 'setup')).toBeUndefined(); + expect(defaultRecordPageObject(page)).toBeUndefined(); + }); + + it('a default page holds no app slot unless it names the app — the two slots are apart', () => { + const page = recordPage('user_detail', 'sys_user', { isDefault: true }); + expect(defaultRecordPageObject(page)).toBe('sys_user'); + expect(defaultRecordPageObject(page, 'duly')).toBeUndefined(); + }); + + it.each([ + ['a home page', { name: 'p', type: 'home', object: 'sys_user', apps: ['duly'] }], + ['a record page bound to no object', { name: 'p', type: 'record', apps: ['duly'] }], + ['`apps` that is not an array', { name: 'p', type: 'record', object: 'sys_user', apps: 'duly' }], + ])('answers undefined for %s', (_label, page) => { + expect(defaultRecordPageObject(page, 'duly')).toBeUndefined(); + }); +}); + +describe('defineStack — two record pages for one object in one app', () => { + it('is refused, naming the object, the app, both pages and their positions', () => { + const err = refusalOf(() => + defineStack(stack([ + recordPage('member_a', 'sys_user', { apps: ['duly'] }), + recordPage('member_b', 'sys_user', { apps: ['duly'] }), + ])), + ); + expect(err, 'the stack was refused').toBeDefined(); + expect({ code: err!.code, status: err!.status }).toEqual({ code: 'STACK_CROSS_REFERENCE_INVALID', status: 422 }); + expect(err!.issues).toHaveLength(1); + expect(err!.issues![0]).toMatch( + /^Object 'sys_user' has two record pages for app 'duly' in `pages`: 'member_a' \(stack\.pages\[0\]\) and 'member_b' \(stack\.pages\[1\]\)\. Remove 'duly' from `apps` on all but one of them\./, + ); + }); + + it('control: the same two pages scoped to different apps are accepted', () => { + expect(() => + defineStack(stack([ + recordPage('member_a', 'sys_user', { apps: ['duly'] }), + recordPage('member_b', 'sys_user', { apps: ['crm'] }), + ])), + ).not.toThrow(); + }); + + it('a page scoped to two apps holds both: a second page in either app is refused, the other app untouched', () => { + const err = refusalOf(() => + defineStack(stack([ + recordPage('member_a', 'sys_user', { apps: ['duly', 'crm'] }), + recordPage('member_b', 'sys_user', { apps: ['crm'] }), + ])), + ); + expect(err?.code).toBe('STACK_CROSS_REFERENCE_INVALID'); + expect(err!.issues).toHaveLength(1); + expect(err!.issues![0]).toMatch(/^Object 'sys_user' has two record pages for app 'crm' in `pages`/); + }); + + it('control: a page scoped to two apps alone is accepted', () => { + expect(() => defineStack(stack([recordPage('member_a', 'sys_user', { apps: ['duly', 'crm'] })]))).not.toThrow(); + }); + + it('the default and an app\'s page for one object coexist — two slots, no precedence', () => { + expect(() => + defineStack(stack([ + recordPage('user_detail', 'sys_user', { isDefault: true }), + recordPage('member', 'sys_user', { apps: ['duly'] }), + ])), + ).not.toThrow(); + }); + + it('one page may be the default and an app\'s page at once', () => { + expect(() => + defineStack(stack([ + recordPage('user_detail', 'sys_user', { isDefault: true, apps: ['setup'] }), + recordPage('member', 'sys_user', { apps: ['duly'] }), + ])), + ).not.toThrow(); + }); + + it('a second default is still refused as a default, never as an app\'s page', () => { + const err = refusalOf(() => + defineStack(stack([ + recordPage('user_detail', 'sys_user', { isDefault: true }), + recordPage('member', 'sys_user', { isDefault: true, apps: ['duly'] }), + ])), + ); + expect(err!.issues).toHaveLength(1); + expect(err!.issues![0]).toMatch(/^Object 'sys_user' has two default record pages in `pages`/); + }); +}); + +describe('composeStacks — two record pages for one object in one app across the stacks', () => { + const a = () => defineStack(stack([recordPage('member_a', 'sys_user', { apps: ['duly'] })], 'com.example.arp-a')); + const b = (page: unknown = recordPage('member_b', 'sys_user', { apps: ['duly'] })) => + defineStack(stack([page], 'com.example.arp-b')); + + it('is refused, naming both stacks\' pages and the app', () => { + const err = refusalOf(() => composeStacks([a(), b()])); + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'STACK_CROSS_REFERENCE_INVALID', status: 422 }); + expect(err!.issues).toHaveLength(1); + expect(err!.issues![0]).toContain( + "Object 'sys_user' has two record pages for app 'duly' in the composed stacks: " + + "'member_a' ('com.example.arp-a' (stack #0) pages[0]) and 'member_b' ('com.example.arp-b' (stack #1) pages[0])", + ); + }); + + it('is refused under `manifest: \'preserve\'` too', () => { + expect(refusalOf(() => composeStacks([a(), b()], { manifest: 'preserve' }))?.code).toBe('STACK_CROSS_REFERENCE_INVALID'); + }); + + it('control: the second stack\'s page scoped to another app composes', () => { + expect(() => composeStacks([a(), b(recordPage('member_b', 'sys_user', { apps: ['crm'] }))])).not.toThrow(); + }); + + it('control: the second stack\'s page the default instead composes beside the app\'s page', () => { + expect(() => composeStacks([a(), b(recordPage('member_b', 'sys_user', { isDefault: true }))])).not.toThrow(); + }); +}); From 391b130e237320dfed3d7d37401334ae78ce05b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 12:54:43 +0000 Subject: [PATCH 04/11] test(objectql): pin the per-app record page refusal at the package door and item seam Claude-Session: https://claude.ai/code/session_01ADHkmSGMLCTCPxZfV3GqHr Co-authored-by: Claude --- .../src/registry-app-record-page.test.ts | 211 ++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 packages/objectql/src/registry-app-record-page.test.ts diff --git a/packages/objectql/src/registry-app-record-page.test.ts b/packages/objectql/src/registry-app-record-page.test.ts new file mode 100644 index 00000000000..639d5d548c9 --- /dev/null +++ b/packages/objectql/src/registry-app-record-page.test.ts @@ -0,0 +1,211 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The app-scoped record page at the registry's package door and item seam — + * `PageSchema.apps` (maintainer ruling A on #28979, record 6573557633; + * `default-record-page.ts`, "The app-scoped record page"). + * + * At most one record page per object PER APP: a second package registering a + * record page for an object inside an app another page already holds is + * refused by the same holders, claims and overlay rules that refuse a second + * default, with the same code, naming both pages and the app. The deployment's + * default and an app's page are two slots and coexist. + * + * The door under test is the real one: the `manifest` service ObjectQLPlugin + * registers (`ObjectQL.registerApp` → `SchemaRegistry.installPackage`, then + * `registerItem('page', …, packageId)`). Every refusal is asserted by its + * ADR-0112 envelope (`code` + `status`), the slot and both pages; every + * refusal has a control that differs from it by one key alone. + */ + +import { afterEach, describe, expect, it } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import { ObjectQLPlugin } from './plugin.js'; +import { DEFAULT_RECORD_PAGE_CONFLICT_CODE, MISPLACED_DEFAULT_PAGE_CODE } from './registry.js'; +import type { ObjectQL } from './engine.js'; + +type ManifestService = { register(m: unknown): void | Promise }; +type Holder = { kind: 'package'; packageId: string; page: string } | { kind: 'environment'; page: string }; +type Conflict = { object: string; app?: string; incoming: Holder; existing: Holder }; +type Refusal = Error & { code?: string; status?: number; object?: string; conflicts?: Conflict[] }; + +/** A record page of `object`; `apps` and `isDefault` are what a case varies. */ +const recordPage = (name: string, object: string, extra: Record = {}) => ({ + name, + label: name, + type: 'record', + object, + regions: [], + ...extra, +}); + +const pkg = (id: string, pages: unknown[] = []) => ({ id, name: id.split('.').pop(), version: '1.0.0', type: 'app', pages }); + +const caught = async (fn: () => unknown): Promise => { + try { + await fn(); + return undefined; + } catch (e) { + return e as Refusal; + } +}; + +const kernels: ObjectKernel[] = []; +const boot = async () => { + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + await kernel.use(new ObjectQLPlugin()); + await kernel.bootstrap(); + kernels.push(kernel); + const ql = kernel.getService('objectql'); + ql.registry.logLevel = 'silent'; + const manifest = kernel.getService('manifest'); + return { registry: ql.registry, register: (m: unknown) => caught(() => manifest.register(m)) }; +}; + +afterEach(async () => { + while (kernels.length) { + const k = kernels.pop()!; + if (k.getState() === 'running') await k.shutdown(); + } +}); + +const pkgPage = (packageId: string, page: string): Holder => ({ kind: 'package', packageId, page }); + +function expectAppRefusal(err: Refusal | undefined, conflict: Conflict) { + expect(err, 'the registration was refused').toBeDefined(); + expect({ code: err!.code, status: err!.status }).toEqual({ code: DEFAULT_RECORD_PAGE_CONFLICT_CODE, status: 422 }); + expect(err!.conflicts).toEqual([conflict]); + const name = (h: Holder) => + h.kind === 'package' ? `page "${h.page}" of package "${h.packageId}"` : `page "${h.page}", authored in this system`; + expect(err!.message.split('. ')[0]).toBe( + `Default record page conflict: ${name(conflict.incoming)} cannot be the record page of object "${conflict.object}" ` + + `in app "${conflict.app}": ${name(conflict.existing)} already is`, + ); + expect(err!.message).toContain('Keep one page per object per app: remove the app from `apps` on one of the two pages'); + expect(err!.message).not.toContain('Keep one default per object'); +} + +describe('a second package registering a record page for an object inside an app another page holds', () => { + it('is refused at the package door, naming both pages and the app, and leaves no record behind', async () => { + const { registry, register } = await boot(); + expect(await register(pkg('com.acme.duly', [recordPage('duly_member', 'sys_user', { apps: ['duly'] })]))).toBeUndefined(); + + const err = await register(pkg('com.acme.other', [recordPage('other_member', 'sys_user', { apps: ['duly'] })])); + expectAppRefusal(err, { + object: 'sys_user', + app: 'duly', + incoming: pkgPage('com.acme.other', 'other_member'), + existing: pkgPage('com.acme.duly', 'duly_member'), + }); + expect(registry.getPackage('com.acme.other')).toBeUndefined(); + expect(registry.listItems('page').map((p) => p.name)).toEqual(['duly_member']); + }); + + it('control: the second page scoped to another app registers', async () => { + const { register } = await boot(); + expect(await register(pkg('com.acme.duly', [recordPage('duly_member', 'sys_user', { apps: ['duly'] })]))).toBeUndefined(); + expect(await register(pkg('com.acme.other', [recordPage('other_member', 'sys_user', { apps: ['crm'] })]))).toBeUndefined(); + }); + + it('two pages of ONE manifest for one object in one app are refused, naming both', async () => { + const { register } = await boot(); + const err = await register(pkg('com.acme.duly', [ + recordPage('member_a', 'sys_user', { apps: ['duly'] }), + recordPage('member_b', 'sys_user', { apps: ['duly'] }), + ])); + expectAppRefusal(err, { + object: 'sys_user', + app: 'duly', + incoming: pkgPage('com.acme.duly', 'member_b'), + existing: pkgPage('com.acme.duly', 'member_a'), + }); + }); + + it('a page scoped to two apps holds both slots: a second page in either app is refused', async () => { + const { register } = await boot(); + expect(await register(pkg('com.acme.duly', [recordPage('duly_member', 'sys_user', { apps: ['duly', 'crm'] })]))).toBeUndefined(); + const err = await register(pkg('com.acme.other', [recordPage('other_member', 'sys_user', { apps: ['crm'] })])); + expect(err?.conflicts?.map((c) => [c.object, c.app])).toEqual([['sys_user', 'crm']]); + }); + + it('a package installed with no pages registered still holds its app slot (its claim), and uninstalling releases it', async () => { + const { registry, register } = await boot(); + registry.installPackage(pkg('com.acme.duly', [recordPage('duly_member', 'sys_user', { apps: ['duly'] })]) as any, 'code'); + expect(registry.listItems('page')).toEqual([]); + expect(registry.defaultRecordPageHoldersOf('sys_user', 'duly')).toEqual([pkgPage('com.acme.duly', 'duly_member')]); + const err = await register(pkg('com.acme.other', [recordPage('other_member', 'sys_user', { apps: ['duly'] })])); + expect(err?.code).toBe(DEFAULT_RECORD_PAGE_CONFLICT_CODE); + + expect(registry.uninstallPackage('com.acme.duly')).toBe(true); + expect(registry.defaultRecordPageHoldersOf('sys_user', 'duly')).toEqual([]); + expect(await register(pkg('com.acme.other', [recordPage('other_member', 'sys_user', { apps: ['duly'] })]))).toBeUndefined(); + }); + + it('the item seam refuses a package-bound page registration in an app another package holds', async () => { + const { registry, register } = await boot(); + expect(await register(pkg('com.acme.duly', [recordPage('duly_member', 'sys_user', { apps: ['duly'] })]))).toBeUndefined(); + const err = await caught(() => + registry.registerItem('page', recordPage('direct_member', 'sys_user', { apps: ['duly'] }), 'name', 'com.acme.direct'), + ); + expectAppRefusal(err, { + object: 'sys_user', + app: 'duly', + incoming: pkgPage('com.acme.direct', 'direct_member'), + existing: pkgPage('com.acme.duly', 'duly_member'), + }); + }); + + it('control: the item seam registers the same page scoped to another app', async () => { + const { registry, register } = await boot(); + expect(await register(pkg('com.acme.duly', [recordPage('duly_member', 'sys_user', { apps: ['duly'] })]))).toBeUndefined(); + expect(await caught(() => + registry.registerItem('page', recordPage('direct_member', 'sys_user', { apps: ['crm'] }), 'name', 'com.acme.direct'), + )).toBeUndefined(); + }); + + it('a package scoping a page to an app the environment\'s page holds is refused, naming the environment\'s page', async () => { + const { registry, register } = await boot(); + registry.registerItem('page', recordPage('env_member', 'sys_user', { apps: ['duly'] })); + const err = await register(pkg('com.acme.duly', [recordPage('duly_member', 'sys_user', { apps: ['duly'] })])); + expectAppRefusal(err, { + object: 'sys_user', + app: 'duly', + incoming: pkgPage('com.acme.duly', 'duly_member'), + existing: { kind: 'environment', page: 'env_member' }, + }); + }); +}); + +describe('the default and an app\'s page are two slots', () => { + it('a package\'s app-scoped page registers beside another package\'s default for the same object', async () => { + const { registry, register } = await boot(); + expect(await register(pkg('com.acme.auth', [recordPage('sys_user_detail', 'sys_user', { isDefault: true })]))).toBeUndefined(); + expect(await register(pkg('com.acme.duly', [recordPage('duly_member', 'sys_user', { apps: ['duly'] })]))).toBeUndefined(); + expect(registry.defaultRecordPageHoldersOf('sys_user')).toEqual([pkgPage('com.acme.auth', 'sys_user_detail')]); + expect(registry.defaultRecordPageHoldersOf('sys_user', 'duly')).toEqual([pkgPage('com.acme.duly', 'duly_member')]); + }); + + it('a second default is still refused as a default, with the default\'s remedy', async () => { + const { register } = await boot(); + expect(await register(pkg('com.acme.auth', [recordPage('sys_user_detail', 'sys_user', { isDefault: true })]))).toBeUndefined(); + const err = await register(pkg('com.acme.duly', [recordPage('duly_member', 'sys_user', { isDefault: true, apps: ['duly'] })])); + expect({ code: err?.code, status: err?.status }).toEqual({ code: DEFAULT_RECORD_PAGE_CONFLICT_CODE, status: 422 }); + expect(err!.conflicts!.map((c) => c.app)).toEqual([undefined]); + expect(err!.message).toContain('Keep one default per object'); + expect(err!.message).not.toContain('Keep one page per object per app'); + }); +}); + +describe('`apps` on a page that is not a record page bound to an object', () => { + it('is refused at the package door with the parse\'s sentence', async () => { + const { register } = await boot(); + const err = await register(pkg('com.acme.duly', [{ name: 'duly_home', label: 'Home', type: 'home', apps: ['duly'] }])); + expect({ code: err?.code, status: err?.status }).toEqual({ code: MISPLACED_DEFAULT_PAGE_CODE, status: 422 }); + expect(err!.message).toContain("`apps` is refused on a `type: 'home'` page"); + }); + + it('control: the same home page without `apps` registers', async () => { + const { register } = await boot(); + expect(await register(pkg('com.acme.duly', [{ name: 'duly_home', label: 'Home', type: 'home' }]))).toBeUndefined(); + }); +}); From 97c78d2d49b6898382e4c8099d594c3d37f90bd2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 12:56:07 +0000 Subject: [PATCH 05/11] test(objectql): pin the per-app record page refusal at the metadata save door Claude-Session: https://claude.ai/code/session_01ADHkmSGMLCTCPxZfV3GqHr Co-authored-by: Claude --- .../src/default-record-page-save-door.test.ts | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/packages/objectql/src/default-record-page-save-door.test.ts b/packages/objectql/src/default-record-page-save-door.test.ts index a67f3618aaa..47e819504a0 100644 --- a/packages/objectql/src/default-record-page-save-door.test.ts +++ b/packages/objectql/src/default-record-page-save-door.test.ts @@ -342,3 +342,79 @@ describe.each(TOPOLOGIES)('%s kernel — the page parse at the save door, and ro expect((again as { error: any }).error?.status).toBe(422); }); }); + +// ── The app-scoped record page (`PageSchema.apps`, ruling A on #28979) ── +// +// The default's rule one level down: at most one record page per object PER +// APP, refused by the same door with the same code, naming both pages and the +// app. The default and an app's page are two slots and coexist. + +const DULY = 'com.acme.duly'; + +/** An app-scoped record page of `object`; not a default unless `extra` says so. */ +const scopedPage = (name: string, object: string, apps: string[], extra: Record = {}) => + recordPage(name, object, false, { apps, ...extra }); + +function expectAppRefusal(outcome: { ok: boolean; error?: any }, saved: string, object: string, app: string, holder: string) { + expect(outcome.ok, 'the save was refused').toBe(false); + const err = outcome.error; + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'DEFAULT_RECORD_PAGE_CONFLICT', status: 422 }); + expect({ object: err.object, app: err.app }).toEqual({ object, app }); + expect(String(err.message).split('. ')[0]).toBe( + `Default record page conflict: page '${saved}' cannot be saved as the record page of object ` + + `'${object}' in app '${app}': ${holder} already is`, + ); +} + +describe.each(TOPOLOGIES)('%s kernel — a page saved as a second record page of an object in one app', (topology) => { + it('is refused over a page a package ships for that app, naming both and the app, and stores nothing', async () => { + const { engine, save, storedPages } = await boot(topology); + engine.registerApp({ id: DULY, name: 'duly', pages: [scopedPage('duly_member', 'sys_user', ['duly'])] }); + const outcome = await save('env_member', scopedPage('env_member', 'sys_user', ['duly'])); + expectAppRefusal(outcome, 'env_member', 'sys_user', 'duly', `page 'duly_member' of package '${DULY}'`); + expect(String((outcome as { error?: any }).error?.message)).toContain( + "Save 'env_member' without 'duly' in `apps`: a page a package ships stays that app's page unless the " + + "deployment is composed without that package's page.", + ); + expect(storedPages()).toEqual([]); + }); + + it('is refused over a page the environment already stored for that app, naming it', async () => { + const { save, storedPages } = await boot(topology); + expect((await save('env_member_a', scopedPage('env_member_a', 'acme_task', ['duly']))).ok).toBe(true); + const outcome = await save('env_member_b', scopedPage('env_member_b', 'acme_task', ['crm', 'duly'])); + expectAppRefusal(outcome, 'env_member_b', 'acme_task', 'duly', "page 'env_member_a', authored in this system"); + expect(storedPages()).toEqual(['env_member_a']); + }); + + it('control: the same page scoped to another app is accepted', async () => { + const { engine, save, storedPages } = await boot(topology); + engine.registerApp({ id: DULY, name: 'duly', pages: [scopedPage('duly_member', 'sys_user', ['duly'])] }); + const outcome = await save('env_member', scopedPage('env_member', 'sys_user', ['crm'])); + expect(outcome.ok, String((outcome as any).error?.message)).toBe(true); + expect(storedPages()).toEqual(['env_member']); + }); + + it('control: an app\'s page is saved beside the package\'s default for the same object — two slots', async () => { + const { save } = await boot(topology); + const outcome = await save('env_org_member', scopedPage('env_org_member', 'acme_org', ['duly'])); + expect(outcome.ok, String((outcome as any).error?.message)).toBe(true); + }); + + it('control: a save under the app page\'s own name (an overlay by name) stays accepted', async () => { + const { save } = await boot(topology); + expect((await save('env_member_a', scopedPage('env_member_a', 'acme_task', ['duly']))).ok).toBe(true); + const again = await save('env_member_a', scopedPage('env_member_a', 'acme_task', ['duly'], { label: 'Renamed' })); + expect(again.ok, String((again as any).error?.message)).toBe(true); + }); + + it('`apps` on a home page is refused by the page parse, and nothing is stored', async () => { + const { save, storedPages } = await boot(topology); + const outcome = await save('env_home', { name: 'env_home', label: 'Home', type: 'home', apps: ['duly'], regions: [] }); + expect(outcome.ok).toBe(false); + const err = (outcome as { error: any }).error; + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'INVALID_METADATA', status: 422 }); + expect(String(err.message)).toContain("`apps` is refused on a `type: 'home'` page"); + expect(storedPages()).toEqual([]); + }); +}); From 5e2c74eb259187ba5d803c58035a65beaab928ea Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 12:58:48 +0000 Subject: [PATCH 06/11] chore: changeset for PageSchema.apps Claude-Session: https://claude.ai/code/session_01ADHkmSGMLCTCPxZfV3GqHr Co-authored-by: Claude --- .../28979-page-app-scoped-record-page.md | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .changeset/28979-page-app-scoped-record-page.md diff --git a/.changeset/28979-page-app-scoped-record-page.md b/.changeset/28979-page-app-scoped-record-page.md new file mode 100644 index 00000000000..0125539270e --- /dev/null +++ b/.changeset/28979-page-app-scoped-record-page.md @@ -0,0 +1,29 @@ +--- +'@objectstack/spec': minor +'@objectstack/objectql': minor +'@objectstack/metadata-protocol': minor +--- + +feat(spec): a record page can name the apps whose routes open it for its object (`PageSchema.apps`) + +Clause-②: yes (widening) +Metadata-protocol: added + +An app could not make its own record page over a platform object the page its routes open: the deployment's one `isDefault` record page (for `sys_user`, plugin-auth's `sys_user_detail`) opened everywhere, and declaring a second default is refused. A record page now takes an optional app scope: + +```ts +definePage({ + name: 'duly_member', + label: 'Member', + type: 'record', + object: 'sys_user', + apps: ['duly'], + regions: [/* … */], +}); +``` + +- **What it means.** Inside an app `apps` names, the object's record view (`/apps/APP/OBJECT/record/ID`) opens this page. Everywhere else, Setup included, the object's one `isDefault` record page still opens. One default per object per deployment is unchanged, and there is no precedence ladder: the default and an app's page are two slots, and one page may hold both. +- **One page per object per app.** Two record pages for the same object that both name the same app are refused at the doors that refuse a second default, naming both pages and the app: `defineStack` (and so `os validate` / `os build`) and `composeStacks` with `STACK_CROSS_REFERENCE_INVALID`, and package registration and the metadata save door with `DEFAULT_RECORD_PAGE_CONFLICT` (422). Saving a page under its own name stays an overlay of that page, as for the default. +- **Where it is accepted.** Only on a record page bound to an `object`, where `isDefault` is: on any other page `apps` is refused, by the page parse and at package registration, in one sentence. Entries are app names (lowercase snake_case), at least one, each once. +- **Declared ahead of the console.** The console's page assignment reads the route's app in a follow-up; until then every app still opens the default record page, exactly as before the key existed. +- **Readers.** `defaultRecordPageObject(page, app)` (`@objectstack/spec/ui`) answers the object a page is the record page of inside `app`; `SchemaRegistry.defaultRecordPageHoldersOf(object, app)` answers the holders of that slot; a `DefaultRecordPageConflictError` conflict and the save door's refusal carry `app` when the slot is an app's. From 4d11203c4e3bc9d7f199017314ee244b3e35cd15 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 13:20:31 +0000 Subject: [PATCH 07/11] test(metadata-protocol): page serves 28 top-level properties with apps Claude-Session: https://claude.ai/code/session_01ADHkmSGMLCTCPxZfV3GqHr Co-authored-by: Claude --- .../src/protocol.meta-types-degenerate-derivation.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts index 852fd0a05f0..160e79e5d54 100644 --- a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts +++ b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts @@ -368,9 +368,10 @@ describe('#17501 — /meta/types serves a real schema for `action`, and moves no // override declaration, on each overridable type. The excluded types // (`agent`, `field`, `hook`, `object`, `position`, `skill`, `tool`) do not // gain it, and their counts stay. + // [#28979] `page` 27 → 28: `apps`, the app scope of a record page. const CARD_PROPERTY_COUNTS: Record = { agent: 24, app: 24, dashboard: 21, dataset: 17, field: 81, flow: 23, - hook: 23, object: 49, page: 27, position: 13, report: 24, skill: 16, tool: 14, + hook: 23, object: 49, page: 28, position: 13, report: 24, skill: 16, tool: 14, }; it.each(Object.entries(CARD_PROPERTY_COUNTS))( From 0b4d78275f27e1a92561c35351c41133ce17abb7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 14:03:43 +0000 Subject: [PATCH 08/11] chore(spec): regenerate the authorable surface on the merged tree Claude-Session: https://claude.ai/code/session_01ADHkmSGMLCTCPxZfV3GqHr Co-authored-by: Claude --- packages/spec/authorable-surface/ui.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 2e14e9d3bea..d82b661c603 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -574,8 +574,8 @@ "ui/FormView:defaultTab", "ui/FormView:defaults", "ui/FormView:description", - "ui/FormView:drawerSide", - "ui/FormView:drawerWidth", + "ui/FormView:drawerSide [RETIRED]", + "ui/FormView:drawerWidth [RETIRED]", "ui/FormView:groups", "ui/FormView:layout", "ui/FormView:modalSize", From 2c39924f5f0c685afc5fde83accef8f87c98e923 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 16:23:27 +0000 Subject: [PATCH 09/11] chore(spec): regenerate the authorable surface on the merged tree Claude-Session: https://claude.ai/code/session_01ADHkmSGMLCTCPxZfV3GqHr Co-authored-by: Claude --- packages/spec/authorable-surface/ui.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index d82b661c603..a85857ad510 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -666,6 +666,7 @@ "ui/HttpRequest:url", "ui/InlineAction:bodyExtra", "ui/InlineAction:confirmText", + "ui/InlineAction:description", "ui/InlineAction:errorMessage", "ui/InlineAction:label", "ui/InlineAction:method", From 16ba5f22d7f8ecfbca715c1dbf13e1da041dcd6c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 18:34:14 +0000 Subject: [PATCH 10/11] chore(spec): regenerate the authorable surface on the merged tree Claude-Session: https://claude.ai/code/session_01ADHkmSGMLCTCPxZfV3GqHr Co-authored-by: Claude --- packages/spec/authorable-surface/ui.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index a85857ad510..a828749955b 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -1379,7 +1379,7 @@ "ui/ViewFilterRule:operator", "ui/ViewFilterRule:value", "ui/ViewSharing:lockedBy", - "ui/ViewSharing:type", + "ui/ViewSharing:type [RETIRED]", "ui/ViewTab:filter", "ui/ViewTab:icon", "ui/ViewTab:isDefault", From b634bbb5d6f14cca29e900ea58461b71722f68a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 18:41:00 +0000 Subject: [PATCH 11/11] chore(spec): recount the dropped-refinements header on the merged tree The merged body holds 998 sites; the three-way merge kept the 993 header both sides had written. Claude-Session: https://claude.ai/code/session_01ADHkmSGMLCTCPxZfV3GqHr Co-authored-by: Claude --- packages/spec/dropped-refinements.baseline.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index b2958559aaa..28c44baf555 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -3,7 +3,7 @@ "measured": { "zod": "4.4.3", "publishedSchemasWithDroppedRefinements": 248, - "droppedRefinementSites": 993, + "droppedRefinementSites": 998, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 },