From e3399c9918009b2399b88ad85b5c1ecb4da6970d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 05:02:27 +0000 Subject: [PATCH 01/11] =?UTF-8?q?wip(spec,objectql):=20one=20default=20rec?= =?UTF-8?q?ord=20page=20per=20object=20per=20deployment=20=E2=80=94=20stac?= =?UTF-8?q?k,=20compose=20and=20registry=20doors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude --- packages/objectql/src/default-record-page.ts | 134 +++++++++ packages/objectql/src/index.ts | 1 + packages/objectql/src/registry.ts | 280 +++++++++++++++++++ packages/spec/src/stack.zod.ts | 122 +++++++- packages/spec/src/ui/default-record-page.ts | 74 +++++ packages/spec/src/ui/index.ts | 4 + packages/spec/src/ui/page.zod.ts | 31 +- 7 files changed, 639 insertions(+), 7 deletions(-) create mode 100644 packages/objectql/src/default-record-page.ts create mode 100644 packages/spec/src/ui/default-record-page.ts diff --git a/packages/objectql/src/default-record-page.ts b/packages/objectql/src/default-record-page.ts new file mode 100644 index 00000000000..0ab6527df74 --- /dev/null +++ b/packages/objectql/src/default-record-page.ts @@ -0,0 +1,134 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * One default record page per object per deployment — the registry's half of + * the rule, and the vocabulary {@link SchemaRegistry}'s refusal of a second + * default is written in. + * + * ## The rule + * + * At most one `isDefault: true` record page may exist for an object in a + * deployment (`PageSchema.isDefault`; maintainer ruling A on objectstack#23015, + * record 6134056653). The renderer opens an object's record view on its + * default record page and honours `isDefault` with no tie-break, so two + * defaults for one object would leave the page to registration order — which + * plugin initialised first — an accident no author wrote. A second default is + * therefore refused at registration, naming both pages, the way ADR-0048's + * 2026-10-08 addendum refuses a second holder of a position, permission-set or + * capability name. + * + * "The default record page of object X" is `@objectstack/spec/ui`'s + * `defaultRecordPageObject` — the one reading `defineStack` (and so + * `os validate`) and the metadata save door ask as well, so the three doors + * cannot disagree about what a default record page is. + * + * ## The holders + * + * A holder is a PAGE — named, and for a packaged page, by its package too: + * + * - **a package's page** — a page a package registered under its own id + * (the registry's composite `PACKAGE:NAME` slot), or one it declared at + * install (its claim, recorded by `installPackage`, so a package installed + * without its items registered still holds what it declared); + * - **an environment's page** — a page authored in this environment rather + * than shipped by a package: the registry's bare slot. + * + * Two packages' pages under one name are two pages (ADR-0048 §3.4 keeps them + * apart, and both are served), so they are two holders. + * + * ## What is not a second default + * + * - **An overlay by name** (ADR-0005). An environment item under a page's own + * name stands in for that page — every by-name read takes the bare slot + * first — so it IS that page, whatever it changed. Its own body decides + * whether the page is a default; the slots it shadows decide nothing. A + * package registering a page under the name an environment item already + * holds is registering the page that item overlays. + * - **The same package again** — an idempotent reload, a re-install, a hot + * reload, an upgrade that moves the default from one of its pages to + * another. A package's own pages are judged at the authoring door + * (`defineStack`) and, within one manifest, at the package door below; a + * page an earlier registration of the same package left in the item store + * is that package's own residue, not another holder. + * - **A page that is not a default record page** — `isDefault` on a `home`, + * `app`, `utility` or `list` page, or on a record page bound to no object. + * + * ## What it deliberately does not judge + * + * - ⛔ A registration with no package — the bare slot every `sys_metadata` + * hydration and write-through performs. The metadata save door refuses a + * second default before it is stored; a stored row is not refused again + * when it hydrates. One consequence is stated rather than left implicit: at + * a cold boot every package registers BEFORE the environment's stored pages + * hydrate, so a stored default saved while no package held that object's + * default, and a package that adds one later, both reach the registry + * 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. + */ + +import { defaultRecordPageObject } from '@objectstack/spec/ui'; + +/** Who holds the default record page of an object: a page, and where it comes from. */ +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. */ + readonly object: string; + /** The page's name. */ + readonly page: string; +} + +/** + * The name a page is registered under — the engine seam's own derivation + * (`name`, else `id`). A page with neither is skipped there, so it declares + * nothing here. + */ +function pageName(item: unknown): string | undefined { + if (!item || typeof item !== 'object') return undefined; + const { name, id } = item as { name?: unknown; id?: unknown }; + if (typeof name === 'string' && name !== '') return name; + if (typeof id === 'string' && id !== '') return id; + return undefined; +} + +/** + * Every default record page a manifest declares, 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 + * declaration order; a page declared twice under one name is listed twice. + */ +export function declaredDefaultRecordPages(manifest: unknown): DeclaredDefaultRecordPage[] { + const out: DeclaredDefaultRecordPage[] = []; + const read = (source: unknown) => { + if (!source || typeof source !== 'object') return; + 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 }); + } + }; + read(manifest); + const plugins = (manifest as { plugins?: unknown } | null | undefined)?.plugins; + if (Array.isArray(plugins)) for (const plugin of plugins) read(plugin); + return out; +} + +/** How a refusal names a holder. */ +export function describeDefaultRecordPageHolder(holder: DefaultRecordPageHolder): string { + return holder.kind === 'package' + ? `page "${holder.page}" of package "${holder.packageId}"` + : `page "${holder.page}", authored in this environment`; +} + +/** A stable identity for de-duplicating holders. */ +export function defaultRecordPageHolderKey(holder: DefaultRecordPageHolder): string { + return holder.kind === 'package' ? `package:${holder.packageId}:${holder.page}` : `environment:${holder.page}`; +} diff --git a/packages/objectql/src/index.ts b/packages/objectql/src/index.ts index 658c8ad2810..06bae5ecb42 100644 --- a/packages/objectql/src/index.ts +++ b/packages/objectql/src/index.ts @@ -82,6 +82,7 @@ export { NAMESPACE_CONFLICT_CODE, DUPLICATE_ARTIFACT_OBJECT_NAME_CODE, OBJECT_OWNERSHIP_CONFLICT_CODE, + DEFAULT_RECORD_PAGE_CONFLICT_CODE, } from './registry.js'; // [#22371] The package half of the cold-boot check's holder reading (ADR-0048 diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index b44796831ba..24e268fc253 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -65,6 +65,15 @@ import { securityCatalogTypeLabel, type SecurityCatalogHolder, } from './security-catalog-namespace.js'; +// One default record page per object per deployment — the holders and the one +// place a manifest's default record pages are read from (module doc). +import { + declaredDefaultRecordPages, + defaultRecordPageHolderKey, + describeDefaultRecordPageHolder, + type DefaultRecordPageHolder, +} from './default-record-page.js'; +import { defaultRecordPageObject } 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 @@ -1540,6 +1549,15 @@ export const DUPLICATE_ARTIFACT_OBJECT_NAME_CODE = 'DUPLICATE_ARTIFACT_OBJECT_NA /** {@link ObjectOwnershipConflictError}'s code — ADR-0029 D3, single owner per name. */ export const OBJECT_OWNERSHIP_CONFLICT_CODE = 'OBJECT_OWNERSHIP_CONFLICT' as const; +/** + * {@link DefaultRecordPageConflictError}'s code — one default record page per + * object per deployment (maintainer ruling A on objectstack#23015). The save + * door in `@objectstack/metadata-protocol` stamps the same string for the same + * condition; that package cannot import this one, so it spells it once and the + * ledger lists it under both. + */ +export const DEFAULT_RECORD_PAGE_CONFLICT_CODE = 'DEFAULT_RECORD_PAGE_CONFLICT' as const; + /** * Raised when a package is installed whose `manifest.namespace` is already owned * by a **different** installed package in this installation (ADR-0048 Phase 1). @@ -1707,6 +1725,70 @@ export class SecurityCatalogNameConflictError extends Error { } } +/** One second default record page {@link DefaultRecordPageConflictError} refused. */ +export interface DefaultRecordPageConflict { + /** The object both pages are the default record page of. */ + readonly object: string; + /** The page whose registration this refusal stopped. */ + readonly incoming: DefaultRecordPageHolder; + /** The page that already holds the object's default record page. */ + readonly existing: DefaultRecordPageHolder; +} + +/** + * Raised when a package registers a record page as the default record page of + * an object another page already holds — another package's page, an + * environment's page, or a second page of the same manifest + * (`default-record-page.ts` states the rule, the holders, and what is not a + * second default). + * + * The ADR-0112 envelope (`code` + `status: 422`), the shape of its sibling + * {@link SecurityCatalogNameConflictError}, so a wire door answers `422`, never + * the `500` fallback. Its own code, not the namespace gate's: the condition is + * not a name already taken but an object's one default slot already held, and + * the remedy differs — keep one default (`isDefault: false` on the other), or + * compose the deployment without the package shipping the other page; renaming + * fixes nothing. + * + * Every conflict the registration carries is listed — `conflicts` — so one boot + * reports all of them; the top-level fields repeat the first. + */ +export class DefaultRecordPageConflictError extends Error { + readonly code = DEFAULT_RECORD_PAGE_CONFLICT_CODE; + readonly status = 422; + /** The same number under ADR-0112 D5's spelling — what a consumer holding the THROWN error reads (the CLI `--json` envelope). `status` stays for the HTTP doors, which read it. */ + readonly httpStatus = 422; + /** Every second default this registration would have created, in declaration order. */ + readonly conflicts: readonly DefaultRecordPageConflict[]; + /** The first conflict's object. */ + readonly object: string; + /** The first conflict's incoming page. */ + readonly incoming: DefaultRecordPageHolder; + /** The first conflict's existing holder. */ + readonly existing: DefaultRecordPageHolder; + + constructor(conflicts: readonly DefaultRecordPageConflict[]) { + 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 package that ` + + `ships the other. 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.`, + ); + this.name = 'DefaultRecordPageConflictError'; + this.conflicts = conflicts; + this.object = first.object; + this.incoming = first.incoming; + this.existing = first.existing; + } +} + /** * Every package-held position and permission-set name the environment catalog * also holds, read off `registry` — the conflicts the engine plugin's cold-boot @@ -2384,6 +2466,167 @@ export class SchemaRegistry { return owners ? Array.from(owners) : []; } + // ========================================== + // One default record page per object per deployment + // ========================================== + + /** + * Object → package → the page that package declared as the object's default + * record page at install, recorded by {@link installPackage} once its + * declarations pass {@link refuseDefaultRecordPageConflicts}, forgotten by + * {@link uninstallPackage}. + * + * The item store answers who holds an object's default only for pages + * something registered into it, and `installPackage` registers none (a bare + * install — `POST /api/v1/packages` — stores the manifest alone). The claim + * is how the package door remembers what a package declared, so a second + * package declaring a default for the same object is refused like any other + * second holder. Unlike the security catalog's claims a re-install REPLACES + * the package's claims: a default the package moved or dropped is no longer + * its declaration, and the pages an earlier registration left in the item + * store keep answering for themselves. + */ + private defaultRecordPageClaims = new Map>(); + + /** + * Every page holding the default record page of `object`, each tagged with + * whether it holds through the registry's bare slot (an environment item, + * which stands in for every slot of its name — module doc, "What is not a + * second default"). + * + * Read, per page name: the bare slot when there is one — its own body + * decides, and the composite slots and claims of that name are shadowed by + * it; otherwise every composite `PACKAGE:NAME` slot, and every install claim. + * A bare-slot item carrying a real package stamp is that package's page as + * overlaid in this environment (the protocol grafts the package's envelope + * 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 }> { + const found = new Map(); + const add = (holder: DefaultRecordPageHolder, bare: boolean) => { + found.set(defaultRecordPageHolderKey(holder), { holder, bare }); + }; + const bareNames = new Set(); + const composites: Array<{ packageId: string; page: string; item: unknown }> = []; + for (const [key, item] of this.metadata.get('page') ?? []) { + const sep = key.lastIndexOf(':'); + const stamped = (item as { _packageId?: unknown } | null | undefined)?._packageId; + if (sep < 0) { + bareNames.add(key); + if (defaultRecordPageObject(item) !== object) 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); + } else { + composites.push({ + packageId: typeof stamped === 'string' && stamped !== '' ? stamped : key.slice(0, sep), + page: key.slice(sep + 1), + item, + }); + } + } + for (const { packageId, page, item } of composites) { + if (bareNames.has(page) || defaultRecordPageObject(item) !== object) continue; + add({ kind: 'package', packageId, page }, false); + } + for (const [packageId, page] of this.defaultRecordPageClaims.get(object) ?? []) { + if (bareNames.has(page)) continue; + add({ kind: 'package', packageId, page }, false); + } + return [...found.values()]; + } + + /** + * Every page that holds the default record page of `object` in this + * registry — the reading both registration refusals judge by, and the + * registry half the metadata save door (`@objectstack/metadata-protocol`'s + * `saveMetaItem`) meets with the stored pages before it refuses a second + * default. Read-only: it records nothing and refuses nothing. + * + * 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}. + */ + defaultRecordPageHoldersOf(object: string): DefaultRecordPageHolder[] { + return this.defaultRecordPageSlots(object).map(({ holder }) => holder); + } + + /** + * The holders of `object`'s default record page that a package registering + * `page` as that default would meet as a SECOND default — every holder but + * 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) + .filter(({ holder, bare }) => { + if (holder.kind === 'package' && holder.packageId === packageId) return false; + return !(bare && holder.page === page); + }) + .map(({ holder }) => holder); + } + + /** + * The package door's half of the rule: refuse a package whose declared + * default record pages meet another holder — a second page of the same + * manifest for the same object, or a page another package or the environment + * holds — ahead of every mutation {@link installPackage} makes, so a refused + * package leaves no record, no namespace ownership and no claim behind. + * + * Every conflict is collected, so one refusal reports all of them. A package + * with no identity (`artifactPackageId` answers nothing) registers under no + * package at all and has nothing to hold; it is not judged here. + * + * @throws {DefaultRecordPageConflictError} ADR-0112 envelope, naming both + * pages of each conflict. + */ + private refuseDefaultRecordPageConflicts(manifest: ObjectStackManifest, selfId: string | undefined): void { + if (selfId === undefined) return; + const conflicts: DefaultRecordPageConflict[] = []; + // object → the first page this manifest declares for it. + const firstDeclared = new Map(); + for (const { object, page } of declaredDefaultRecordPages(manifest)) { + const incoming: DefaultRecordPageHolder = { kind: 'package', packageId: selfId, page }; + const first = firstDeclared.get(object); + if (first === undefined) { + firstDeclared.set(object, page); + for (const existing of this.otherDefaultRecordPageHolders(object, page, selfId)) { + conflicts.push({ object, incoming, existing }); + } + } else if (first !== page) { + // Two pages of one manifest for one object. 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 } }); + } + } + if (conflicts.length > 0) throw new DefaultRecordPageConflictError(conflicts); + } + + /** + * Replace `packageId`'s claims with what `manifest` declares (see + * {@link defaultRecordPageClaims} for why a re-install replaces). + */ + private recordDefaultRecordPageClaims(packageId: string, manifest: ObjectStackManifest): void { + this.forgetDefaultRecordPageClaims(packageId); + for (const { object, page } of declaredDefaultRecordPages(manifest)) { + let byPackage = this.defaultRecordPageClaims.get(object); + if (!byPackage) { + byPackage = new Map(); + this.defaultRecordPageClaims.set(object, byPackage); + } + if (!byPackage.has(packageId)) byPackage.set(packageId, page); + } + } + + /** Drop every default record page claim `packageId` holds. */ + private forgetDefaultRecordPageClaims(packageId: string): void { + for (const [object, byPackage] of this.defaultRecordPageClaims) { + byPackage.delete(packageId); + if (byPackage.size === 0) this.defaultRecordPageClaims.delete(object); + } + } + // ========================================== // Security catalog namespace — one name, one holder // ========================================== @@ -4246,6 +4489,30 @@ export class SchemaRegistry { } } + // One default record page per object per deployment — the item seam + // (maintainer ruling A on objectstack#23015; `default-record-page.ts`): a + // package registering a page as the default record page of an object + // another page already holds is refused BEFORE anything below stamps or + // stores it. The package door ({@link installPackage}) has already judged + // what a manifest declares; this is the seam every OTHER package-bound page + // registration reaches (a nested plugin's pages, a plugin registering + // through the registry directly). The package's own pages, and the + // environment's overlay of this very page by name, are not a second + // default. ⛔ A registration with no `packageId` is the bare slot — + // `sys_metadata` hydration, the metadata write-through — and is never + // judged here: the metadata save door refuses a second default before it + // is stored. + if (packageId && type === 'page') { + 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 }))); + } + } + } + // ADR-0010 §3.7 — translate the author-facing `protection` block // into the private `_lock` envelope and stamp package provenance. // This call knows the package id only. A caller that holds the package's @@ -5092,6 +5359,14 @@ export class SchemaRegistry { // `collisionPolicy` downgrade (`security-catalog-namespace.ts`). this.refuseSecurityCatalogNameConflicts(manifest, selfId); + // One default record page per object per deployment (maintainer ruling A + // on objectstack#23015; `default-record-page.ts`) — also ahead of every + // mutation, for the same reason. No co-owner exemption and no + // `collisionPolicy` downgrade: two packages of one artifact shipping a + // default for one object leave the record view to registration order + // exactly as two strangers do. + this.refuseDefaultRecordPageConflicts(manifest, selfId); + const now = new Date().toISOString(); // [#18877] 「缺省 = 保持,有旗 = 设置」 — an install that was not asked to move @@ -5169,6 +5444,8 @@ export class SchemaRegistry { // The names this package now holds, recorded whether or not an item store // entry will carry them (see `securityCatalogClaims`). if (selfId !== undefined) this.recordSecurityCatalogClaims(selfId, manifest); + // …and the default record pages it declares (see `defaultRecordPageClaims`). + if (selfId !== undefined) this.recordDefaultRecordPageClaims(selfId, manifest); this.log(`[Registry] Installed package: ${manifest.id} (${manifest.name})`); return pkg; } @@ -5267,6 +5544,9 @@ export class SchemaRegistry { // …and the security catalog names it held, so an uninstalled package's // names are free for the next package to declare. this.forgetSecurityCatalogClaims(id); + // …and the default record pages it declared, so the next package may + // declare a default for those objects. + this.forgetDefaultRecordPageClaims(id); // [#18877] The boot seed forgets the id together with the row. A package // that no longer exists has no lifecycle state to preserve, so the next diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index fe581a13ff6..286556d2e38 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -42,6 +42,7 @@ import { SeedSchema } from './data/seed.zod'; import { AppSchema } from './ui/app.zod'; import { ViewSchema, isAggregatedViewContainer } from './ui/view.zod'; import { deriveViewContainerObject } from './ui/view-container-object'; +import { defaultRecordPageObject } from './ui/default-record-page'; import { PageSchema } from './ui/page.zod'; import { DashboardSchema } from './ui/dashboard.zod'; import { ReportSchema } from './ui/report.zod'; @@ -2572,6 +2573,109 @@ function collectDuplicateEntryErrors(config: ObjectStackDefinition): string[] { return errors; } +/** + * Two or more DEFAULT RECORD PAGES for one object (#23015, maintainer ruling A, + * record 6134056653) — one refusal line per object, naming every page and where + * each sits. + * + * At most one `isDefault: true` record page may exist for an object in a + * deployment (`PageSchema.isDefault`): the renderer opens an object's record + * view on its default record page with no tie-break, so with two the choice + * would fall to registration order, which no author wrote. One stack is one + * package, so two defaults in it are two defaults in every deployment that + * installs it. "Default record page of object X" is the one reading + * {@link defaultRecordPageObject} gives — the same one the registry's package + * door and the metadata save door ask — so this door cannot pass a stack those + * doors refuse, nor refuse one they accept. + * + * Counted by distinct page NAME within one package: two entries under one name + * are the duplicate {@link collectDuplicateEntryErrors} already refuses, and are + * not reported a second time here. An entry carrying a `holder` (the package it + * lands in) is counted by that package and name, since two packages' pages + * under one name are two pages (ADR-0048 §3.4). The object need not be one this stack defines (a stack may + * ship the record page of a platform object), so this runs before the + * object-count early return. + * + * `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. + */ +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>(); + 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 errors: string[] = []; + for (const [object, pages] of byObject) { + if (pages.size < 2) continue; + const named = [...pages.values()].map(({ name, origins }) => `'${name}' (${joinDeclarationOrigins(origins)})`); + 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.`, + ); + } + return errors; +} + +/** The one-stack door: {@link duplicateDefaultRecordPageFindings} over `stack.pages`. */ +function collectDuplicateDefaultRecordPageErrors(config: ObjectStackDefinition): string[] { + const pages: unknown = config.pages; + if (!Array.isArray(pages)) return []; + return duplicateDefaultRecordPageFindings( + pages.map((page, i) => ({ page, origin: `stack.pages[${i}]` })), + '`pages`', + ); +} + +/** + * The composition door: {@link duplicateDefaultRecordPageFindings} over every + * INPUT stack's `pages` together. Each input passed the one-stack door on its + * own, and the pages they bring together land in one deployment — as one + * package (the default manifest strategies, which concatenate `pages`) or as + * several packages of one artifact (`manifest: 'preserve'`), where the + * registry's package door would refuse the boot. Refused here first, in the + * cross-reference envelope, so `os validate` and `os build` of a composed + * project say what the boot would. An input whose `pages` is not an array is + * skipped here: the concat pass refuses it by its own rule. + */ +function collectComposedDefaultRecordPageErrors( + stacks: readonly ObjectStackDefinition[], + preserve: boolean, +): string[] { + const entries: Array<{ page: unknown; origin: string; holder?: string }> = []; + for (const [i, stack] of stacks.entries()) { + const pages: unknown = stack.pages; + if (!Array.isArray(pages)) continue; + for (const [j, page] of pages.entries()) { + entries.push({ + page, + origin: `${stackLabel(stack, i)} pages[${j}]`, + // Under `preserve` each input stays its own package, so a page name two + // inputs share is two pages; merged, it is one package's one name. + ...(preserve ? { holder: String(i) } : {}), + }); + } + } + return duplicateDefaultRecordPageFindings(entries, 'the composed stacks'); +} + /** * [ADR-0112 · #15963] The envelope every refusal `defineStack` raises shares: * `status: 422` — an unprocessable authored entity, not a server fault, the @@ -3311,6 +3415,9 @@ function validateCrossReferences( // Same placement, same reason: two entries of one named collection under one // identity occupy one registry slot whether or not there are objects. errors.push(...collectDuplicateEntryErrors(config)); + // Same placement: two default record pages for one object are two defaults + // whether or not this stack defines that object (#23015). + errors.push(...collectDuplicateDefaultRecordPageErrors(config)); // Same placement, same reason: a global `operation: 'update'` action is // wrong whether or not the stack declares any objects. errors.push(...collectGlobalUpdateActionErrors(config)); @@ -6033,10 +6140,17 @@ function composeBuiltStacks( // and cannot newly refuse: an input that passed the strict parse cannot // newly fail, an input that bypassed it is checked here for the first // time. - const artifactCrossRefErrors = collectArtifactCrossReferenceErrors( - stacks, - objects as readonly { name: string }[] | undefined, - ); + // [#23015] …and two default record pages for one object across the + // inputs, each legal on its own: one deployment holds them both. It needs + // no object to resolve against, so it is not behind that pass's + // object-count early return. + const artifactCrossRefErrors = [ + ...collectArtifactCrossReferenceErrors( + stacks, + objects as readonly { name: string }[] | undefined, + ), + ...collectComposedDefaultRecordPageErrors(stacks, opts.manifest === 'preserve'), + ]; if (artifactCrossRefErrors.length > 0) { const count = artifactCrossRefErrors.length; const header = `composeStacks artifact cross-reference validation failed (${count} issue${count === 1 ? '' : 's'}):`; diff --git a/packages/spec/src/ui/default-record-page.ts b/packages/spec/src/ui/default-record-page.ts new file mode 100644 index 00000000000..5530aac923d --- /dev/null +++ b/packages/spec/src/ui/default-record-page.ts @@ -0,0 +1,74 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The one spelling of "which object is this page the default record page of" — + * a pure module that imports nothing. + * + * ## The rule it serves + * + * At most one `isDefault: true` record page may exist for an object in a + * deployment (`PageSchema.isDefault`). The renderer opens an object's record + * view on that page and on nothing else, with no tie-break between two + * defaults: registration order, package order and declaration order decide + * nothing. A second default record page for the same object is therefore + * refused, naming both pages, at each door that can add one: + * + * - **one stack** — `defineStack`'s cross-reference pass (and so `os validate`, + * `os build` and a source boot, which all load the stack through it), and + * `composeStacks` over the pages its inputs bring together; + * - **package registration** — `@objectstack/objectql`'s `SchemaRegistry`, at + * the package door (`installPackage`) and at the item seam + * (`registerItem`), against every other holder in the registry; + * - **metadata save** — `@objectstack/metadata-protocol`'s `saveMetaItem`, + * against the stored pages and the pages packages ship. + * + * Replacing an existing default by saving a page under ITS name (an ADR-0005 + * 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.) + * + * ## Why it lives in the spec + * + * The three doors live in three packages, and the stack contract cannot import + * either runtime package. A door that read "default record page" its own way + * could refuse a page another door accepts, or accept the pair another door + * refuses — `os validate` passing a stack the boot then refuses is the shape + * one judge read by every door exists to prevent. So the reading is here, once, + * and every door calls it. + */ + +/** + * The page type a page carries when it declares none. `PageSchema` declares + * its `type` key with this default (`page.zod.ts` reads this constant for it), + * so a page authored without `type` IS a record page, and an unparsed body — + * a registry item, a stored row — that leaves the key out is judged as one. + * One constant, so the schema's default and this reading cannot part. + */ +export const PAGE_TYPE_DEFAULT = 'record' as const; + +/** + * The object `page` is the default record page of, or `undefined` when it is + * not a default record page of any object. + * + * A page is the default record page of object `X` when all three hold: + * + * - `isDefault` is `true` (exactly the boolean — the key's declared type); + * - its `type` is `'record'`, or absent ({@link PAGE_TYPE_DEFAULT}); + * - its `object` names `X` (a non-empty string). + * + * A page that is `isDefault: true` but is not a record page (a `home`, `app`, + * `utility` or `list` page), or a record page bound to no object, is outside + * the rule and answers `undefined`. Only declared keys are read: an undeclared + * spelling (`default`, `objectName`, `pageType`) is refused by the page schema + * itself and decides nothing here. + * + * 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 { + if (!page || typeof page !== 'object' || Array.isArray(page)) return undefined; + const p = page as Record; + if (p.isDefault !== true) 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; +} diff --git a/packages/spec/src/ui/index.ts b/packages/spec/src/ui/index.ts index 8d7292bc949..995ecebdb48 100644 --- a/packages/spec/src/ui/index.ts +++ b/packages/spec/src/ui/index.ts @@ -24,6 +24,10 @@ export * from './view.zod'; // [#22934] Which object an aggregated `views` container binds to (moved from // `@objectstack/metadata`, which re-exports it under the same name). export * from './view-container-object'; +// [#23015] Which object a page is the default record page of — the one +// reading the stack contract, the registry and the metadata save door judge +// "one default record page per object per deployment" by. +export { defaultRecordPageObject } from './default-record-page'; // [#14556] List-view grouping is SERVER-SIDE (ruling A on objectui#7189): the // group header aggregate query and the per-group row page, compiled from the // view's `grouping` + column summaries + composed filter in the query AST's diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index 62efbb6f9da..5ad67683d54 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -23,6 +23,7 @@ import { lazySchema } from '../shared/lazy-schema'; import { strictObject } from '../shared/strict-object'; import { ruleArrayFilterError } from './filter-rule-array'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; +import { PAGE_TYPE_DEFAULT } from './default-record-page'; /** * Shared history for this file (#4001). @@ -1504,7 +1505,7 @@ export const PageSchema = lazySchema(() => strictObject({ icon: z.string().optional().describe('Page icon name'), /** Page Type */ - type: PageTypeSchema.default('record').describe('Page type'), + type: PageTypeSchema.default(PAGE_TYPE_DEFAULT).describe('Page type'), /** * Page-local state variables (ADR-0049). Runtime-live: the renderer mounts the @@ -1539,8 +1540,32 @@ export const PageSchema = lazySchema(() => strictObject({ regions: z.array(PageRegionSchema).optional().default([]) .describe('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.'), - /** Activation */ - isDefault: z.boolean().default(false), + /** + * Activation — whether this page is the DEFAULT RECORD PAGE of its `object`: + * the page the record view of that object opens. + * + * **At most one default record page per object per deployment.** A record + * page (`type: 'record'`, the default) bound to an `object` and declared + * `isDefault: true` is the one the renderer opens for that object's records, + * with no tie-break: two defaults for one object would leave the choice to + * registration order, which no author wrote. So a second is refused, and the + * refusal names both pages — by `defineStack` within one stack (and so by + * `os validate` and `os build`), by `composeStacks` across the stacks it + * composes, at package registration across packages, and at metadata save. + * Saving a page under the existing default's own name (an ADR-0005 overlay + * by name) is that same page, not a second default. (Maintainer ruling A on + * objectstack#23015, record 6134056653.) + * + * To change which page opens: set `isDefault: false` on one of the two, or + * compose the deployment without the package that ships the other. Shipping + * a second default is not an override path. + * + * `isDefault` on a page that is not a record page bound to an object is + * outside this rule. The one reading every door uses is + * `defaultRecordPageObject` (`default-record-page.ts`). + */ + 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. Outside this rule for a page that is not a record page bound to an object.'), // `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 bca4349daf7fbad02a600ee5273420d9320518af Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 05:04:28 +0000 Subject: [PATCH 02/11] wip(metadata-protocol,spec): save-door refusal, ledger rows, D3 entry Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 126 +++++++++++++++++- .../spec/src/api/error-code-ledger.zod.ts | 24 ++++ ...ault-record-page-one-per-object-refused.ts | 45 +++++++ 3 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 packages/spec/src/migrations/entries/semantic/18.page-default-record-page-one-per-object-refused.ts diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index a6f3c3bb300..4a286877932 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -162,7 +162,7 @@ import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL, canonicalMetaUrlType, metaUrlSp // live transport in through `attachMetadataMutationPubSub`. import type { IObjectQLEngine, IPubSub, ISecurityService } from '@objectstack/spec/contracts'; import { applyConversionsToFlow, applyConversionsToStoredItem, type ConversionNotice, type ConversionTodoNotice } from '@objectstack/spec'; -import { type FormView, type I18nLabel, isAggregatedViewContainer, expandViewContainer, resolveI18nLabel } from '@objectstack/spec/ui'; +import { type FormView, type I18nLabel, isAggregatedViewContainer, expandViewContainer, resolveI18nLabel, defaultRecordPageObject } from '@objectstack/spec/ui'; // [commit ece4dad31] Emitted-specifier pin. This module's inferred public declarations // structurally mention `FormFieldInput` (FormView `sections[].fields`), and // this file imports BOTH `@objectstack/spec` (root, for @@ -20140,6 +20140,117 @@ export class ObjectStackProtocolImplementation implements } } + /** + * [#23015] The save door's half of "one default record page per object per + * deployment" (maintainer ruling A, record 6134056653; `PageSchema.isDefault`). + * + * A `page` body that is the default record page of an object + * (`defaultRecordPageObject`, the one reading `defineStack` and the + * registry judge by too) is refused when another page already holds that + * object's default. The holders are the two sources a deployment serves + * pages from, judged by name: + * + * - **the stored pages** — the environment-wide active rows + * ({@link readActiveOverlayRows}, parsed by {@link storedOverlayEntries}), + * a row bound to a package naming that package; + * - **the registry's pages** — `SchemaRegistry.defaultRecordPageHoldersOf` + * (the reading the registration refusals judge by: packaged pages, their + * install claims, and the environment items written through), for every + * name no stored row holds. A stored row under a name stands in for + * every registry slot of that name, as it does on every read + * (ADR-0005), so its own body decides for the name. + * + * The page saved under `saveName` is never its own holder: a save under the + * existing default's own name is an overlay of that page (ADR-0005), not a + * second default, and stays accepted. A registry with no such reading (a + * host engine that is not `SchemaRegistry`) contributes no packaged holder; + * the stored rows are still judged. + * + * An UNPROVISIONED store counts as "no stored pages"; any other read + * failure propagates ({@link rethrowUnlessMetadataStoreUnprovisioned}): + * answering "no other default" from a read that never happened would + * accept the very second default this exists to refuse. + * + * `DEFAULT_RECORD_PAGE_CONFLICT` / 422 — the envelope the registry's + * registration refusal carries for the same condition, so a client reads + * one code whether the second default arrived by a save or by an install. + * The message names this page and the holder, then the prescription, then + * why: a 4xx message crosses the REST boundary truncated at its tail. ⛔ It + * never prescribes overlaying a page a package ships by name: the save door + * refuses that write (`NOT_OVERRIDABLE`). Runtime words carry no tracker + * number. + */ + private async defaultRecordPageSaveRefusal( + type: string, + item: unknown, + saveName: string, + ): Promise<(Error & { code: 'DEFAULT_RECORD_PAGE_CONFLICT'; status: 422 }) | undefined> { + if (type !== 'page') return undefined; + const object = defaultRecordPageObject(item); + if (object === undefined) 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 + // are the environment-wide rows. + records = await this.readActiveOverlayRows({ type }, undefined); + } catch (error) { + this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata'); + } + 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 }); + } + } + 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 environment`; + 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.` + : `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 saved 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; + } + /** * [#21639] The save door's ONE collision predicate for a view container: * a container is refused when its save name, or any name its expansion @@ -21145,6 +21256,19 @@ export class ObjectStackProtocolImplementation implements if (hookRefusal) throw hookRefusal; } + // One default record page per object per deployment (maintainer ruling + // A on objectstack#23015): a page saved as the default record page of an + // object another page already holds — a stored page, or a page a + // package ships — is refused, naming both, in draft and in publish mode, + // after the schema (the body is a page) and before the authoring gate + // and every write. A save under the existing default's own name is an + // overlay of that page, not a second default. See + // {@link defaultRecordPageSaveRefusal}. + { + const defaultPageRefusal = await this.defaultRecordPageSaveRefusal(singularType, request.item, request.name); + if (defaultPageRefusal) throw defaultPageRefusal; + } + // The #4463 runtime authoring gate — the shared author-time rule // registry, on the write path. `active` saves only (D1): this is the // publish verb, and it is the same table `os build` gates on. Placed diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 0c7fc62dac8..373a846df7c 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -606,6 +606,15 @@ export const ERROR_CODE_LEDGER = { 'COMMIT_NOT_FOUND', 'CONCURRENT_UPDATE', 'DESTRUCTIVE_CHANGE', // change would drop data; needs an explicit opt-in + // [#23015] The save door's half of "one default record page per object per + // deployment" (maintainer ruling A, record 6134056653): `saveMetaItem` + // refuses a `page` body that is the default record page of an object a + // stored page or a packaged page already holds, naming both, 422 — the + // same code and status `@objectstack/objectql`'s registration refusal + // stamps for the same condition (that package depends on this one, so the + // string is spelled in each). Served by the `/meta` write doors through + // `errorFromThrown`. + 'DEFAULT_RECORD_PAGE_CONFLICT', // [#9567] ADR-0078's rename guard refuses a metadata write whose `type`/ // `name` token is already a LIVE name owned by something else in the // environment — persisting the un-renamed body would mint exactly the row @@ -891,6 +900,21 @@ export const ERROR_CODE_LEDGER = { // carries; `DUPLICATE_RECORD` / `DUPLICATE_VALUE` name a different // condition and share only the first token). 'DUPLICATE_ARTIFACT_OBJECT_NAME', + // [#23015] One default record page per object per deployment (maintainer + // ruling A, record 6134056653): a package registering a record page as the + // default record page of an object another page already holds — another + // package's page, the environment's, or a second page of the same manifest + // — is refused at the package door (`SchemaRegistry.installPackage`, ahead + // of every mutation) and at the item seam (`registerItem`), naming both + // pages (`DefaultRecordPageConflictError`, `registry.ts`; `status: 422`; + // the exported `DEFAULT_RECORD_PAGE_CONFLICT_CODE`). Wire-reachable: the + // two HTTP install doors call `installPackage`. Its own code rather than + // `NAMESPACE_CONFLICT`: no name is taken — an object's one default slot is + // held — and renaming fixes nothing; the remedy is `isDefault: false` on + // one page, or composing the deployment without the other package. Not a + // synonym of any standard member: the manifest is well-formed, and the + // refusal comes from what the deployment already holds. + 'DEFAULT_RECORD_PAGE_CONFLICT', ], '@objectstack/core': [ 'ERR_BULK_RESULT_MISMATCH', diff --git a/packages/spec/src/migrations/entries/semantic/18.page-default-record-page-one-per-object-refused.ts b/packages/spec/src/migrations/entries/semantic/18.page-default-record-page-one-per-object-refused.ts new file mode 100644 index 00000000000..e7ef5dc7a15 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.page-default-record-page-one-per-object-refused.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #23015, maintainer ruling A (record 6134056653). An accept-set narrowing: a +// second isDefault: true record page for one object is refused — by +// defineStack (and so os validate and os build) within one stack, by +// composeStacks across its inputs, at package registration across packages, +// and at metadata save. No D2 conversion: which page stays the default is the +// author's call. No relevance question either: the surface reaches past the +// stack's own keys, to pages a plugin registers in code and to stored rows. +export const entry: SemanticMigration = { + id: 'page-default-record-page-one-per-object-refused', + // No backticks in `surface` — build-upgrade-guide renders it inside a code + // span already, and a nested backtick would close it. + surface: + 'pages[] — two or more record pages (type record, the default) bound to one object and both declared ' + + 'isDefault: true, within one stack, across the stacks composeStacks composes, across the packages one ' + + 'deployment registers (manifest pages and nested plugins[].pages, including pages a plugin registers ' + + 'through manifest.register), or between a page saved through the metadata API and a page already stored ' + + 'or shipped', + replacement: + 'One default record page per object per deployment: keep isDefault: true on the page that should open the ' + + 'record view and set isDefault: false on every other record page of that object. To replace a page a ' + + 'package ships, compose the deployment without that package\'s page (host composition); to replace a page ' + + 'authored in the environment, save it again under its own name — a save under the existing default\'s ' + + 'name is that page, not a second default. Shipping a second default is not an override path. isDefault ' + + 'on a page that is not a record page bound to an object is unchanged.', + reason: + 'The renderer opens an object\'s record view on its default record page and honours isDefault with no ' + + 'tie-break, and nothing decided between two defaults for one object: the page that opened was whichever ' + + 'plugin initialised first, an accident no author wrote and a refactor could flip silently. The rule now ' + + 'stated on PageSchema.isDefault is refused at every door that can add a second default, naming both ' + + 'pages: defineStack and composeStacks refuse with STACK_CROSS_REFERENCE_INVALID (422); package ' + + 'registration (SchemaRegistry.installPackage and registerItem) and the metadata save door (saveMetaItem) ' + + 'refuse with DEFAULT_RECORD_PAGE_CONFLICT (422). Not convertible: which of the two pages stays the default ' + + 'is a fact only the author holds.', + acceptanceCriteria: + 'os validate and os build load the stack without a finding of the form "Object X has two default record ' + + 'pages", the deployment boots without DEFAULT_RECORD_PAGE_CONFLICT, and each object\'s record view opens ' + + 'the page you kept as its default — check it, because before this change the page that opened was decided ' + + 'by registration order and may not be the one you keep. Stored pages that already form a pair keep ' + + 'serving; the next save of the one that is not kept as the default is refused until it carries ' + + 'isDefault: false.', +}; From 49f5f6583a8b9cf8eaefb4c6cebb0f9494839566 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 05:18:29 +0000 Subject: [PATCH 03/11] test(spec): pin the one-stack and composition doors for default record pages Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude --- .../src/stack-default-record-page.test.ts | 214 ++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 packages/spec/src/stack-default-record-page.test.ts diff --git a/packages/spec/src/stack-default-record-page.test.ts b/packages/spec/src/stack-default-record-page.test.ts new file mode 100644 index 00000000000..7b9f34ab033 --- /dev/null +++ b/packages/spec/src/stack-default-record-page.test.ts @@ -0,0 +1,214 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * One default record page per object per deployment — the stack contract's + * door (maintainer ruling A on #23015, record 6134056653). + * + * `defineStack` (and so `os validate`, `os build` and a source boot, which all + * load a stack through it) refuses two `isDefault: true` record pages for one + * object inside one stack, and `composeStacks` refuses the pair across the + * stacks it composes. Both name every page and where it sits, in the + * cross-reference envelope. + * + * Refusals assert the ADR-0112 envelope (`code` + `status`), the issue count + * and the named subjects — the object and both pages with their positions — + * never a bare `toThrow()`, which a refusal for any other reason would satisfy. + * Every refusal row's CONTROL differs from it by one key alone. + * + * `defaultRecordPageObject` is the one reading the registry and the metadata + * save door ask too, so its own cases are pinned here. + */ +import { describe, it, expect } from 'vitest'; +import { defineStack, composeStacks } from './stack.zod'; +import { defaultRecordPageObject } from './ui/default-record-page'; +import { PageSchema } from './ui/page.zod'; + +type Refusal = Error & { code?: string; status?: number; issues?: readonly string[] }; + +const manifest = (id: string) => ({ + id, + namespace: id.split('.').pop()!.replace(/-/g, ''), + name: id.split('.').pop()!, + version: '1.0.0', + type: 'module' as const, +}); + +const task = { + name: 'drp_task', + label: 'Task', + sharingModel: 'private' as const, + fields: { title: { type: 'text' as const, label: 'Title' } }, +}; + +const project = { ...task, name: 'drp_project', label: 'Project' }; + +/** A record page; `isDefault` and `object` are the only keys a case varies. */ +const recordPage = (name: string, object: string, isDefault: boolean, extra: Record = {}) => ({ + name, + label: name, + type: 'record' as const, + object, + isDefault, + 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.drp') => + ({ manifest: manifest(id), objects: [task, project], pages }) as any; + +describe('defaultRecordPageObject — the one reading every door asks', () => { + it('names the object of an isDefault record page', () => { + expect(defaultRecordPageObject(recordPage('p', 'drp_task', true))).toBe('drp_task'); + }); + + it('reads an absent `type` as the declared default, record', () => { + const { type: _omit, ...untyped } = recordPage('p', 'drp_task', true); + expect(defaultRecordPageObject(untyped)).toBe('drp_task'); + // The constant it reads is the one the schema's default is built from. + expect(PageSchema.parse({ name: 'probe_page', label: 'Probe' }).type).toBe('record'); + }); + + it.each([ + ['isDefault false', recordPage('p', 'drp_task', false)], + ['isDefault absent', (() => { const { isDefault: _o, ...rest } = recordPage('p', 'drp_task', true); return rest; })()], + ['isDefault a truthy non-boolean', { ...recordPage('p', 'drp_task', true), isDefault: 'true' }], + ['a home page', { ...recordPage('p', 'drp_task', true), type: 'home' }], + ['a list page', { ...recordPage('p', 'drp_task', true), type: 'list' }], + ['a record page bound to no object', (() => { const { object: _o, ...rest } = recordPage('p', 'x', true); return rest; })()], + ['an empty object', recordPage('p', '', true)], + ['not an object', 'p'], + ['null', null], + ])('answers undefined for %s', (_label, page) => { + expect(defaultRecordPageObject(page)).toBeUndefined(); + }); + + it('reads no undeclared spelling — `default` and `objectName` decide nothing', () => { + expect(defaultRecordPageObject({ name: 'p', type: 'record', objectName: 'drp_task', default: true })).toBeUndefined(); + }); +}); + +describe('defineStack — two default record pages for one object in one stack', () => { + it('is refused, naming the object, both pages and their positions', () => { + const err = refusalOf(() => + defineStack(stack([recordPage('task_a', 'drp_task', true), recordPage('task_b', 'drp_task', true)])), + ); + 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 'drp_task' has two default record pages in `pages`: 'task_a' \(stack\.pages\[0\]\) and 'task_b' \(stack\.pages\[1\]\)\. Set `isDefault: false` on all but one of them\./, + ); + expect(err!.message).toContain('defineStack cross-reference validation failed (1 issue):'); + }); + + it('control: the same two pages with one of them not a default is accepted', () => { + expect(() => + defineStack(stack([recordPage('task_a', 'drp_task', true), recordPage('task_b', 'drp_task', false)])), + ).not.toThrow(); + }); + + it('control: two objects with one default record page each is accepted', () => { + expect(() => + defineStack(stack([recordPage('task_a', 'drp_task', true), recordPage('project_a', 'drp_project', true)])), + ).not.toThrow(); + }); + + it('control: an isDefault page that is not a record page is outside the rule', () => { + expect(() => + defineStack(stack([recordPage('task_a', 'drp_task', true), { name: 'task_home', label: 'Home', type: 'home', isDefault: true }])), + ).not.toThrow(); + }); + + it('a page that leaves `type` out is a record page, and counts', () => { + const { type: _omit, ...untyped } = recordPage('task_b', 'drp_task', true); + const err = refusalOf(() => defineStack(stack([recordPage('task_a', 'drp_task', true), untyped]))); + expect(err?.code).toBe('STACK_CROSS_REFERENCE_INVALID'); + expect(err!.issues![0]).toContain("'task_a' (stack.pages[0]) and 'task_b' (stack.pages[1])"); + }); + + it('three defaults are one finding naming all three', () => { + const err = refusalOf(() => + defineStack(stack([ + recordPage('task_a', 'drp_task', true), + recordPage('task_b', 'drp_task', true), + recordPage('task_c', 'drp_task', true), + ])), + ); + expect(err!.issues).toHaveLength(1); + expect(err!.issues![0]).toContain("has 3 default record pages in `pages`: 'task_a' (stack.pages[0]), 'task_b' (stack.pages[1]) and 'task_c' (stack.pages[2])"); + }); + + it('the object need not be one the stack defines — a platform object\'s record page counts', () => { + const err = refusalOf(() => + defineStack({ + manifest: manifest('com.example.drp-platform'), + pages: [recordPage('org_a', 'sys_organization', true), recordPage('org_b', 'sys_organization', true)], + } as any), + ); + expect(err?.code).toBe('STACK_CROSS_REFERENCE_INVALID'); + expect(err!.issues![0]).toMatch(/^Object 'sys_organization' has two default record pages/); + }); + + it('two entries under ONE name are the duplicate-entry finding only, not a second default', () => { + const err = refusalOf(() => + defineStack(stack([recordPage('task_a', 'drp_task', true), recordPage('task_a', 'drp_task', true)])), + ); + expect(err?.code).toBe('STACK_CROSS_REFERENCE_INVALID'); + expect(err!.issues).toHaveLength(1); + expect(err!.issues![0]).toMatch(/^Page 'task_a' is declared twice in `pages`/); + }); + + it('`strict: false` skips it, like every cross-reference check', () => { + expect(() => + defineStack(stack([recordPage('task_a', 'drp_task', true), recordPage('task_b', 'drp_task', true)]), { strict: false }), + ).not.toThrow(); + }); +}); + +describe('composeStacks — two default record pages for one object across the stacks it composes', () => { + // Object-less stacks: the pages bind an object neither declares (a platform + // object's record page is the live case), which the rule never needs. + const a = () => defineStack({ manifest: manifest('com.example.drp-a'), pages: [recordPage('task_a', 'drp_task', true)] } as any); + const b = (page = recordPage('task_b', 'drp_task', true)) => + defineStack({ manifest: manifest('com.example.drp-b'), pages: [page] } as any); + + it('is refused, naming both stacks\' pages', () => { + const err = refusalOf(() => composeStacks([a(), b()])); + expect(err, 'the composition 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]).toContain( + "Object 'drp_task' has two default record pages in the composed stacks: " + + "'task_a' ('com.example.drp-a' (stack #0) pages[0]) and 'task_b' ('com.example.drp-b' (stack #1) pages[0])", + ); + expect(err!.message).toContain('composeStacks artifact cross-reference validation failed (1 issue):'); + }); + + it('is refused under `manifest: \'preserve\'` too — each stack a package of one artifact', () => { + const err = refusalOf(() => composeStacks([a(), b()], { manifest: 'preserve' })); + expect(err?.code).toBe('STACK_CROSS_REFERENCE_INVALID'); + }); + + it('under `preserve`, one page name in two stacks is two packages\' pages, and is refused', () => { + const err = refusalOf(() => composeStacks([a(), b(recordPage('task_a', 'drp_task', true))], { manifest: 'preserve' })); + expect(err?.code).toBe('STACK_CROSS_REFERENCE_INVALID'); + expect(err!.issues![0]).toContain("'task_a' ('com.example.drp-a' (stack #0) pages[0]) and 'task_a' ('com.example.drp-b' (stack #1) pages[0])"); + }); + + it('control: the second stack\'s page not a default composes', () => { + expect(() => composeStacks([a(), b(recordPage('task_b', 'drp_task', false))])).not.toThrow(); + }); + + it('control: one default per object across the stacks composes', () => { + expect(() => composeStacks([a(), b(recordPage('project_b', 'drp_project', true))])).not.toThrow(); + }); +}); From 31aa2be22b3ae3b54f11ba37040a66365dbd04c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 05:20:25 +0000 Subject: [PATCH 04/11] test(objectql): pin the registry and save doors for default record pages Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude --- .../src/default-record-page-save-door.test.ts | 258 ++++++++++++++++++ .../src/registry-default-record-page.test.ts | 245 +++++++++++++++++ 2 files changed, 503 insertions(+) create mode 100644 packages/objectql/src/default-record-page-save-door.test.ts create mode 100644 packages/objectql/src/registry-default-record-page.test.ts diff --git a/packages/objectql/src/default-record-page-save-door.test.ts b/packages/objectql/src/default-record-page-save-door.test.ts new file mode 100644 index 00000000000..219d07ff6ac --- /dev/null +++ b/packages/objectql/src/default-record-page-save-door.test.ts @@ -0,0 +1,258 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * One default record page per object per deployment — the metadata save door + * (maintainer ruling A on #23015, record 6134056653; `saveMetaItem`'s + * `defaultRecordPageSaveRefusal`). + * + * A `page` saved as the default record page of an object another page already + * holds — a page a package ships, or a page already stored — is refused 422 + * `DEFAULT_RECORD_PAGE_CONFLICT`, naming both, and nothing is stored. Run on + * the real engine, registry and protocol over an in-memory driver, on both + * topologies: a host-config kernel writes a saved page through into the + * registry, an environment-scoped kernel does not, so there the stored rows are + * the only record of the environment's pages and the door must read them. + * + * The controls keep the refusal as narrow as the ruling: a save under the + * existing default's own name (an overlay by name) stays accepted; so do a + * non-default record page, a default for another object, an `isDefault` page + * that is not a record page, and the demote-then-promote sequence. A save under + * a PACKAGED page's own name is answered by the packaged-base refusal + * (`NOT_OVERRIDABLE` 403), unchanged and never by this one. + */ + +import { describe, expect, it } from 'vitest'; +import type { ServiceObject } from '@objectstack/spec/data'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject } from '@objectstack/metadata-core'; +import { ObjectQL } from './engine.js'; + +const PKG = 'com.acme.auth'; + +/** A record page; `object` and `isDefault` are what a case varies. */ +const recordPage = (name: string, object: string, isDefault = true, extra: Record = {}) => ({ + name, + label: name, + type: 'record', + object, + isDefault, + regions: [], + ...extra, +}); + +const sysMetadataObject: ServiceObject = { + name: 'sys_metadata', + label: 'System Metadata', + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const }, + type: { name: 'type', label: 'Type', type: 'text' as const, required: true }, + name: { name: 'name', label: 'Name', type: 'text' as const, required: true }, + organization_id: { name: 'organization_id', label: 'Org', type: 'text' as const }, + package_id: { name: 'package_id', label: 'Package', type: 'text' as const }, + // The bundled type's member (#22877) — `sys-metadata.object.ts` declares + // it, and an `email_template` write keys its row by it. + bundle_member: { name: 'bundle_member', label: 'Bundle Member', type: 'text' as const, maxLength: 100 }, + metadata: { name: 'metadata', label: 'Body', type: 'textarea' as const }, + checksum: { name: 'checksum', label: 'Checksum', type: 'text' as const, maxLength: 71 }, + state: { name: 'state', label: 'State', type: 'text' as const }, + version: { name: 'version', label: 'Version', type: 'number' as const }, + created_at: { name: 'created_at', label: 'Created', type: 'datetime' as const }, + updated_at: { name: 'updated_at', label: 'Updated', type: 'datetime' as const }, + }, +}; + +/** In-memory driver, copied from `save-meta-response-conformance.test.ts`; equality-only WHERE. */ +function makeMemoryDriver() { + const stores = new Map>>(); + const storeFor = (obj: string) => { + let s = stores.get(obj); + if (!s) { s = new Map(); stores.set(obj, s); } + return s; + }; + let nextId = 0; + // `$and` / `$or` are conjoined WITH their sibling keys, the way a real + // driver ANDs them (#7620). + const matchesWhere = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k === '$and' && Array.isArray(v)) { + if (!v.every((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k === '$or' && Array.isArray(v)) { + if (!v.some((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k.startsWith('$')) continue; + const rowVal = row[k]; + const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v; + const a = rowVal === undefined ? null : rowVal; + const b = expected === undefined ? null : expected; + if (a !== b) return false; + } + return true; + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {} as any, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast: any) { + const rows = Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where)); + // The caller's bound, after the filter, by presence (`check:objectql-double-limit`). + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matchesWhere(r, ast?.where)) return r; + return null; + }, + async create(object: string, data: Record) { + nextId += 1; + const id = (data.id as string) ?? `r_${nextId}`; + const row = { ...data, id }; + storeFor(object).set(id, row); + return row; + }, + async update(object: string, id: string, data: Record) { + const s = storeFor(object); + const cur = s.get(id); + if (!cur) throw new Error(`not found: ${object}/${id}`); + const updated = { ...cur, ...data, id }; + s.set(id, updated); + return updated; + }, + async upsert(object: string, data: Record) { + const id = data.id as string | undefined; + if (id && storeFor(object).has(id)) return this.update(object, id, data); + return this.create(object, data); + }, + async delete(object: string, id: string) { return storeFor(object).delete(id); }, + async count(object: string, ast: any) { return (await this.find(object, ast)).length; }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r))); + }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, stores }; +} + +type Topology = 'environment-scoped' | 'host-config'; +const TOPOLOGIES: readonly Topology[] = ['environment-scoped', 'host-config']; + +/** The package ships the default record page of `acme_org`. */ +async function boot(topology: Topology) { + const engine = new ObjectQL(); + const { driver, stores } = makeMemoryDriver(); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(sysMetadataObject, 'test-package'); + for (const o of [SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) { + engine.registry.registerObject(o as any, 'test-package'); + } + engine.registerApp({ id: PKG, name: 'auth', pages: [recordPage('acme_org_detail', 'acme_org')] }); + const protocol = new ObjectStackProtocolImplementation( + engine, + () => new Map(), + topology === 'environment-scoped' ? 'env_prod' : undefined, + ); + const storedPages = () => + Array.from(stores.get('sys_metadata')?.values() ?? []) + .filter((r: any) => r.type === 'page') + .map((r: any) => r.name) + .sort(); + const save = (name: string, item: Record) => + protocol.saveMetaItem({ type: 'page', name, item }).then( + (result: any) => ({ ok: true as const, result }), + (error: any) => ({ ok: false as const, error }), + ); + return { engine, protocol, storedPages, save }; +} + +/** The envelope, the object and both pages of one refusal, by its first sentence. */ +function expectRefusal(outcome: { ok: boolean; error?: any }, saved: string, object: 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(err.object).toBe(object); + expect(String(err.message).split('. ')[0]).toBe( + `Default record page conflict: page '${saved}' cannot be saved as the default record page of object ` + + `'${object}': ${holder} already is`, + ); +} + +describe.each(TOPOLOGIES)('%s kernel — a page saved as a second default record page', (topology) => { + it('is refused over a page a package ships, naming both, and stores nothing', async () => { + const { save, storedPages } = await boot(topology); + const outcome = await save('env_org_detail', recordPage('env_org_detail', 'acme_org')); + expectRefusal(outcome, 'env_org_detail', 'acme_org', `page 'acme_org_detail' of package '${PKG}'`); + expect(String(outcome.error.message)).toContain( + "Save 'env_org_detail' with `isDefault: false`: a page a package ships stays the default unless the deployment " + + 'is composed without that package.', + ); + expect(storedPages()).toEqual([]); + }); + + it('is refused over a page the environment already stored, naming it', async () => { + const { save, storedPages } = await boot(topology); + expect((await save('env_task_a', recordPage('env_task_a', 'acme_task'))).ok).toBe(true); + const outcome = await save('env_task_b', recordPage('env_task_b', 'acme_task')); + expectRefusal(outcome, 'env_task_b', 'acme_task', "page 'env_task_a', authored in this environment"); + expect(String(outcome.error.message)).toContain( + "Save 'env_task_b' with `isDefault: false`, or first save 'env_task_a' with `isDefault: false` to make " + + "'env_task_b' the default instead.", + ); + expect(storedPages()).toEqual(['env_task_a']); + }); + + it('a page that leaves `type` out is a record page, and is refused too', async () => { + const { save } = await boot(topology); + const { type: _omit, ...untyped } = recordPage('env_org_detail', 'acme_org'); + expectRefusal(await save('env_org_detail', untyped), 'env_org_detail', 'acme_org', `page 'acme_org_detail' of package '${PKG}'`); + }); + + // ── controls ───────────────────────────────────────────────────────── + + it('control: a save under the existing default\'s own name (an overlay by name) stays accepted', async () => { + const { save, storedPages } = await boot(topology); + expect((await save('env_task_a', recordPage('env_task_a', 'acme_task'))).ok).toBe(true); + const again = await save('env_task_a', recordPage('env_task_a', 'acme_task', true, { label: 'Renamed' })); + expect(again.ok, String((again as any).error?.message)).toBe(true); + expect(storedPages()).toEqual(['env_task_a']); + }); + + it('control: the packaged default\'s own name answers the packaged-base refusal, unchanged, never this one', async () => { + const { save } = await boot(topology); + const outcome = await save('acme_org_detail', recordPage('acme_org_detail', 'acme_org')); + expect(outcome.ok).toBe(false); + expect({ code: (outcome as any).error?.code, status: (outcome as any).error?.status }).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); + }); + + it('control: a non-default record page for the object saves', async () => { + const { save, storedPages } = await boot(topology); + const outcome = await save('env_org_detail', recordPage('env_org_detail', 'acme_org', false)); + expect(outcome.ok, String((outcome as any).error?.message)).toBe(true); + expect(storedPages()).toEqual(['env_org_detail']); + }); + + it('control: a default record page for another object saves', async () => { + const { save } = await boot(topology); + const outcome = await save('env_env_detail', recordPage('env_env_detail', 'acme_env')); + expect(outcome.ok, String((outcome as any).error?.message)).toBe(true); + }); + + it('control: an isDefault page that is not a record page is outside the rule', async () => { + const { save } = await boot(topology); + const outcome = await save('env_home', { name: 'env_home', label: 'Home', type: 'home', isDefault: true, regions: [] }); + expect(outcome.ok, String((outcome as any).error?.message)).toBe(true); + }); + + it('control: demote the stored default, then the new page may be the default', async () => { + const { save, storedPages } = await boot(topology); + expect((await save('env_task_a', recordPage('env_task_a', 'acme_task'))).ok).toBe(true); + expect((await save('env_task_a', recordPage('env_task_a', 'acme_task', false))).ok).toBe(true); + const promoted = await save('env_task_b', recordPage('env_task_b', 'acme_task')); + expect(promoted.ok, String((promoted as any).error?.message)).toBe(true); + expect(storedPages()).toEqual(['env_task_a', 'env_task_b']); + }); +}); diff --git a/packages/objectql/src/registry-default-record-page.test.ts b/packages/objectql/src/registry-default-record-page.test.ts new file mode 100644 index 00000000000..3363fd75f1f --- /dev/null +++ b/packages/objectql/src/registry-default-record-page.test.ts @@ -0,0 +1,245 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * One default record page per object per deployment — the registry's package + * door and item seam (maintainer ruling A on #23015, record 6134056653; + * `default-record-page.ts` states the rule, the holders and what is not a + * second default). + * + * The door under test is the real one: the `manifest` service ObjectQLPlugin + * registers, which every package registration reaches — `AppPlugin.init` at + * boot (an artifact boot included), a hot install, a plugin's + * `manifest.register` — through `ObjectQL.registerApp` → + * `SchemaRegistry.installPackage`, then the generic collection loop's + * `registerItem('page', …, packageId)`. + * + * Every refusal is asserted by its ADR-0112 envelope (`code` + `status`), by + * the two pages it names (`incoming` / `existing`) and by the message's first + * sentence naming both. + * + * The controls keep the refusal as narrow as the ruling: one default per + * object; two objects with one default each; a non-default record page; an + * `isDefault` page that is not a record page; the same package again; the + * environment's overlay of the very page by name; an environment registration + * (the bare slot every `sys_metadata` hydration writes), which is not judged. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import { ObjectQLPlugin } from './plugin.js'; +import { DEFAULT_RECORD_PAGE_CONFLICT_CODE, SchemaRegistry } 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 Refusal = Error & { + code?: string; + status?: number; + object?: string; + incoming?: Holder; + existing?: Holder; + conflicts?: Array<{ object: string; incoming: Holder; existing: Holder }>; +}; + +/** A record page; `object` and `isDefault` are what a case varies. */ +const recordPage = (name: string, object: string, isDefault = true, extra: Record = {}) => ({ + name, + label: name, + type: 'record', + object, + isDefault, + regions: [], + ...extra, +}); + +/** A flat package payload — the shape `AppPlugin.init` hands the manifest service. */ +const pkg = (id: string, pages: unknown[] = [], extra: Record = {}) => ({ + id, + name: id.split('.').pop(), + version: '1.0.0', + type: 'app', + pages, + ...extra, +}); + +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 }); + +/** The envelope, and the two pages, of one refusal. */ +function expectRefusal(err: Refusal | undefined, object: string, incoming: Holder, existing: Holder) { + expect(err, 'the registration was refused').toBeDefined(); + expect({ code: err!.code, status: err!.status }).toEqual({ code: DEFAULT_RECORD_PAGE_CONFLICT_CODE, status: 422 }); + expect(DEFAULT_RECORD_PAGE_CONFLICT_CODE).toBe('DEFAULT_RECORD_PAGE_CONFLICT'); + expect(err!.object).toBe(object); + expect(err!.incoming).toEqual(incoming); + expect(err!.existing).toEqual(existing); + const name = (h: Holder) => + h.kind === 'package' ? `page "${h.page}" of package "${h.packageId}"` : `page "${h.page}", authored in this environment`; + expect(err!.message.split('. ')[0]).toBe( + `Default record page conflict: ${name(incoming)} cannot be the default record page of object "${object}": ${name(existing)} already is`, + ); +} + +describe('a second package registering a default record page for an object another package holds', () => { + it('is refused at the package door, naming both pages, and leaves no record behind', async () => { + const { registry, register } = await boot(); + expect(await register(pkg('com.acme.auth', [recordPage('acme_org_detail', 'acme_org')]))).toBeUndefined(); + + const err = await register(pkg('com.acme.tenant', [recordPage('tenant_org_detail', 'acme_org')])); + expectRefusal(err, 'acme_org', pkgPage('com.acme.tenant', 'tenant_org_detail'), pkgPage('com.acme.auth', 'acme_org_detail')); + expect(err!.conflicts).toHaveLength(1); + // Ahead of every mutation: no package record, and no page registered. + expect(registry.getPackage('com.acme.tenant')).toBeUndefined(); + expect(registry.getPackage('com.acme.auth')).toBeDefined(); + expect(registry.listItems('page').map((p) => p.name)).toEqual(['acme_org_detail']); + }); + + it('is refused in either order — whichever registers second is the second default', async () => { + const { register } = await boot(); + expect(await register(pkg('com.acme.tenant', [recordPage('tenant_org_detail', 'acme_org')]))).toBeUndefined(); + const err = await register(pkg('com.acme.auth', [recordPage('acme_org_detail', 'acme_org')])); + expectRefusal(err, 'acme_org', pkgPage('com.acme.auth', 'acme_org_detail'), pkgPage('com.acme.tenant', 'tenant_org_detail')); + }); + + it('two packages\' pages under ONE name are two pages, and the second is refused', async () => { + const { register } = await boot(); + expect(await register(pkg('com.acme.auth', [recordPage('org_detail', 'acme_org')]))).toBeUndefined(); + const err = await register(pkg('com.acme.tenant', [recordPage('org_detail', 'acme_org')])); + expectRefusal(err, 'acme_org', pkgPage('com.acme.tenant', 'org_detail'), pkgPage('com.acme.auth', 'org_detail')); + }); + + it('a nested plugin declares under its parent package, and is held to the same rule', async () => { + const { register } = await boot(); + expect(await register(pkg('com.acme.auth', [], { plugins: [{ name: 'inner', pages: [recordPage('acme_org_detail', 'acme_org')] }] }))).toBeUndefined(); + const err = await register(pkg('com.acme.tenant', [recordPage('tenant_org_detail', 'acme_org')])); + expectRefusal(err, 'acme_org', pkgPage('com.acme.tenant', 'tenant_org_detail'), pkgPage('com.acme.auth', 'acme_org_detail')); + }); + + it('a package installed with no pages registered still holds what it declared (its claim)', async () => { + const { registry, register } = await boot(); + // A bare install — `POST /api/v1/packages` stores the manifest alone. + registry.installPackage(pkg('com.acme.auth', [recordPage('acme_org_detail', 'acme_org')]) as any); + expect(registry.listItems('page')).toEqual([]); + const err = await register(pkg('com.acme.tenant', [recordPage('tenant_org_detail', 'acme_org')])); + expectRefusal(err, 'acme_org', pkgPage('com.acme.tenant', 'tenant_org_detail'), pkgPage('com.acme.auth', 'acme_org_detail')); + }); + + it('uninstalling the holder releases the object\'s default', async () => { + const { registry, register } = await boot(); + expect(await register(pkg('com.acme.auth', [recordPage('acme_org_detail', 'acme_org')]))).toBeUndefined(); + expect(registry.uninstallPackage('com.acme.auth')).toBe(true); + expect(await register(pkg('com.acme.tenant', [recordPage('tenant_org_detail', 'acme_org')]))).toBeUndefined(); + }); + + it('the item seam refuses a package-bound page registration over another package\'s default', async () => { + const { registry, register } = await boot(); + expect(await register(pkg('com.acme.auth', [recordPage('acme_org_detail', 'acme_org')]))).toBeUndefined(); + const err = await caught(() => registry.registerItem('page', recordPage('direct_org_detail', 'acme_org'), 'name', 'com.acme.direct')); + expectRefusal(err, 'acme_org', pkgPage('com.acme.direct', 'direct_org_detail'), pkgPage('com.acme.auth', 'acme_org_detail')); + expect(registry.listItems('page').map((p) => p.name)).toEqual(['acme_org_detail']); + }); +}); + +describe('two default record pages for one object inside one manifest', () => { + it('is refused at the package door, naming both of the package\'s pages', async () => { + const { registry, register } = await boot(); + const err = await register(pkg('com.acme.auth', [recordPage('org_a', 'acme_org'), recordPage('org_b', 'acme_org')])); + expectRefusal(err, 'acme_org', pkgPage('com.acme.auth', 'org_b'), pkgPage('com.acme.auth', 'org_a')); + expect(registry.getPackage('com.acme.auth')).toBeUndefined(); + }); +}); + +describe('an environment\'s page as a holder (the bare slot)', () => { + it('a package registering a default for an object the environment holds a default of is refused, naming the environment\'s page', async () => { + const { register, registry } = await boot(); + // The bare slot — how a stored page reaches the registry (hydration, write-through). + registry.registerItem('page', recordPage('env_org_detail', 'acme_org')); + const err = await register(pkg('com.acme.tenant', [recordPage('tenant_org_detail', 'acme_org')])); + expectRefusal(err, 'acme_org', pkgPage('com.acme.tenant', 'tenant_org_detail'), { kind: 'environment', page: 'env_org_detail' }); + }); + + it('control: the environment\'s item under the SAME name is that page\'s overlay, not a second default', async () => { + const { register, registry } = await boot(); + registry.registerItem('page', recordPage('tenant_org_detail', 'acme_org')); + expect(await register(pkg('com.acme.tenant', [recordPage('tenant_org_detail', 'acme_org')]))).toBeUndefined(); + }); + + it('control: an environment registration over a package\'s default is not judged here (the save door refuses it before it is stored)', async () => { + const { register, registry } = await boot(); + expect(await register(pkg('com.acme.auth', [recordPage('acme_org_detail', 'acme_org')]))).toBeUndefined(); + expect(await caught(() => registry.registerItem('page', recordPage('env_org_detail', 'acme_org')))).toBeUndefined(); + }); + + it('an environment overlay that demotes a package\'s default stands in for it: the name no longer holds', async () => { + const { register, registry } = await boot(); + expect(await register(pkg('com.acme.auth', [recordPage('acme_org_detail', 'acme_org')]))).toBeUndefined(); + registry.registerItem('page', { ...recordPage('acme_org_detail', 'acme_org', false), _packageId: 'com.acme.auth' }); + expect(registry.defaultRecordPageHoldersOf('acme_org')).toEqual([]); + expect(await register(pkg('com.acme.tenant', [recordPage('tenant_org_detail', 'acme_org')]))).toBeUndefined(); + }); +}); + +describe('controls — what is not a second default', () => { + it('one default record page per object, across packages, registers', async () => { + const { register, registry } = await boot(); + expect(await register(pkg('com.acme.auth', [recordPage('acme_org_detail', 'acme_org')]))).toBeUndefined(); + expect(await register(pkg('com.acme.tenant', [recordPage('tenant_env_detail', 'acme_env')]))).toBeUndefined(); + expect(registry.defaultRecordPageHoldersOf('acme_org')).toEqual([pkgPage('com.acme.auth', 'acme_org_detail')]); + expect(registry.defaultRecordPageHoldersOf('acme_env')).toEqual([pkgPage('com.acme.tenant', 'tenant_env_detail')]); + }); + + it('a second record page for the object that is NOT a default registers', async () => { + const { register } = await boot(); + expect(await register(pkg('com.acme.auth', [recordPage('acme_org_detail', 'acme_org')]))).toBeUndefined(); + expect(await register(pkg('com.acme.tenant', [recordPage('tenant_org_detail', 'acme_org', false)]))).toBeUndefined(); + }); + + it('an isDefault page that is not a record page is outside the rule', async () => { + const { register } = await boot(); + expect(await register(pkg('com.acme.auth', [recordPage('acme_org_detail', 'acme_org')]))).toBeUndefined(); + expect(await register(pkg('com.acme.tenant', [{ name: 'tenant_home', label: 'Home', type: 'home', isDefault: true, regions: [] }]))).toBeUndefined(); + }); + + it('the same package again — a reload, and an upgrade that moves its default to another of its pages — is one holder', async () => { + const { register, registry } = await boot(); + expect(await register(pkg('com.acme.auth', [recordPage('org_a', 'acme_org')]))).toBeUndefined(); + expect(await register(pkg('com.acme.auth', [recordPage('org_a', 'acme_org')]))).toBeUndefined(); + expect(await register(pkg('com.acme.auth', [recordPage('org_a', 'acme_org', false), recordPage('org_b', 'acme_org')]))).toBeUndefined(); + expect(registry.defaultRecordPageHoldersOf('acme_org')).toEqual([pkgPage('com.acme.auth', 'org_b')]); + }); + + it('`collisionPolicy: \'warn\'` does not downgrade it', async () => { + const registry = new SchemaRegistry({ collisionPolicy: 'warn' }); + registry.logLevel = 'silent'; + registry.installPackage(pkg('com.acme.auth', [recordPage('acme_org_detail', 'acme_org')]) as never); + const err = await caught(() => registry.installPackage(pkg('com.acme.tenant', [recordPage('tenant_org_detail', 'acme_org')]) as never)); + expectRefusal(err, 'acme_org', pkgPage('com.acme.tenant', 'tenant_org_detail'), pkgPage('com.acme.auth', 'acme_org_detail')); + }); +}); From 19f146e7302a93b4ee182ccbc7bca9fba8ef66b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 05:25:18 +0000 Subject: [PATCH 05/11] fix(spec,platform-objects): the page form's isDefault help states the one-default-per-object rule Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude --- .../src/apps/translations/en.metadata-forms.generated.ts | 2 +- .../src/apps/translations/es-ES.metadata-forms.generated.ts | 2 +- .../src/apps/translations/ja-JP.metadata-forms.generated.ts | 2 +- .../src/apps/translations/zh-CN.metadata-forms.generated.ts | 2 +- packages/spec/src/ui/page.form.ts | 5 ++++- 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 41638c7296f..9fe67e9a65c 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -1307,7 +1307,7 @@ export const enMetadataForms: NonNullable = { }, isDefault: { label: "Is Default", - helpText: "Set as default page for this page type" + helpText: "Open this record page for its object's records. One default record page per object: a second is refused." }, kind: { label: "Kind", diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index d0cccee4340..cd3af11bfb5 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -1307,7 +1307,7 @@ export const esESMetadataForms: NonNullable = }, isDefault: { label: "Predeterminado", - helpText: "Establece como página predeterminada para este tipo de página" + helpText: "Abre los registros de su objeto con esta página de registro. Una sola página de registro predeterminada por objeto: una segunda se rechaza." }, kind: { label: "Modo", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 3026c2a07af..dd86c222b20 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -1307,7 +1307,7 @@ export const jaJPMetadataForms: NonNullable = }, isDefault: { label: "既定", - helpText: "このページ種別の既定ページに設定" + helpText: "このオブジェクトのレコードを開くときにこのレコードページを使います。既定のレコードページはオブジェクトごとに 1 つだけで、2 つ目は拒否されます。" }, kind: { label: "モード", diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index 970e0320394..55a8b66a9a0 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -1307,7 +1307,7 @@ export const zhCNMetadataForms: NonNullable = }, isDefault: { label: "默认", - helpText: "设为该页面类型的默认页" + helpText: "打开该对象的记录时使用此记录页。每个对象只能有一个默认记录页:第二个会被拒绝。" }, kind: { label: "模式", diff --git a/packages/spec/src/ui/page.form.ts b/packages/spec/src/ui/page.form.ts index 6b3ed41cd75..ed83a543227 100644 --- a/packages/spec/src/ui/page.form.ts +++ b/packages/spec/src/ui/page.form.ts @@ -187,7 +187,10 @@ export const pageForm = defineForm({ collapsible: true, collapsed: true, fields: [ - { field: 'isDefault', helpText: 'Set as default page for this page type' }, + // [#23015] One default record page per object per deployment + // (`PageSchema.isDefault`); the old "for this page type" stated a rule + // nothing enforced. + { field: 'isDefault', helpText: 'Open this record page for its object\'s records. One default record page per object: a second is refused.' }, { field: 'kind', helpText: 'Page override mode: full or slotted (for record pages)' }, // `assignedProfiles` input removed with the key (ADR-0090 D2 / ADR-0049): a form // input for an unwritable key is the false-compliant UI half of a retirement, and From 42382458270b38fd70ab5b22d40c0745d70676ce Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 05:34:27 +0000 Subject: [PATCH 06/11] chore(spec): regenerate api-surface, export-origins and reference docs Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude --- content/docs/references/api/contract.mdx | 3 ++- content/docs/references/api/error-code-ledger.mdx | 1 + content/docs/references/ui/page.mdx | 2 +- packages/spec/api-surface/ui.json | 1 + packages/spec/export-origins/ui.json | 1 + 5 files changed, 6 insertions(+), 2 deletions(-) diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index 04c91fa6845..3b37dbda0d0 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +325 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +326 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | | **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | | **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | @@ -134,6 +134,7 @@ const result = ApiErrorSchema.parse(data); * `CUBE_NOT_FOUND` * `DATASET_INVALID` * `DATASOURCE_ADMIN_ERROR` +* `DEFAULT_RECORD_PAGE_CONFLICT` * `DELEGABLE_SCOPE_FAILED` * `DELETE_HOOK_RESULT_NOT_WRITE_SHAPE` * `DELIVERY_NEVER_SENT` diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index 4a0e45b7ad0..9240b2241cb 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -301,6 +301,7 @@ const result = ErrorCode.parse(data); * `CUBE_NOT_FOUND` * `DATASET_INVALID` * `DATASOURCE_ADMIN_ERROR` +* `DEFAULT_RECORD_PAGE_CONFLICT` * `DELEGABLE_SCOPE_FAILED` * `DELETE_HOOK_RESULT_NOT_WRITE_SHAPE` * `DELIVERY_NEVER_SENT` diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index ae7581fe574..d587d6d5a9c 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -209,7 +209,7 @@ View filter rule | **object** | `string` | optional | Bound object (for Record pages) | | **template** | `string` | optional (default: `"default"`) | Layout template name (e.g. "header-sidebar-main") | | **regions** | `{ name: string; width?: Enum<'small' \| 'medium' \| 'large' \| 'full'>; components: object[] }[]` | optional | Layout regions (header, main, sidebar, footer) with their components. Optional — list pages use interfaceConfig, slotted pages use slots, and an empty full page falls back to the synthesized default layout. | -| **isDefault** | `boolean` | optional (default: `false`) | | +| **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. Outside this rule for a page that is not a record page bound to an object. | | **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[]; … }` | 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/api-surface/ui.json b/packages/spec/api-surface/ui.json index b02b72b7450..073646c6e59 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -505,6 +505,7 @@ "compileListViewGroupRowsQuery (function)", "dashboardForm (const)", "datasetForm (const)", + "defaultRecordPageObject (function)", "defineAction (function)", "defineApp (function)", "defineDataset (function)", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index 05f54a652a4..de94775bf7a 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -490,6 +490,7 @@ "compileListViewGroupRowsQuery": "src/ui/view-grouping-query.ts#compileListViewGroupRowsQuery (function)", "dashboardForm": "src/ui/dashboard.form.ts#dashboardForm (const)", "datasetForm": "src/ui/dataset.form.ts#datasetForm (const)", + "defaultRecordPageObject": "src/ui/default-record-page.ts#defaultRecordPageObject (function)", "defineAction": "src/ui/action.zod.ts#defineAction (function)", "defineApp": "src/ui/app.zod.ts#defineApp (function)", "defineDataset": "src/ui/dataset.zod.ts#defineDataset (function)", From 3d23b0271a7cfb387fd48ed482697d50a685cf13 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 05:35:35 +0000 Subject: [PATCH 07/11] docs(changeset): one default record page per object per deployment Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude --- ...3015-one-default-record-page-per-object.md | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 .changeset/23015-one-default-record-page-per-object.md diff --git a/.changeset/23015-one-default-record-page-per-object.md b/.changeset/23015-one-default-record-page-per-object.md new file mode 100644 index 00000000000..bf225582679 --- /dev/null +++ b/.changeset/23015-one-default-record-page-per-object.md @@ -0,0 +1,47 @@ +--- +'@objectstack/spec': minor +'@objectstack/objectql': minor +'@objectstack/metadata-protocol': minor +'@objectstack/platform-objects': patch +--- + +feat(spec)!: at most one `isDefault: true` record page per object per deployment — a second is refused at `defineStack` / `os validate`, at package registration and at metadata save, naming both pages (#23015) + +Clause-②: no (narrowing) + + + +**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for breaking changes. + +`PageSchema.isDefault` now states the rule (maintainer ruling A on #23015): 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 defaults for one object the page that opened used to be whichever plugin initialised first — an accident no author wrote. A record page is `type: 'record'` (the default when `type` is left out) bound to an `object`; `isDefault` on any other page is outside the rule. + +**What is refused, and where.** Every door that can add a second default refuses it, naming both pages: + +- **`defineStack`** (and so `objectstack validate`, `objectstack build` and a source boot) — two default record pages for one object inside one stack, `STACK_CROSS_REFERENCE_INVALID` (422), one finding per object naming the object, every page and its position (`stack.pages[0]`). **`composeStacks`** refuses the pair across the stacks it composes, in the same envelope; under `manifest: 'preserve'` one page name in two stacks is two packages' pages. +- **Package registration** (`@objectstack/objectql`'s `SchemaRegistry`, at `installPackage` ahead of every mutation and at the `registerItem` seam) — a package whose default record page meets another package's default for the same object, an environment's stored default, or a second default in its own manifest, `DEFAULT_RECORD_PAGE_CONFLICT` (422, exported as `DEFAULT_RECORD_PAGE_CONFLICT_CODE`). Two packages' pages under one name are two pages and are refused too. `OS_METADATA_COLLISION=warn` does not soften it. +- **Metadata save** (`@objectstack/metadata-protocol`'s `saveMetaItem`, so `PUT /api/v1/meta/page/:name`) — a page saved as the default record page of an object a stored page or a packaged page already holds, `DEFAULT_RECORD_PAGE_CONFLICT` (422), in draft and publish mode, before anything is written. + +**Unchanged.** A save under the existing default's own name is that page (an ADR-0005 overlay by name), not a second default. One default per object, a second record page that is not a default, and two objects with one default each are accepted. The same package registering again — a reload, or an upgrade that moves its default to another of its pages — is one holder. A save under the name of a page a package ships is still answered by the packaged-base refusal (`NOT_OVERRIDABLE`, 403), as before. A stored page reaching the registry from `sys_metadata` (hydration, write-through) is not refused again. + +## FROM → TO + +| door | FROM | TO | +|:--|:--|:--| +| `defineStack` / `objectstack validate` | `pages: [{ name: 'account_a', type: 'record', object: 'crm_account', isDefault: true }, { name: 'account_b', type: 'record', object: 'crm_account', isDefault: true }]` accepted; the boot kept both and the record view opened whichever registered first | refused, `STACK_CROSS_REFERENCE_INVALID` 422 — set `isDefault: false` on all but one | +| `composeStacks` | two stacks each shipping a default record page for one object composed clean | refused in the cross-reference envelope, naming both stacks' pages | +| package registration | a second package (or a plugin's `manifest.register`) shipping a default record page for an object another package holds registered; init order decided the page | refused, `DEFAULT_RECORD_PAGE_CONFLICT` 422, naming both pages; keep one default, or compose the deployment without the package that ships the other | +| metadata save | `PUT /api/v1/meta/page/my_page` with `isDefault: true` for an object that already has a default record page stored or shipped saved | refused, `DEFAULT_RECORD_PAGE_CONFLICT` 422, naming both pages; save it with `isDefault: false`, or first demote the stored default | + +**The one-line fix: keep `isDefault: true` on the one record page per object that should open the record view, and set `isDefault: false` on every other.** To replace a page a package ships, compose the deployment without that package's page; shipping a second default is not an override path. + +No D2 conversion rewrites it: which page stays the default is the author's call. Its D3 record is the semantic entry `page-default-record-page-one-per-object-refused` (protocol 18). + +**`@objectstack/spec`.** `defaultRecordPageObject(page)` (exported from `@objectstack/spec/ui`) is the one reading of "which object is this page the default record page of" that the three doors share. `PageSchema.isDefault` gains its `.describe()`. `DEFAULT_RECORD_PAGE_CONFLICT` joins the error-code ledger under `@objectstack/objectql` and `@objectstack/metadata-protocol`. + +**`@objectstack/objectql`.** The registration refusal, `SchemaRegistry.defaultRecordPageHoldersOf(object)` (every page holding the object's default — the reading the save door meets with the stored pages) and the `DEFAULT_RECORD_PAGE_CONFLICT_CODE` constant. + +**`@objectstack/metadata-protocol`.** The save-door refusal. + +**`@objectstack/platform-objects`.** The page form's `isDefault` help text, which said "Set as default page for this page type" — a rule nothing enforced — now states the one-default-per-object rule, in all four locales. + +**Who is affected, measured.** In this repository every object with a default record page has exactly one: `sys_organization` and `sys_user` (`@objectstack/plugin-auth`), `sys_position` (`@objectstack/plugin-security`), `sys_approval_request` (`@objectstack/plugin-approvals`), and `showcase_project`, `showcase_task` and `showcase_preference` (`examples/app-showcase`). A deployment that also composes a package shipping its own default record page for one of those objects is refused at boot until one of the two pages stops being a default. From 498957f10bef63739b465de51d2bd2e502a2fadb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 06:34:14 +0000 Subject: [PATCH 08/11] docs(ui): the pages reference states the one-default-record-page-per-object rule Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude --- content/docs/ui/pages.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/ui/pages.mdx b/content/docs/ui/pages.mdx index 10d11a758cb..45a7537b25a 100644 --- a/content/docs/ui/pages.mdx +++ b/content/docs/ui/pages.mdx @@ -66,7 +66,7 @@ const homePage = { | `regions` | `PageRegion[]` | optional | Layout regions with components (default `[]` — `list` pages render via `interfaceConfig`, and an empty `record`/`home`/`app` page falls back to the synthesized default layout) | | `source` | `string` | optional | Page source text — required (and authoritative over `regions`) when `kind` is `'html'` or `'react'`. For `kind: 'html'` it is constrained JSX/HTML compiled to the tree by `@objectstack/sdui-parser` at save time (parse, never execute). For `kind: 'react'` it is real React/JSX executed at render by `@object-ui/react-runtime` (trusted tier). See [React Pages](/docs/ui/react-pages) | | `variables` | `PageVariable[]` | optional | Local state variables | -| `isDefault` | `boolean` | optional | Is default page for its type | +| `isDefault` | `boolean` | optional | The default record page of its `object` — the page that object's record view opens. At most one `isDefault: true` record page per object per 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. Outside the rule on a page that is not a record page bound to an object | ### Page Types From d7a8f7f34ddbbba38da55d9471fc23341a115ce2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 07:13:30 +0000 Subject: [PATCH 09/11] fix(metadata-protocol): a draft page is judged again for a second default when it is published Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 18 ++++++++++++++++- .../src/default-record-page-save-door.test.ts | 20 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 4a286877932..880ba88c60a 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -20160,6 +20160,11 @@ export class ObjectStackProtocolImplementation implements * every registry slot of that name, as it does on every read * (ADR-0005), so its own body decides for the name. * + * Asked twice: by `saveMetaItem` on the body it is about to store (draft or + * publish mode), and by the draft publish (`promoteDraftForPublish`) on the + * draft body it is about to promote — the save judged only the pages + * active at that moment, and another may have taken the default since. + * * The page saved under `saveName` is never its own holder: a save under the * existing default's own name is an overlay of that page (ADR-0005), not a * second default, and stays accepted. A registry with no such reading (a @@ -20184,6 +20189,7 @@ export class ObjectStackProtocolImplementation implements type: string, item: unknown, saveName: string, + verb: 'saved' | 'published' = 'saved', ): Promise<(Error & { code: 'DEFAULT_RECORD_PAGE_CONFLICT'; status: 422 }) | undefined> { if (type !== 'page') return undefined; const object = defaultRecordPageObject(item); @@ -20240,7 +20246,7 @@ export class ObjectStackProtocolImplementation implements : `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 saved as the default record page of object ` + `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[] }; @@ -22846,6 +22852,16 @@ export class ObjectStackProtocolImplementation implements const nameRefusal = savedItemNameRefusal(singularType, draftForGate.body, request.name, 'publish'); if (nameRefusal) throw nameRefusal; } + // [#23015] …and one default record page per object per deployment: a + // draft saved while no other page held its object's default can meet + // one by the time it is published, since the save door judged only the + // ACTIVE pages of that moment. Judged again here, on the draft body and + // with the save door's own predicate and envelope, before + // `repo.promoteDraft` writes — so a batch publish aborts on it too. + if (draftForGate) { + const defaultPageRefusal = await this.defaultRecordPageSaveRefusal(singularType, draftForGate.body, request.name, 'published'); + if (defaultPageRefusal) throw defaultPageRefusal; + } // [#9176] The gate's return is its advisory half (#4717): captured and // handed out so `publishMetaItem` can attach it to the 2xx this // promotion is about to earn, exactly as `saveMetaItem` attaches its 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 219d07ff6ac..917ef260927 100644 --- a/packages/objectql/src/default-record-page-save-door.test.ts +++ b/packages/objectql/src/default-record-page-save-door.test.ts @@ -205,6 +205,26 @@ describe.each(TOPOLOGIES)('%s kernel — a page saved as a second default record expect(storedPages()).toEqual(['env_task_a']); }); + it('a draft is judged again when it is published: another page took the default in between', async () => { + const { protocol, save } = await boot(topology); + const draft = await protocol + .saveMetaItem({ type: 'page', name: 'env_task_a', item: recordPage('env_task_a', 'acme_task'), mode: 'draft' }) + .then(() => true, (e: any) => e); + expect(draft, String(draft?.message)).toBe(true); + expect((await save('env_task_b', recordPage('env_task_b', 'acme_task'))).ok).toBe(true); + const published = await protocol.publishMetaItem({ type: 'page', name: 'env_task_a' }).then( + (result: any) => ({ ok: true as const, result }), + (error: any) => ({ ok: false as const, error }), + ); + expect(published.ok, 'the publish was refused').toBe(false); + const err = (published as { error: any }).error; + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'DEFAULT_RECORD_PAGE_CONFLICT', status: 422 }); + expect(String(err.message).split('. ')[0]).toBe( + "Default record page conflict: page 'env_task_a' cannot be published as the default record page of object " + + "'acme_task': page 'env_task_b', authored in this environment already is", + ); + }); + it('a page that leaves `type` out is a record page, and is refused too', async () => { const { save } = await boot(topology); const { type: _omit, ...untyped } = recordPage('env_org_detail', 'acme_org'); From 8c5bb7500180ade0260ca0a05b2c7cb04d1c6b6c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 07:41:28 +0000 Subject: [PATCH 10/11] test(objectql): narrow the save-door outcome union where the error is read Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude --- packages/objectql/src/default-record-page-save-door.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 917ef260927..f1d4c0e09ae 100644 --- a/packages/objectql/src/default-record-page-save-door.test.ts +++ b/packages/objectql/src/default-record-page-save-door.test.ts @@ -186,7 +186,7 @@ describe.each(TOPOLOGIES)('%s kernel — a page saved as a second default record const { save, storedPages } = await boot(topology); const outcome = await save('env_org_detail', recordPage('env_org_detail', 'acme_org')); expectRefusal(outcome, 'env_org_detail', 'acme_org', `page 'acme_org_detail' of package '${PKG}'`); - expect(String(outcome.error.message)).toContain( + expect(String((outcome as { error?: any }).error?.message)).toContain( "Save 'env_org_detail' with `isDefault: false`: a page a package ships stays the default unless the deployment " + 'is composed without that package.', ); @@ -198,7 +198,7 @@ describe.each(TOPOLOGIES)('%s kernel — a page saved as a second default record expect((await save('env_task_a', recordPage('env_task_a', 'acme_task'))).ok).toBe(true); const outcome = await save('env_task_b', recordPage('env_task_b', 'acme_task')); expectRefusal(outcome, 'env_task_b', 'acme_task', "page 'env_task_a', authored in this environment"); - expect(String(outcome.error.message)).toContain( + expect(String((outcome as { error?: any }).error?.message)).toContain( "Save 'env_task_b' with `isDefault: false`, or first save 'env_task_a' with `isDefault: false` to make " + "'env_task_b' the default instead.", ); From 566d19b85869c8a444873f701b06c629d86fd5f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 09:01:53 +0000 Subject: [PATCH 11/11] =?UTF-8?q?docs(changeset):=20the=20#23015=20declara?= =?UTF-8?q?tion=20reads=20Clause-=E2=91=A1=20yes=20(narrowing)=20=E2=80=94?= =?UTF-8?q?=20the=20diff=20adds=20a=20ledger=20code=20and=20public=20expor?= =?UTF-8?q?ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude --- .changeset/23015-one-default-record-page-per-object.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/23015-one-default-record-page-per-object.md b/.changeset/23015-one-default-record-page-per-object.md index bf225582679..e76797b7fbc 100644 --- a/.changeset/23015-one-default-record-page-per-object.md +++ b/.changeset/23015-one-default-record-page-per-object.md @@ -7,7 +7,7 @@ feat(spec)!: at most one `isDefault: true` record page per object per deployment — a second is refused at `defineStack` / `os validate`, at package registration and at metadata save, naming both pages (#23015) -Clause-②: no (narrowing) +Clause-②: yes (narrowing)