diff --git a/.changeset/book-job-translation-app-authorwarn-keys-retired.md b/.changeset/book-job-translation-app-authorwarn-keys-retired.md index 2fe7c4d2b1..7435405f9e 100644 --- a/.changeset/book-job-translation-app-authorwarn-keys-retired.md +++ b/.changeset/book-job-translation-app-authorwarn-keys-retired.md @@ -39,8 +39,14 @@ Run `os migrate meta --from 16` to rewrite existing sources automatically. migration table steered retired `errors:` authors straight into it. **That guidance entry is rewritten here**: retiring one dead key by pointing at another is the defect, not the fix. -- **`app.homePageId`** — *its own hedge*. "If not set, usually defaults to the - first navigation item" described the only behaviour there was. +- **`app.homePageId`** — *a second source for one fact*. Not unread: objectui's + console consumed it in `resolveLandingRoute()` and it was the only thing + deciding where an app opened. (This entry first shipped saying otherwise; + corrected in #4709, which upheld the removal.) What condemns the key is its + shape — an ID cross-reference into `navigation` with no referential integrity, + falling back to the first item *silently* when the id dangled. If "land + somewhere other than first" is ever wanted again it belongs on the navigation + item itself, not on a pointer that can miss. - **`app.areas[].order`** — *the sibling that works*. Nav-item `order` really is sorted; area-level order never was, and both renderers iterate the array as authored. diff --git a/.changeset/homepageid-tombstone-premise-corrected.md b/.changeset/homepageid-tombstone-premise-corrected.md new file mode 100644 index 0000000000..0d60e34bb1 --- /dev/null +++ b/.changeset/homepageid-tombstone-premise-corrected.md @@ -0,0 +1,34 @@ +--- +'@objectstack/spec': patch +--- + +docs(spec): `app.homePageId` 的墓碑说清真正的退役理由 —— 「no shell ever read it」是假的 (#4709) + +**改的是「为什么删」的表述,不是删本身。** `app.homePageId` 在 17.0.0 依旧退役 +(`retiredKey`:编译期 `never`、解析期报错),conversion `app-dead-authoring-keys-removed` +的行为、baseline、`os migrate meta --from 16` 的处方一字未动。 + +#4667 给出的理由是「no shell ever read it」。这句是**假的**,而且与本仓自己的记录直接 +矛盾 —— 2026-06 的 AppSchema liveness 审计 +(`docs/audits/2026-06-appschema-property-liveness.md`)把 `homePageId` 明确列在 LIVE +一侧,因为 objectui console 的 `resolveLandingRoute()` +(`packages/app-shell/src/console/AppContent.tsx`,objectui @785b8a5d)一直在读它,而且 +它是**唯一**决定「app 打开时落在哪」的地方。两份文档矛盾了两个月无人发现,直到有人做 +cloud pin 对账时先信了这句、再去核渲染器才发现不对(#4709)。 + +真正让这个键该走的是它的**形状**,不是无人使用:它把落地页编码成指向 `navigation` 的 +ID 交叉引用,没有引用完整性 —— id 悬空时**静默**回退到第一项(objectui 的实现正是如此), +于是同一件事有两个来源,而错的那个不出声。将来若要「落地页 ≠ 第一项」,正确形状是导航项 +自身的标记(`navigation[].landing`:单一来源、不可能悬空),并按 enforce-first 设计 +(先有渲染器与测试,再进 schema)。 + +墓碑文案改为诚实版本后,作者看到的处方**保持不变**:删掉这个键;要改 app 从哪里打开就 +重排 `navigation` 让目标项排第一;根落地由 `isDefault` 决定。同步纠正:conversion 摘要 +(经 `gen:upgrade-guide` / `gen:spec-changes` 重生成到 `docs/protocol-upgrade-guide.md` +与 `spec-changes.json`)、生成文档 `content/docs/references/ui/app.mdx`、 +`content/docs/ui/apps.mdx`、liveness ledger 的 `homePageId` note、`examples/app-showcase` +里那句「has no console consumer yet」,并给 6 月审计补了一条指向 #4667/#4709 的后续注记 +(审计结论本身是对的,原文不动)。新增一条 pin 测试,防止「无人读过」这类假前提回潮。 + +objectui 侧那段永远进不去的 `if (homePageId)` 死分支单独清理: +`objectstack-ai/objectui#3264`。 diff --git a/content/docs/references/ui/app.mdx b/content/docs/references/ui/app.mdx index 80405b8237..12b9b47ca5 100644 --- a/content/docs/references/ui/app.mdx +++ b/content/docs/references/ui/app.mdx @@ -83,7 +83,7 @@ const result = ActionNavItemSchema.parse(data); | **navigation** | `{ id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| { id: string; label: string; icon?: string; order?: number; … }[]` | optional | Full navigation tree for the app sidebar | | **areas** | `{ id: string; label: string; icon?: string; description?: string; … }[]` | optional | Navigation areas for partitioning navigation by business domain | | **contextSelectors** | `{ id: string; label: string; icon?: string; optionsSource: object; … }[]` | optional | App-level scope dropdowns whose value is injected into nav items as `{}` template vars | -| **homePageId** | `any` | optional | [REMOVED] `app.homePageId` was removed in @objectstack/spec 17.0.0 (#4667, ADR-0049) — no shell ever read it. An app's landing page IS its first navigation item (by `order`), and the root landing follows `isDefault` routing. Delete the key; to change where an app opens, reorder `navigation` so the intended entry is first, and set `isDefault` on the app that should own the root landing. Run `os migrate meta --from 16` to rewrite existing sources automatically. | +| **homePageId** | `any` | optional | [REMOVED] `app.homePageId` was removed in @objectstack/spec 17.0.0 (#4667, #4709, ADR-0049). objectui's console did read it before v17 (`resolveLandingRoute`), so this key had a consumer — it was retired because the capability is better expressed on the navigation item itself than as an ID cross-reference that silently falls back when it dangles. An app's landing page IS its first navigation item (by `order`), and the root landing follows `isDefault` routing. Delete the key; to change where an app opens, reorder `navigation` so the intended entry is first, and set `isDefault` on the app that should own the root landing. Run `os migrate meta --from 16` to rewrite existing sources automatically. | | **requiredPermissions** | `string[]` | optional | Permissions required to access this app | | **objects** | `any` | optional | [REMOVED] `App.objects` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — never read; the spec itself labelled it "config file convenience"). Objects belong to the stack (`defineStack({ objects })`); an app reaches them through its navigation items. Delete the key. | | **apis** | `any` | optional | [REMOVED] `App.apis` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — never read). Declarative endpoints belong to the stack (`defineStack({ apis })`), not the app shell. Delete the key. | diff --git a/content/docs/ui/apps.mdx b/content/docs/ui/apps.mdx index e09ce702bb..4667e9049f 100644 --- a/content/docs/ui/apps.mdx +++ b/content/docs/ui/apps.mdx @@ -284,8 +284,9 @@ const projectApp = { requiredPermissions: ['pm_access'], // No `homePageId`: the landing page IS the first navigation item (by `order`), - // and the ROOT landing follows `isDefault`. The key was removed in 17.0.0 (#4667) - // because nothing ever read it. + // and the ROOT landing follows `isDefault`. The key was removed in 17.0.0 + // (#4667, #4709) — it did have a consumer, but it pointed at a navigation item + // by id and fell back silently when that id dangled. }; ``` diff --git a/docs/audits/2026-06-appschema-property-liveness.md b/docs/audits/2026-06-appschema-property-liveness.md index 77ff9f4dd3..9505bd632f 100644 --- a/docs/audits/2026-06-appschema-property-liveness.md +++ b/docs/audits/2026-06-appschema-property-liveness.md @@ -18,5 +18,17 @@ These are the highest-priority fixes (authoring them per spec fails; they only w `name`, `label`, `description`, `icon`, `active`, `isDefault`, `hidden`, `navigation` (whole tree), `areas` (precedence over navigation), `contextSelectors` (all sub-fields), `homePageId`, `defaultAgent` (dual consumer: framework `agent-runtime.ts:341` + objectui chatbot), `branding.{primaryColor,favicon}`, `protection`. Nav-item union fully live: `id/label/icon/order/badge/visible(CEL)/requiredPermissions/requiresObject/requiresService` + per-type payloads (`objectName/viewName/recordId/recordMode/dashboardName/pageName/url/target/reportName/componentRef/params/children/expanded`). NavigationContribution (ADR-0029) live via `objectql/engine.ts:912`. - PARTIAL: `App.requiredPermissions` (app-entry gate not observed; only nav-item perms enforced), nav `type:'action'` `actionDef` (fires `onAction(item)`; `actionDef.{actionName,params}` shape read loosely in the action runtime, 0 direct grep in shell). +> **Later (2026-08-03, #4709) — `homePageId` is retired; this row's LIVE verdict was right.** +> #4667 removed `app.homePageId` in spec 17.0.0 justifying it with "no shell ever read it" — which +> **contradicted this audit** and was false: objectui's console read it in `resolveLandingRoute()` +> (`packages/app-shell/src/console/AppContent.tsx`, @785b8a5d), exactly as recorded above. #4709 +> **upheld the removal on a different reason** and corrected the copy everywhere it appeared: the key +> encoded the landing page as an ID cross-reference into `navigation` with no referential integrity, +> silently falling back to the first item when it dangled. Post-v17 the landing page is the first +> navigation item; if the capability returns it belongs on the navigation item itself +> (`navigation[].landing`), enforce-first. Nothing above is amended — it is kept as written, because +> the failure it exposes is procedural: **a retirement citing liveness must reconcile against the +> existing audit record**, and for two months nobody noticed these two documents disagreed. + ## Recommendation Add the 3 drift fields to the spec (`accentColor`, `badgeVariant`, `separator`) **or** stop the renderer reading them. Prune the aspirational block; `App.sharing`/`App.embed`/`apiEnabled`-style props create a false security/feature impression. diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 918e9ab696..a78eec4297 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -199,7 +199,7 @@ The same pass closes `activationEvents` (#4657): both keys that carried it — ` | `flow-node-script-config-aliases` | `flow.node.script.config` | script flow-node config keys 'functionName' → 'function', 'input' → 'inputs' (#3796) | live — protocol 17 loader accepts the old shape | | `permission-rls-priority-removed` | `permission.rowLevelSecurity.priority` | RLS-policy key 'priority' removed (#3896 audit — policies OR-combine, so the promised conflict-resolution semantics cannot exist; dropping it changes no outcome) | retired — `migrate meta` only | | `tool-inert-authoring-keys-removed` | `tool.category / tool.permissions / tool.active / tool.builtIn` | tool keys 'category'/'permissions'/'active'/'builtIn' removed (#3896 close-out — authorable and inert; permissions gated nothing, active:false withdrew nothing) | retired — `migrate meta` only | -| `app-dead-authoring-keys-removed` | `app.version / app.aria / app.objects / app.apis / app.sharing / app.embed / app.mobileNavigation / app.contextSelectors.includeAll / app.contextSelectors.placement / app.homePageId / app.areas.order` | app keys 'version'/'aria'/'objects'/'apis'/'sharing'/'embed'/'mobileNavigation'/'homePageId' plus contextSelectors 'includeAll'/'placement' and areas 'order' removed (liveness audits #4001, #4509, #4667 — never read; sharing/embed declared a public surface no route enforced, mobileNavigation was fully unimplemented, includeAll was deliberately disobeyed because an 'All' row would clear a mandatory scope, the landing page IS the first nav item, and no renderer ever sorted areas) | retired — `migrate meta` only | +| `app-dead-authoring-keys-removed` | `app.version / app.aria / app.objects / app.apis / app.sharing / app.embed / app.mobileNavigation / app.contextSelectors.includeAll / app.contextSelectors.placement / app.homePageId / app.areas.order` | app keys 'version'/'aria'/'objects'/'apis'/'sharing'/'embed'/'mobileNavigation'/'homePageId' plus contextSelectors 'includeAll'/'placement' and areas 'order' removed (liveness audits #4001, #4509, #4667 — unread or wrongly encoded; sharing/embed declared a public surface no route enforced, mobileNavigation was fully unimplemented, includeAll was deliberately disobeyed because an 'All' row would clear a mandatory scope, homePageId WAS read by objectui's console before v17 but encoded the landing page as an ID cross-reference that silently fell back when it dangled — the landing page is the first nav item (premise corrected in #4709; the retirement stands), and no renderer ever sorted areas) | retired — `migrate meta` only | | `app-area-fail-open-gates-removed` | `app.areas.visible / app.areas.requiredPermissions` | navigation-area keys 'visible'/'requiredPermissions' removed (#4651, ADR-0049 — FAIL-OPEN access gates: no layer ever read them, so a 'hidden' or permission-gated area was served and rendered to every user, while the identically named keys on a navigation ITEM and on the APP are enforced; gate the items inside the area, or gate the app) | retired — `migrate meta` only | | `field-required-notnull-explicit` | `object.fields.*.required / object.fields.*.storage.notNull` | required fields gain explicit 'storage.notNull: true' (ADR-0113 — pre-17 'required' implied the column constraint; post-17 it is only the write contract) | retired — `migrate meta` only | | `action-inert-keys-removed` | `action.shortcut / action.bulkEnabled` | action keys 'shortcut'/'bulkEnabled' removed (#3896 close-out — no keydown path dispatches shortcuts; the multi-select toolbar reads the view's bulkActions) | retired — `migrate meta` only | diff --git a/examples/app-showcase/src/ui/apps/index.ts b/examples/app-showcase/src/ui/apps/index.ts index 96edd46fa4..3b64b2ac41 100644 --- a/examples/app-showcase/src/ui/apps/index.ts +++ b/examples/app-showcase/src/ui/apps/index.ts @@ -25,8 +25,8 @@ export const ShowcaseApp = App.create({ branding: { primaryColor: '#7C3AED' }, navigation: [ - // First item = the app's landing surface (the delivered convention; the - // spec's homePageId has no console consumer yet). + // First item = the app's landing surface — the only way to say it since + // `homePageId` was removed in spec 17.0.0 (#4667, #4709). { id: 'nav_capability_map', type: 'page', pageName: 'showcase_capability_map', label: 'Capability Map', icon: 'map' }, { id: 'nav_start_here', type: 'page', pageName: 'showcase_start_here', label: 'Page Authoring', icon: 'compass' }, { diff --git a/packages/spec/liveness/app.json b/packages/spec/liveness/app.json index 9dc223a60b..1c2e750aaf 100644 --- a/packages/spec/liveness/app.json +++ b/packages/spec/liveness/app.json @@ -228,7 +228,7 @@ "homePageId": { "status": "dead", "verifiedAt": "2026-08-02", - "note": "REMOVED 2026-08-02 (#4667) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent). No shell ever read it — the landing IS the first navigation item (by order), and the ROOT landing follows isDefault routing (objectui RootLandingRedirect). The schema own hedge (\"if not set, usually defaults to the first navigation item\") described the only behavior that existed. Reorder navigation, or set isDefault, instead. The three aliases that routed here (home / homepage / landingpage) were removed from AppSchema aliases and now carry the same prescription as guidance." + "note": "REMOVED 2026-08-02 (#4667) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent). PREMISE CORRECTED 2026-08-03 (#4709), removal upheld: #4667 justified this row with \"no shell ever read it\", which was FALSE and contradicted this repo's own 2026-06 AppSchema liveness audit (docs/audits/2026-06-appschema-property-liveness.md), which listed homePageId LIVE. objectui's console DID read it — resolveLandingRoute(), packages/app-shell/src/console/AppContent.tsx @785b8a5d — and it was the only thing deciding where an app opened. The key is retired for its SHAPE: an ID cross-reference into navigation with no referential integrity, silently falling back to the first item when it dangled. Post-v17 the landing IS the first navigation item (by order), and the ROOT landing follows isDefault routing (objectui RootLandingRedirect). Reorder navigation, or set isDefault, instead; if the capability returns it belongs on the navigation item itself (navigation[].landing), enforce-first. The three aliases that routed here (home / homepage / landingpage) were removed from AppSchema aliases and now carry the same prescription as guidance. Process lesson: a retirement citing liveness MUST reconcile against existing audit records — this contradiction went unnoticed for two months." }, "requiredPermissions": { "status": "live", diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 3f69fef82f..493d99d7d2 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -148,7 +148,7 @@ }, { "surface": "app.version / app.aria / app.objects / app.apis / app.sharing / app.embed / app.mobileNavigation / app.contextSelectors.includeAll / app.contextSelectors.placement / app.homePageId / app.areas.order", - "to": "app keys 'version'/'aria'/'objects'/'apis'/'sharing'/'embed'/'mobileNavigation'/'homePageId' plus contextSelectors 'includeAll'/'placement' and areas 'order' removed (liveness audits #4001, #4509, #4667 — never read; sharing/embed declared a public surface no route enforced, mobileNavigation was fully unimplemented, includeAll was deliberately disobeyed because an 'All' row would clear a mandatory scope, the landing page IS the first nav item, and no renderer ever sorted areas)", + "to": "app keys 'version'/'aria'/'objects'/'apis'/'sharing'/'embed'/'mobileNavigation'/'homePageId' plus contextSelectors 'includeAll'/'placement' and areas 'order' removed (liveness audits #4001, #4509, #4667 — unread or wrongly encoded; sharing/embed declared a public surface no route enforced, mobileNavigation was fully unimplemented, includeAll was deliberately disobeyed because an 'All' row would clear a mandatory scope, homePageId WAS read by objectui's console before v17 but encoded the landing page as an ID cross-reference that silently fell back when it dangled — the landing page is the first nav item (premise corrected in #4709; the retirement stands), and no renderer ever sorted areas)", "conversionId": "app-dead-authoring-keys-removed", "toMajor": 17 }, @@ -758,7 +758,7 @@ }, { "surface": "app.version / app.aria / app.objects / app.apis / app.sharing / app.embed / app.mobileNavigation / app.contextSelectors.includeAll / app.contextSelectors.placement / app.homePageId / app.areas.order", - "to": "app keys 'version'/'aria'/'objects'/'apis'/'sharing'/'embed'/'mobileNavigation'/'homePageId' plus contextSelectors 'includeAll'/'placement' and areas 'order' removed (liveness audits #4001, #4509, #4667 — never read; sharing/embed declared a public surface no route enforced, mobileNavigation was fully unimplemented, includeAll was deliberately disobeyed because an 'All' row would clear a mandatory scope, the landing page IS the first nav item, and no renderer ever sorted areas)", + "to": "app keys 'version'/'aria'/'objects'/'apis'/'sharing'/'embed'/'mobileNavigation'/'homePageId' plus contextSelectors 'includeAll'/'placement' and areas 'order' removed (liveness audits #4001, #4509, #4667 — unread or wrongly encoded; sharing/embed declared a public surface no route enforced, mobileNavigation was fully unimplemented, includeAll was deliberately disobeyed because an 'All' row would clear a mandatory scope, homePageId WAS read by objectui's console before v17 but encoded the landing page as an ID cross-reference that silently fell back when it dangled — the landing page is the first nav item (premise corrected in #4709; the retirement stands), and no renderer ever sorted areas)", "conversionId": "app-dead-authoring-keys-removed", "toMajor": 17 }, diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 14a232ad14..fb4ca25be6 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -1620,7 +1620,7 @@ const appDeadAuthoringKeysRemoved: MetadataConversion = { 'app.version / app.aria / app.objects / app.apis / app.sharing / app.embed / ' + 'app.mobileNavigation / app.contextSelectors.includeAll / app.contextSelectors.placement / ' + 'app.homePageId / app.areas.order', - summary: "app keys 'version'/'aria'/'objects'/'apis'/'sharing'/'embed'/'mobileNavigation'/'homePageId' plus contextSelectors 'includeAll'/'placement' and areas 'order' removed (liveness audits #4001, #4509, #4667 — never read; sharing/embed declared a public surface no route enforced, mobileNavigation was fully unimplemented, includeAll was deliberately disobeyed because an 'All' row would clear a mandatory scope, the landing page IS the first nav item, and no renderer ever sorted areas)", + summary: "app keys 'version'/'aria'/'objects'/'apis'/'sharing'/'embed'/'mobileNavigation'/'homePageId' plus contextSelectors 'includeAll'/'placement' and areas 'order' removed (liveness audits #4001, #4509, #4667 — unread or wrongly encoded; sharing/embed declared a public surface no route enforced, mobileNavigation was fully unimplemented, includeAll was deliberately disobeyed because an 'All' row would clear a mandatory scope, homePageId WAS read by objectui's console before v17 but encoded the landing page as an ID cross-reference that silently fell back when it dangled — the landing page is the first nav item (premise corrected in #4709; the retirement stands), and no renderer ever sorted areas)", apply(stack, emit) { const RETIRED = [ 'version', 'aria', 'objects', 'apis', 'sharing', 'embed', 'mobileNavigation', diff --git a/packages/spec/src/ui/app.test.ts b/packages/spec/src/ui/app.test.ts index 3faaa5bc09..c415fa1b5b 100644 --- a/packages/spec/src/ui/app.test.ts +++ b/packages/spec/src/ui/app.test.ts @@ -1298,9 +1298,9 @@ describe('unknown keys are rejected, not stripped (#4001 PR B)', () => { describe('retired app keys (#4667)', () => { it('rejects `homePageId` and names what actually decides the landing page', () => { // Tombstoned (retiredKey), matching the seven #4142 retirements on this - // schema — so it is a tsc error as well as a parse error. The schema's own - // hedge ("if not set, usually defaults to the first navigation item") had - // been describing the only behaviour that ever existed. + // schema — so it is a tsc error as well as a parse error. Post-v17 the + // landing page really is the first navigation item; what the message must + // NOT do is explain *why* by claiming nobody read the key (see below). const parse = () => AppSchema.parse({ name: 'crm', label: 'CRM', homePageId: 'nav_pipeline', navigation: [{ id: 'nav_home', type: 'object', label: 'Home', objectName: 'account' }], @@ -1310,6 +1310,28 @@ describe('unknown keys are rejected, not stripped (#4001 PR B)', () => { expect(parse).toThrow(/isDefault/s); }); + it('the tombstone states the REAL reason, not the false "no shell read it" premise (#4709)', () => { + // #4667 shipped this tombstone claiming "no shell ever read it". It was + // false — objectui's console read the key in `resolveLandingRoute()` + // (packages/app-shell/src/console/AppContent.tsx @785b8a5d) and it was the + // only thing deciding where an app opened — and it contradicted this + // repo's own 2026-06 AppSchema liveness audit, which listed the key LIVE. + // The removal stands (#4709 ruling B): the key encoded the landing page as + // an ID cross-reference that silently fell back when it dangled. Pinned + // because a tombstone is what the next reader reasons FROM: this sentence + // already sent one session to the renderer to discover it was wrong, and a + // regression would re-arm exactly that trap. + const parse = () => AppSchema.parse({ + name: 'crm', label: 'CRM', homePageId: 'nav_pipeline', + navigation: [{ id: 'nav_home', type: 'object', label: 'Home', objectName: 'account' }], + }); + expect(parse).not.toThrow(/no shell ever read/i); + expect(parse).toThrow(/did read it/i); + expect(parse).toThrow(/resolveLandingRoute/); + // The prescription is unchanged by the correction — reorder, or isDefault. + expect(parse).toThrow(/reorder `navigation`/s); + }); + it('routes the three retired homePageId aliases to the same prescription', () => { for (const alias of ['home', 'homepage', 'landingpage']) { expect(unknownKeyIssue(AppSchema, { name: 'crm', label: 'CRM', [alias]: 'nav_x' })!.message) diff --git a/packages/spec/src/ui/app.zod.ts b/packages/spec/src/ui/app.zod.ts index fc04e86579..23e3518c70 100644 --- a/packages/spec/src/ui/app.zod.ts +++ b/packages/spec/src/ui/app.zod.ts @@ -973,19 +973,39 @@ const APP_KEYS = [ ] as const; /** - * `app.homePageId`, retired in 17.0.0 (#4667, ADR-0049). + * `app.homePageId`, retired in 17.0.0 (#4667, ADR-0049) — **premise corrected in + * #4709**, retirement itself upheld. * - * The schema's own hedge gave it away — "if not set, usually defaults to the - * first navigation item" describes the ONLY behaviour that exists. No shell - * reads the key: an app's landing page is its first navigation item in `order`, - * and the ROOT landing follows `isDefault` routing (objectui's - * `RootLandingRedirect`). So an author pinning a home page got the first nav - * item anyway, and "usually" was doing the work of "always". + * #4667 retired the key saying "no shell ever read it". That was FALSE, and this + * repo's own record already said so: the 2026-06 AppSchema liveness audit + * (`docs/audits/2026-06-appschema-property-liveness.md`) listed `homePageId` on + * the LIVE side, because objectui's console read it — + * `resolveLandingRoute()`, `packages/app-shell/src/console/AppContent.tsx` + * (objectui @785b8a5d) — and it was the only thing deciding where an app opened. + * A tombstone is what the next reader reasons from, so a false reason in one is + * not cosmetic: #4709 was opened by someone who believed this sentence and only + * then checked the renderer. + * + * What actually condemns the key is its SHAPE, not disuse. It encoded the + * landing page as an ID cross-reference into `navigation` with no referential + * integrity — a dangling id fell back to the first item *silently* (that is + * literally what `resolveLandingRoute` did) — so one fact had two sources and + * the wrong one failed quietly. If "land somewhere other than first" is ever + * wanted again, it belongs on the navigation item itself (a + * `navigation[].landing`-shaped marker: single source, cannot dangle), designed + * enforce-first — renderer and tests before schema. Until then an app's landing + * page IS its first navigation item in `order`, and the ROOT landing follows + * `isDefault` routing (objectui's `RootLandingRedirect`, which was always + * correct here). Retiring the key left a dead `if (homePageId)` branch in + * objectui, tracked for removal in objectstack-ai/objectui#3264. */ const HOME_PAGE_ID_RETIRED = - '`app.homePageId` was removed in @objectstack/spec 17.0.0 (#4667, ADR-0049) — no shell ' - + 'ever read it. An app\'s landing page IS its first navigation item (by `order`), and the ' - + 'root landing follows `isDefault` routing. Delete the key; to change where an app opens, ' + '`app.homePageId` was removed in @objectstack/spec 17.0.0 (#4667, #4709, ADR-0049). ' + + 'objectui\'s console did read it before v17 (`resolveLandingRoute`), so this key had a ' + + 'consumer — it was retired because the capability is better expressed on the navigation ' + + 'item itself than as an ID cross-reference that silently falls back when it dangles. An ' + + 'app\'s landing page IS its first navigation item (by `order`), and the root landing ' + + 'follows `isDefault` routing. Delete the key; to change where an app opens, ' + 'reorder `navigation` so the intended entry is first, and set `isDefault` on the app that ' + 'should own the root landing. Run `os migrate meta --from 16` to rewrite existing sources ' + 'automatically.';