From 756552967a6356e904aae1df652c160738cf5be9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 23:01:30 +0000 Subject: [PATCH 01/12] wip(console): open an app on its declared per-audience landing Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibw9TMGcWfAYsNMXXRo --- .../app-shell/src/console/AppContent.tsx | 67 ++++++++---- .../src/console/appDeclaredLanding.ts | 102 ++++++++++++++++++ .../spec-object-refinements-7715.test.ts | 49 +++++++-- ui/packages/types/src/zod/app.zod.ts | 12 ++- 4 files changed, 204 insertions(+), 26 deletions(-) create mode 100644 ui/packages/app-shell/src/console/appDeclaredLanding.ts diff --git a/ui/packages/app-shell/src/console/AppContent.tsx b/ui/packages/app-shell/src/console/AppContent.tsx index 79aa0fdb6d1..fc8ffbaabf4 100644 --- a/ui/packages/app-shell/src/console/AppContent.tsx +++ b/ui/packages/app-shell/src/console/AppContent.tsx @@ -58,6 +58,7 @@ import { RouteFader } from '../chrome/RouteFader.js'; import { NavigationSyncEffect } from '../hooks/useNavigationSync.js'; import { AppNoAccessEmptyState } from './AppNoAccessEmptyState.js'; import { appNamesOnlyUnreadableObjects, appServesNoNavigation } from './appServesNoNavigation.js'; +import { resolveDeclaredLandingRoute, type LandingVisibilityEvaluator } from './appDeclaredLanding.js'; import { useNavMemberGates, navEntriesShownToMember, @@ -1711,25 +1712,40 @@ function buildItemRoute(item: any, ctx?: NavTemplateContext): string { /** * Resolves the route to navigate to when the user lands on the bare - * `/console/apps/:appName` URL: the app's landing page is its FIRST reachable - * navigation item, in `order`, among the entries the member is shown - * (`navigation` arrives already gated, objectui#12426) — `findFirstRoute` walks groups and lands on an - * `object`, `page`, `dashboard`, `report` or `component` entry (objectui#12113 - * added `component`). It skips the item types that address nothing inside - * the app (`url`, `separator`, `action`), and a `doc` entry is never the - * landing (objectui#11197). Which app the ROOT lands on is a separate - * question, decided by `isDefault`. + * `/console/apps/:appName` URL. * - * This used to honour an explicit `app.homePageId`. Spec 17.0.0 retired that - * key (objectstack#4667, premise corrected in objectstack-ai/objectstack#4709 / objectui#3287): it was - * an ID cross-reference with no referential integrity, so a dangling id fell - * back to the first item *silently*. If the capability returns, the correct - * encoding is a flag on the navigation item itself (`navigation[].landing`), - * which cannot dangle — hence this function is kept as the seam even though it - * now has a single expression. + * The app's declared per-audience landing comes first (`AppSchema.landing`, + * objectstack-ai/objectstack#28022, ruling record 6545223246, letter A): the + * first entry whose `visible` holds for this member, asked with the + * navigation's own `visible` evaluator, opens the navigation entry its `item` + * names, when the member is shown that entry. The rules, the predicate's scope + * and what a fault does are `resolveDeclaredLandingRoute`'s + * (`appDeclaredLanding.ts`). + * + * With no declared landing, or none that lands, the app's landing page is its + * FIRST reachable navigation item, in `order`, among the entries the member is + * shown (`navigation` arrives already gated, objectui#12426) — `findFirstRoute` + * walks groups and lands on an `object`, `page`, `dashboard`, `report` or + * `component` entry (objectui#12113 added `component`). It skips the item + * types that address nothing inside the app (`url`, `separator`, `action`), + * and a `doc` entry is never that fallback landing (objectui#11197). Which app + * the ROOT lands on is a separate question, decided by `isDefault`. + * + * The retired `app.homePageId` (spec 17.0.0, objectstack#4667) is not read: it + * was an id cross-reference that fell back to the first item in silence when + * it dangled. A `landing` entry's `item` is refused at parse when it names no + * entry of the app (`checkAppLandingItems`), so it cannot dangle. */ -function resolveLandingRoute(navigation: NavigationItem[], ctx?: NavTemplateContext): string { - return findFirstRoute(navigation, ctx); +function resolveLandingRoute( + app: { landing?: unknown } | null | undefined, + shownTrees: readonly NavigationItem[][], + evaluateVisibility: LandingVisibilityEvaluator, + ctx?: NavTemplateContext, +): string { + const declared = resolveDeclaredLandingRoute(app?.landing, shownTrees, evaluateVisibility, (item) => + buildItemRoute(item, ctx), + ); + return declared || findFirstRoute(shownTrees[0] ?? [], ctx); } /** @@ -1809,12 +1825,19 @@ function AppRootLanding({ return ; } const navigation = navEntriesShownToMember(app?.navigation || [], gates); - const landing = resolveLandingRoute(navigation, ctx); const areas = (app?.areas || []) as Array<{ navigation?: NavigationItem[] } | null | undefined>; const shownApp = { navigation, areas: areas.map((area) => ({ navigation: navEntriesShownToMember(area?.navigation || [], gates) })), }; + // objectstack-ai/objectstack#28022 — a declared landing entry may name an + // entry of any tree the member is shown, `navigation` first. + const landing = resolveLandingRoute( + app, + [navigation, ...shownApp.areas.map((area) => area.navigation)], + gates.evaluateVisibility, + ctx, + ); // objectui#13097 — the same questions, as if every pending read showed its // entry. A different answer means a pending read is still deciding it. const undecided = navEntriesShownOncePendingReadsShow(app?.navigation || [], gates); @@ -1822,8 +1845,14 @@ function AppRootLanding({ navigation: undecided, areas: areas.map((area) => ({ navigation: navEntriesShownOncePendingReadsShow(area?.navigation || [], gates) })), }; + const undecidedLanding = resolveLandingRoute( + app, + [undecided, ...undecidedApp.areas.map((area) => area.navigation)], + gates.evaluateVisibility, + ctx, + ); if ( - resolveLandingRoute(undecided, ctx) !== landing || + undecidedLanding !== landing || (!landing && appServesNoNavigation(shownApp) !== appServesNoNavigation(undecidedApp)) ) { return ( diff --git a/ui/packages/app-shell/src/console/appDeclaredLanding.ts b/ui/packages/app-shell/src/console/appDeclaredLanding.ts new file mode 100644 index 00000000000..e620c3dd014 --- /dev/null +++ b/ui/packages/app-shell/src/console/appDeclaredLanding.ts @@ -0,0 +1,102 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The app's declared per-audience landing, `@objectstack/spec`'s + * `AppSchema.landing` (objectstack-ai/objectstack#28022, ruling record + * 6545223246, letter A): `[{ item, visible }]`, asked in order, and the first + * entry whose `visible` holds for the reader names the navigation entry the app + * opens on. + * + * ## The scope a landing predicate is asked in + * + * The evaluator is the one the app's navigation `visible` is asked with: the + * `evaluateVisibility` of `useNavMemberGates`, called with no navigation entry, + * so the predicate sees exactly the roots `buildExpressionScope` + * (`providers/ExpressionProvider.tsx`) binds for the shell, and nothing else. + * A landing entry declares no `dataSource`, so no `data` is bound for it (the + * spec refuses a `data` read at parse, `checkAppLandingItems`). A predicate + * that names a root that scope does not bind faults in the engine, and so does + * any other predicate the engine cannot answer. + * + * ## What a fault does + * + * A faulting predicate is passed over, and reported once, by the navigation + * evaluator's own fault report (`evaluateNavigationVisibility`, which + * withholds on a fault). The landing only decides where a reader opens, never + * what they may reach: every navigation entry keeps its own gates. + * + * ## Which entries can be landed on + * + * The entry `item` names is looked up, by `id` and at any depth, in the trees + * as this member is SHOWN them (the app's `navigation` first, then each of its + * `areas[].navigation`): an entry the member's own navigation gates hide is + * passed over, as the spec's `AppSchema.landing` docblock states. An entry + * whose route the console cannot open inside the app (`routeOf` answers `''`: + * a `url` entry, or an entry with no target) is passed over too. When no entry + * lands, the caller keeps today's rule: the first navigation entry by `order`. + * + * Internal module: not re-exported by the package entry. + */ + +import type { NavigationItem } from '@object-ui/types'; + +/** The `visible` evaluator of `useNavMemberGates`, asked here with no entry. */ +export type LandingVisibilityEvaluator = (expression: NavigationItem['visible']) => boolean; + +/** One entry of `AppSchema.landing`, as the served app carries it. */ +interface LandingEntry { + item: string; + visible: NavigationItem['visible']; +} + +/** The first entry with `id` in `items`, at any depth through `children`. */ +function findEntryById(items: readonly NavigationItem[], id: string): NavigationItem | undefined { + for (const item of items) { + if (item.id === id) return item; + const children = (item as { children?: unknown }).children; + if (Array.isArray(children)) { + const found = findEntryById(children as NavigationItem[], id); + if (found) return found; + } + } + return undefined; +} + +function isLandingEntry(value: unknown): value is LandingEntry { + return !!value && typeof value === 'object' && typeof (value as { item?: unknown }).item === 'string'; +} + +/** + * The route of the first declared landing entry that holds for this member, + * or `''` when the app declares none, or none of its entries lands. + * + * `shownTrees` are the app's navigation trees as the member is shown them, + * `navigation` first; `routeOf` is the in-app route of one entry, `''` when it + * opens nothing inside the app. + */ +export function resolveDeclaredLandingRoute( + landing: unknown, + shownTrees: readonly (readonly NavigationItem[])[], + evaluateVisibility: LandingVisibilityEvaluator, + routeOf: (item: NavigationItem) => string, +): string { + if (!Array.isArray(landing)) return ''; + for (const entry of landing) { + if (!isLandingEntry(entry)) continue; + if (!evaluateVisibility(entry.visible)) continue; + for (const tree of shownTrees) { + const target = findEntryById(tree, entry.item); + if (!target) continue; + const route = routeOf(target); + if (route) return route; + break; + } + } + return ''; +} diff --git a/ui/packages/types/src/__tests__/spec-object-refinements-7715.test.ts b/ui/packages/types/src/__tests__/spec-object-refinements-7715.test.ts index 2a56b156084..0d465fa4126 100644 --- a/ui/packages/types/src/__tests__/spec-object-refinements-7715.test.ts +++ b/ui/packages/types/src/__tests__/spec-object-refinements-7715.test.ts @@ -41,7 +41,7 @@ import { NavigationAreaSchema as SpecNavigationAreaSchema, PageSchema as SpecPageSchema, } from '@objectstack/spec/ui'; -import { ListViewSchema, PageNodeSchema, safeValidateSchema } from '../zod/index.zod'; +import { AppComponentSchema, ListViewSchema, PageNodeSchema, safeValidateSchema } from '../zod/index.zod'; import { asPageListView, bagPath } from './list-view-page-node'; import { DashboardWidgetSchema, GlobalFilterSchema } from '../zod/complex.zod'; @@ -318,11 +318,11 @@ const SITES = [ { site: 'SpecAppFields → AppComponentSchema (app.zod.ts)', spec: SpecAppSchema, - attached: [], - // `checkAppLandingItems` joined the spec object with `landing` (objectstack#28022, spec half): - // a landing `item` must name an openable entry of the app's own navigation. OWED TO the - // follow-up domain:ui landing card, which attaches it here together with the console redirect. - notAttachable: ['checkAppLandingItems'], + // `checkAppLandingItems` joined the spec object with `landing` (objectstack#28022, spec half) + // and was attached by objectstack#28450 with the console's landing redirect: a landing `item` + // must name an openable entry of the app's own navigation, and its `visible` reads no `data`. + attached: ['checkAppLandingItems'], + notAttachable: [], }, { site: 'DashboardWidgetSchema (complex.zod.ts)', @@ -551,3 +551,40 @@ describe('objectui#11073 — the DashboardWidget checks reach objectui\'s door w }); }); +describe('objectstack#28450 — the App `landing` refusals reach objectui\'s app node with the spec\'s own issue', () => { + const navigation = [ + { id: 'nav_leads', type: 'object', objectName: 'crm_lead', label: 'Leads' }, + { id: 'grp_service', type: 'group', label: 'Service', children: [{ id: 'nav_cases', type: 'object', objectName: 'crm_case', label: 'Cases' }] }, + ]; + const specApp = (landing: unknown[]) => ({ name: 'crm', label: 'CRM', navigation, landing }); + const appNode = (landing: unknown[]) => ({ type: 'app', name: 'crm', navigation, landing }); + const agents = "'service_agent' in current_user.positions"; + + it.each([ + ['an `item` that names no entry of the app', [{ item: 'nav_missing', visible: agents }], 'landing.0.item'], + ['an `item` that names a `group`', [{ item: 'grp_service', visible: agents }], 'landing.0.item'], + ['a `visible` that reads `data`', [{ item: 'nav_cases', visible: 'data.total > 0' }], 'landing.0.visible'], + ])('%s: the spec refuses it, and objectui refuses with the spec\'s own issue, mirror and published door', (_label, landing, at) => { + const spec = SpecAppSchema.safeParse(specApp(landing)); + expect(spec.success).toBe(false); + const specIssue = spec.error!.issues.find((i) => i.path.join('.') === at); + expect(specIssue?.code).toBe('custom'); + + for (const r of [AppComponentSchema.safeParse(appNode(landing)), safeValidateSchema(appNode(landing))]) { + expect(r.success).toBe(false); + expect(r.error!.issues.map((i) => ({ code: i.code, path: i.path.join('.'), message: i.message }))) + .toContainEqual({ code: 'custom', path: at, message: specIssue!.message }); + } + }); + + it('controls: a landing on an entry inside a group, and an app with no landing, pass on the spec and both doors', () => { + for (const landing of [[{ item: 'nav_cases', visible: agents }], undefined]) { + const spec = landing ? specApp(landing) : { name: 'crm', label: 'CRM', navigation }; + const node = landing ? appNode(landing) : { type: 'app', name: 'crm', navigation }; + expect(SpecAppSchema.safeParse(spec).success).toBe(true); + const mirror = AppComponentSchema.safeParse(node); + expect(mirror.success, JSON.stringify(mirror.error?.issues)).toBe(true); + expect(safeValidateSchema(node).success).toBe(true); + } + }); +}); diff --git a/ui/packages/types/src/zod/app.zod.ts b/ui/packages/types/src/zod/app.zod.ts index 989b5093af3..5057ed3b09c 100644 --- a/ui/packages/types/src/zod/app.zod.ts +++ b/ui/packages/types/src/zod/app.zod.ts @@ -33,6 +33,9 @@ import { // RAW for the same reason: the spec's own refinement function, chained on // every navigation arm below (objectui#13076). navItemVisibleDataNeedsDataSource, + // RAW for the same reason: the spec's own `AppSchema.landing` check, chained + // on `AppComponentSchema` below (objectstack-ai/objectstack#28450). + checkAppLandingItems, } from '@objectstack/spec/ui'; import { EvaluatedExpressionInputSchema as SpecEvaluatedExpressionInputSchema, @@ -803,7 +806,14 @@ export const AppComponentSchema = BaseSchema.extend(SpecAppFields.shape).extend( 'No renderer read app-level ARIA attributes. Declare `aria` on the page component that renders the DOM node ' + '(`page.components[].aria`; `page.aria` and the list view\'s `aria` are live too). Delete the key.', ), -}); +}) + // The spec's own object-level check for `landing` (objectstack-ai/objectstack#28450, + // objectui#7715's rule): a landing `item` must name an entry of this app's own + // `navigation` or `areas[].navigation` that opens something, and its `visible` + // reads no `data`. `landing` flows in by reference through `SpecAppFields`; this + // node's `navigation` / `areas` are its own, and carry the same `id` / `type` the + // check reads. `__tests__/spec-object-refinements-7715.test.ts` re-derives the count. + .superRefine(checkAppLandingItems); /** * objectui#11440 — ONE refusal string for both content channels of From 085f4a83c67526add2f2e56924cfec4bf7e5b668 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 23:08:15 +0000 Subject: [PATCH 02/12] wip(console): pin the declared landing through AppContent Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibw9TMGcWfAYsNMXXRo --- .../AppContent.declaredLanding-28450.test.tsx | 297 ++++++++++++++++++ 1 file changed, 297 insertions(+) create mode 100644 ui/packages/app-shell/src/console/__tests__/AppContent.declaredLanding-28450.test.tsx diff --git a/ui/packages/app-shell/src/console/__tests__/AppContent.declaredLanding-28450.test.tsx b/ui/packages/app-shell/src/console/__tests__/AppContent.declaredLanding-28450.test.tsx new file mode 100644 index 00000000000..5be164a9f0b --- /dev/null +++ b/ui/packages/app-shell/src/console/__tests__/AppContent.declaredLanding-28450.test.tsx @@ -0,0 +1,297 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * An app opens on its declared per-audience landing: the first + * `AppSchema.landing` entry whose `visible` holds for the member + * (objectstack-ai/objectstack#28450; the key is objectstack-ai/objectstack#28022, + * ruling record 6545223246, letter A). + * + * What is pinned here, through the real `AppContent`, the real + * `ExpressionProvider` it mounts and the real `MePermissionsProvider`: + * - two entries, the first predicate false and the second true: the app opens + * on the second entry's item, which is not the first navigation item; + * - no entry holds: the first navigation item by `order`, today's rule; + * - a predicate that faults is passed over, and reported once with the + * navigation gate's `withholding` face; + * - the roots: the predicate reads the scope navigation `visible` reads + * (`current_user` and its `user` / `ctx.user` / `os.user` aliases); a root + * outside it (`record`, `app`) faults and is passed over; + * - an entry whose item the member's own navigation gates hide is passed over; + * - an item inside a group, and an item of `areas[].navigation`, are landed on. + * + * NOTE ON SCOPE: routing only. `ConsoleLayout` and the lazily imported pages + * are stubbed, as in `AppContent.landingMemberGates-12426.test.tsx`. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom'; +import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions'; + +// AGENTS.md §测试纪律 — the lazy page modules are stubbed so no unbounded +// `import()` races a bounded `findBy` window. +vi.mock('../../views/metadata-admin', () => ({ + MetadataDirectoryPage: () =>
, + MetadataResourceListPage: () =>
, + MetadataResourceEditPage: () =>
, + MetadataResourceHistoryPage: () =>
, + MetadataDiagnosticsPage: () =>
, +})); + +vi.mock('@object-ui/plugin-designer', async (importOriginal) => ({ + ...(await importOriginal()), + CreateAppPage: () =>
, + EditAppPage: () =>
, + DashboardDesignPage: () =>
, +})); + +vi.mock('../../layout/ConsoleLayout', () => ({ + ConsoleLayout: ({ activeAppName, children }: { activeAppName?: string; children?: React.ReactNode }) => ( +
+ {children} +
+ ), +})); +vi.mock('../../chrome/CommandPalette', () => ({ CommandPalette: () => null })); +vi.mock('../../chrome/KeyboardShortcutsDialog', () => ({ KeyboardShortcutsDialog: () => null })); +vi.mock('../../chrome/OnboardingWalkthrough', () => ({ OnboardingWalkthrough: () => null })); +vi.mock('../../views/ObjectView', () => ({ + ObjectView: () =>
, +})); +vi.mock('../../views/DashboardView', () => ({ + DashboardView: () =>
, +})); + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ + t: (key: string, options?: Record) => + String(options?.defaultValue ?? key).replace(/\{\{(\w+)\}\}/g, (_m, name: string) => String(options?.[name] ?? '')), + }), + useObjectLabel: () => ({ + objectLabel: ({ label }: { label?: string }) => label, + objectPluralLabel: ({ label }: { label?: string }) => label, + appLabel: ({ name, label }: { name: string; label?: string }) => label ?? name, + }), +})); + +/** The viewer under test: a `member` by default; positions name the audience. */ +let viewerRole = 'member'; +let viewerPositions: string[] = []; +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ + user: { id: 'u_1', name: 'Service Agent', email: 'agent@example.com', role: viewerRole, positions: viewerPositions }, + getAuthConfig: async () => ({ features: {} }), + activeOrganization: { id: 'org_1', name: 'Org' }, + }), + useWorkspaceAdminStatus: () => ({ isAdmin: false, isResolved: true }), +})); + +const dataSourceStub = { + onConnectionStateChange: () => () => {}, + getConnectionState: () => 'connected', +}; +vi.mock('../../providers/AdapterProvider', async (importOriginal) => ({ + ...(await importOriginal>()), + useAdapter: () => dataSourceStub, +})); + +/** The app list as the server hands it to this session. */ +let metadataApps: unknown[] = []; +vi.mock('../../providers/MetadataProvider', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadata: () => ({ + apps: metadataApps, + objects: [], + loading: false, + ensureType: undefined, + error: null, + refresh: vi.fn(async () => {}), + }), +})); + +const actionRunnerStub = { registerHandler: vi.fn(), getContext: () => ({}) }; +vi.mock('@object-ui/react', async (importOriginal) => ({ + ...(await importOriginal>()), + useActionRunner: () => ({ execute: vi.fn(), runner: actionRunnerStub }), + useGlobalUndo: () => {}, + useMutationInvalidationBridge: () => {}, +})); + +import { AppContent } from '../AppContent'; + +function LocationProbe() { + const location = useLocation(); + return
{location.pathname}
; +} + +/** The member's `/me/permissions` answer: every object readable unless denied. */ +function permissionsDenying(...unreadable: string[]): MePermissionsResponse { + return { + authenticated: true, + userId: 'u_1', + tenantId: null, + roles: [], + permissionSets: [], + objects: { + '*': { allowRead: true }, + ...Object.fromEntries(unreadable.map((name) => [name, { allowRead: false }])), + }, + fields: {}, + } as MePermissionsResponse; +} + +function renderConsoleAt(initialUrl: string, permissions: Partial>) { + return render( + } {...permissions}> + + + + } /> + + + , + ); +} + +const pathname = () => screen.getByTestId('pathname').textContent; + +/** HotCRM's shape: a dashboard first, then the object entries. */ +function crmApp(extra: Record) { + return { + name: 'crm', + label: 'CRM', + navigation: [ + { id: 'nav_home', type: 'dashboard', dashboardName: 'executive_dashboard', label: 'Home' }, + { id: 'nav_leads', type: 'object', objectName: 'crm_lead', label: 'Leads' }, + { + id: 'grp_service', + type: 'group', + label: 'Service', + children: [{ id: 'nav_cases', type: 'object', objectName: 'crm_case', label: 'Cases' }], + }, + ], + ...extra, + }; +} + +const agents = "'service_agent' in current_user.positions"; +const reps = "'sales_rep' in current_user.positions"; + +describe('AppContent — an app opens on its declared per-audience landing (objectstack#28450)', () => { + let warn: ReturnType; + beforeEach(() => { + viewerRole = 'member'; + viewerPositions = []; + metadataApps = []; + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + return () => warn.mockRestore(); + }); + + it('the first entry false and the second true: opens on the second entry\'s item', async () => { + viewerPositions = ['sales_rep']; + metadataApps = [crmApp({ landing: [{ item: 'nav_cases', visible: agents }, { item: 'nav_leads', visible: reps }] })]; + renderConsoleAt('/apps/crm', { initialPermissions: permissionsDenying() }); + + expect(await screen.findByTestId('object-view')).toBeInTheDocument(); + expect(pathname()).toBe('/apps/crm/crm_lead'); + }); + + it('the first entry that holds wins, and an item inside a group is landed on', async () => { + viewerPositions = ['service_agent', 'sales_rep']; + metadataApps = [crmApp({ landing: [{ item: 'nav_cases', visible: agents }, { item: 'nav_leads', visible: reps }] })]; + renderConsoleAt('/apps/crm', { initialPermissions: permissionsDenying() }); + + expect(await screen.findByTestId('object-view')).toBeInTheDocument(); + expect(pathname()).toBe('/apps/crm/crm_case'); + }); + + it('no entry holds: the first navigation item by `order`, today\'s rule', async () => { + viewerPositions = ['finance']; + metadataApps = [crmApp({ landing: [{ item: 'nav_cases', visible: agents }, { item: 'nav_leads', visible: reps }] })]; + renderConsoleAt('/apps/crm', { initialPermissions: permissionsDenying() }); + + expect(await screen.findByTestId('dashboard-view')).toBeInTheDocument(); + expect(pathname()).toBe('/apps/crm/dashboard/executive_dashboard'); + }); + + it('a predicate that faults is passed over and reported once, with the navigation gate\'s withholding face', async () => { + viewerPositions = ['sales_rep']; + const faulting = 'current_user.no_such_member.deeper == 1'; + metadataApps = [crmApp({ landing: [{ item: 'nav_cases', visible: faulting }, { item: 'nav_leads', visible: reps }] })]; + renderConsoleAt('/apps/crm', { initialPermissions: permissionsDenying() }); + + expect(await screen.findByTestId('object-view')).toBeInTheDocument(); + expect(pathname()).toBe('/apps/crm/crm_lead'); + const reports = warn.mock.calls.map((call) => String(call[0])).filter((line) => line.includes(faulting)); + expect(reports.length).toBeGreaterThanOrEqual(1); + expect(new Set(reports).size).toBe(1); + expect(reports[0]).toMatch(/withh/i); + }); + + it.each([ + ['`record`, the page row', "record.owner == 'u_1'"], + ['`app`, which no app-shell scope binds', "app.name == 'crm'"], + ])('a predicate naming a root outside the navigation scope (%s) faults and is passed over', async (_label, outside) => { + viewerPositions = ['sales_rep']; + metadataApps = [crmApp({ landing: [{ item: 'nav_cases', visible: outside }, { item: 'nav_leads', visible: reps }] })]; + renderConsoleAt('/apps/crm', { initialPermissions: permissionsDenying() }); + + expect(await screen.findByTestId('object-view')).toBeInTheDocument(); + expect(pathname()).toBe('/apps/crm/crm_lead'); + expect(warn.mock.calls.some((call) => String(call[0]).includes(outside))).toBe(true); + }); + + it.each([ + ['`user`', "user.role == 'member'"], + ['`ctx.user`', "ctx.user.role == 'member'"], + ['`os.user`', "os.user.role == 'member'"], + ])('the identity alias %s reads the same member as `current_user`', async (_label, alias) => { + metadataApps = [crmApp({ landing: [{ item: 'nav_cases', visible: alias }] })]; + renderConsoleAt('/apps/crm', { initialPermissions: permissionsDenying() }); + + expect(await screen.findByTestId('object-view')).toBeInTheDocument(); + expect(pathname()).toBe('/apps/crm/crm_case'); + }); + + it('an entry whose item the member\'s own navigation gates hide is passed over', async () => { + viewerPositions = ['service_agent', 'sales_rep']; + metadataApps = [crmApp({ landing: [{ item: 'nav_cases', visible: agents }, { item: 'nav_leads', visible: reps }] })]; + renderConsoleAt('/apps/crm', { initialPermissions: permissionsDenying('crm_case') }); + + expect(await screen.findByTestId('object-view')).toBeInTheDocument(); + expect(pathname()).toBe('/apps/crm/crm_lead'); + }); + + it('an item of `areas[].navigation` is landed on', async () => { + viewerPositions = ['service_agent']; + metadataApps = [ + crmApp({ + areas: [ + { + id: 'area_service', + label: 'Service', + navigation: [{ id: 'nav_contracts', type: 'object', objectName: 'crm_contract', label: 'Contracts' }], + }, + ], + landing: [{ item: 'nav_contracts', visible: agents }], + }), + ]; + renderConsoleAt('/apps/crm', { initialPermissions: permissionsDenying() }); + + expect(await screen.findByTestId('object-view')).toBeInTheDocument(); + expect(pathname()).toBe('/apps/crm/crm_contract'); + }); + + it('control: an app that declares no landing opens on its first navigation item, as before', async () => { + viewerPositions = ['service_agent']; + metadataApps = [crmApp({})]; + renderConsoleAt('/apps/crm', { initialPermissions: permissionsDenying() }); + + expect(await screen.findByTestId('dashboard-view')).toBeInTheDocument(); + expect(pathname()).toBe('/apps/crm/dashboard/executive_dashboard'); + }); +}); From e8ffac8e5ec02866ecfe1f3ce17fb477f640dae6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 23:11:16 +0000 Subject: [PATCH 03/12] feat(console): flip the app.landing liveness row and re-classify its conformance row Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibw9TMGcWfAYsNMXXRo --- .changeset/28450-app-landing-console.md | 21 +++++++++++++++++++ .../test/expression-conformance.ledger.ts | 8 +++---- packages/spec/liveness/app.json | 16 +++++++------- 3 files changed, 32 insertions(+), 13 deletions(-) create mode 100644 .changeset/28450-app-landing-console.md diff --git a/.changeset/28450-app-landing-console.md b/.changeset/28450-app-landing-console.md new file mode 100644 index 00000000000..1771644c20c --- /dev/null +++ b/.changeset/28450-app-landing-console.md @@ -0,0 +1,21 @@ +--- +'@object-ui/app-shell': minor +'@object-ui/types': minor +'@objectstack/spec': minor +--- + +feat(console)!: an app opens on the first `landing` entry whose `visible` holds for the reader + +Clause-②: no (narrowing) +Metadata-protocol: semantic + + + +**BREAKING** accept-set narrowing at objectui's `app` node (`@object-ui/types` `AppComponentSchema`, and the published `objectui validate` door on it), for a document the platform already refuses. + +- **The console reads `landing`.** On the app root (`/apps/:app`), the console asks the app's `landing` entries in order and opens the navigation entry named by the first one whose `visible` holds for the signed-in member. Before, every reader opened on the app's first navigation item whatever the list said. No match, or no `landing` key, keeps that rule: the first navigation item by `order`. +- **The predicate's scope.** `visible` is asked with the evaluator the app's navigation `visible` uses, over the same roots: `current_user` (and its `user`, `ctx.user` and `os.user` aliases) and `features`. No `data` is bound. A predicate naming any other root (`record`, `app`) faults. +- **A fault passes the entry over.** A predicate the evaluator cannot answer is reported once in the browser console (the navigation gate's `withholding` line) and its entry is skipped; the next entry is asked. The landing only decides where a reader opens: every navigation entry keeps its own `visible`, `requiredPermissions` and `requiresObject` gates. +- **Only an entry the member is shown.** The `item` is looked up, at any depth, in the app's `navigation` and then each `areas[].navigation`, as the member is shown them. An entry the member's navigation gates hide is passed over, and so is a `url` entry, which opens nothing inside the app. +- **objectui refuses what the spec refuses.** `AppComponentSchema` now runs `checkAppLandingItems`: a `landing` entry whose `item` names no entry of the app's own navigation, or names a `group`, `separator` or `action` entry, is refused at `landing.N.item`, and a `visible` that reads `data` at `landing.N.visible`, with the spec's own message. To fix such a document, name an entry the app declares that opens a page, and gate on `current_user` or `features`. +- **The liveness row flips.** `app.landing` (with `item` and `visible`) is `live` in `@objectstack/spec`'s liveness ledger, and its author warning is dropped. diff --git a/packages/qa/dogfood/test/expression-conformance.ledger.ts b/packages/qa/dogfood/test/expression-conformance.ledger.ts index 497ed3b953b..d565753eacd 100644 --- a/packages/qa/dogfood/test/expression-conformance.ledger.ts +++ b/packages/qa/dogfood/test/expression-conformance.ledger.ts @@ -440,14 +440,14 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ note: 'Display only by contract — it never gates, greys or routes the action — so a fault refuses nothing: the predicate\'s effect (the pressed face) is dropped and the fault is logged, which is `fail-soft-log`, not `fail-closed`. The unpressed face is also the one that never claims the reader acted. Split from `cel-action-visible` / `cel-action-disabled` for the collision rule those two were split on: one row cannot state three fault faces. A menu item (the header overflow, the bar\'s override menu) draws no pressed state at all. Measured here: the ui half is in this checkout and its suites pin the TRUE / FALSE / fault faces on both surfaces.', }, { - // The key arrived spec-first (card 28022); the console landing redirect that reads it is its own ui card. + // The key arrived spec-first (card 28022); the console landing redirect reads it (card 28450). id: 'cel-app-landing', summary: "app per-audience landing predicate (AppLandingEntry.visible) — the first entry whose predicate holds names the navigation entry the app opens on", - dialect: 'cel', mode: 'interpret', state: 'experimental', failPolicy: 'unevaluated', + dialect: 'cel', mode: 'interpret', state: 'enforced', failPolicy: 'fail-soft-log', enforcement: - 'PARSE ONLY — `EvaluatedExpressionInputSchema` normalizes a bare string to `{dialect:"cel",source}` and refuses an `ast`-only envelope and a blank source, and `checkAppLandingItems` on `AppSchema` refuses a `data` read (a landing entry binds no `data`) and an `item` naming no openable navigation entry of the app. NO EVALUATOR FOUND: no console reads `app.landing` yet; the landing redirect that will ask these predicates is the follow-up ui card', + 'EVALUATOR: the console (objectui) app-root landing, `resolveDeclaredLandingRoute` called from `AppRootLanding`, which asks each entry in order with the navigation `visible` evaluator `useNavMemberGates` builds (`evaluateNavigationVisibility` over the shell scope `buildExpressionScope` binds: `current_user` and its `user` / `ctx.user` / `os.user` aliases, and `features`; no `data`). A predicate that faults, a root outside that scope included, is reported once and its entry passed over; the next entry is asked, and with none left the app opens on its first navigation item by `order`. PARSE: `EvaluatedExpressionInputSchema` normalizes a bare string and refuses an `ast`-only envelope and a blank source, and `checkAppLandingItems` (on `AppSchema` and on objectui\'s `AppComponentSchema`) refuses a `data` read and an `item` naming no openable navigation entry of the app', covers: ['ui/app.zod.ts:AppLandingEntrySchema.visible'], - note: 'EXPERIMENTAL, declared spec-first by ruling (letter A): the liveness ledger carries `app.landing` as `planned` with an author warning, so an author is told no console reads it yet. Re-classify when the console redirect lands: the predicate decides only where a reader opens, never what they may reach (each navigation entry keeps its own gates), so its fault face is expected to be `fail-soft-log` — a faulting entry is passed over and the app opens on its first navigation item.', + note: 'Re-classified from `experimental` / `unevaluated` when the console redirect landed (the domain:ui half of the letter-A ruling); the liveness row `app.landing` flipped to `live` with it. `fail-soft-log` because the predicate decides only where a reader opens, never what they may reach (each navigation entry keeps its own gates): a fault passes the entry over, loud once in the console, and the landing falls back as it would with no match. ⛔ NOT MEASURED HERE: the renderer, which is under ui/ and not run by this suite; its pin is `ui/packages/app-shell/src/console/__tests__/AppContent.declaredLanding-28450.test.tsx`.', }, { id: 'cel-record-alert-visible', diff --git a/packages/spec/liveness/app.json b/packages/spec/liveness/app.json index a891c9d0ed8..65e33ce5946 100644 --- a/packages/spec/liveness/app.json +++ b/packages/spec/liveness/app.json @@ -315,23 +315,21 @@ "note": "Drilled because includeAll/placement diverge (dead) from the live core." }, "landing": { - "status": "planned", + "status": "live", "verifiedAt": "2026-10-31", "evidenceScope": "in-repo", - "authorWarn": true, - "authorHint": "Keep it, but nothing reads it yet: no console applies `landing`, so every reader still opens on the app's first navigation item until the landing redirect ships.", - "evidence": "packages/spec/src/ui/app.zod.ts#checkAppLandingItems (the parse-time door, mounted on AppSchema: an `item` naming no entry of the app's own `navigation` / `areas[].navigation`, or a `group` / `separator` / `action` entry, is refused at `landing.N.item`, and a `visible` reading `data` at `landing.N.visible`); packages/spec/src/ui/app.zod.ts#AppLandingEntrySchema (strict `{ item, visible }`, `visible` required, the navigation entry's CEL dialect)", - "note": "PLANNED RECORD (card 28022, ruling record 6545223246, letter A; spec half by domain:spec seat 2): a per-audience landing list, first entry whose `visible` holds names the navigation entry the app opens on; no match or no key keeps today's rule (first navigation item by `order`). Declared spec-first by the maintainer's ruling, against the `homePageId` tombstone's enforce-first preference, which is why this row warns: measured at origin/main 5da3a8ce, nothing under ui/ reads `app.landing` (the console's landing resolution is ui/packages/app-shell/src/utils/homePath.ts and RootLandingRedirect, neither of which reads the key). The tombstone's other objection, a dangling id resolving silently to the first item, is closed at parse by the door in `evidence`. Flip to `live` when the console landing redirect (the follow-up domain:ui card) reads it, citing the read point, and drop `authorWarn` with the flip.", + "evidence": "ui/packages/app-shell/src/console/appDeclaredLanding.ts#resolveDeclaredLandingRoute (the read point: the entries in order, each `visible` asked with the navigation's own evaluator, the first that holds opens the entry its `item` names when the member is shown it; a faulting predicate is passed over); ui/packages/app-shell/src/console/AppContent.tsx#AppRootLanding (the app root's redirect calls it over the trees the member is shown, through `useNavMemberGates`, and keeps the first navigation item by `order` when no entry lands); packages/spec/src/ui/app.zod.ts#checkAppLandingItems (the parse-time door, mounted on AppSchema and on objectui's `AppComponentSchema`: an `item` naming no entry of the app's own `navigation` / `areas[].navigation`, or a `group` / `separator` / `action` entry, is refused at `landing.N.item`, and a `visible` reading `data` at `landing.N.visible`)", + "note": "FLIPPED planned → live 2026-10-31 (card 28450, the domain:ui half of card 28022, ruling record 6545223246, letter A): the console's app-root landing reads the list, and `authorWarn` is dropped with the flip. Scope: the predicate is asked in the scope the app's navigation `visible` is asked in, the roots `buildExpressionScope` binds for the shell (`current_user` with its `user` / `ctx.user` / `os.user` aliases, and `features`); no `data` is bound, and a root outside that scope (`record`, `app`) faults. Fault face: a faulting entry is passed over and reported once by the navigation evaluator (`withholding`), the next entry is asked, and with none left the app opens on its first navigation item. An entry whose item the member's navigation gates hide, or whose item opens no in-app route (a `url` entry), is passed over too. Pinned in ui/packages/app-shell/src/console/__tests__/AppContent.declaredLanding-28450.test.tsx.", "children": { "item": { - "status": "planned", + "status": "live", "verifiedAt": "2026-10-31", - "note": "Planned with its container `landing` (see that row): the id of the navigation entry this audience opens on. Its referential refusal is live at parse (pinned in packages/spec/src/ui/app-landing.test.ts); what is planned is the redirect that opens it." + "note": "Live with its container `landing` (see that row): the id of the navigation entry this audience opens on, looked up at any depth in the trees the member is shown. Its referential refusal is live at parse (packages/spec/src/ui/app-landing.test.ts)." }, "visible": { - "status": "planned", + "status": "live", "verifiedAt": "2026-10-31", - "note": "Planned with its container `landing` (see that row): the audience predicate, the navigation entry `visible` dialect and reader roots, required on every entry. Its shape refusals (blank, ast-only, a `data` read) are live at parse; nothing evaluates it until the console landing redirect ships." + "note": "Live with its container `landing` (see that row): the audience predicate, asked with the navigation entry `visible` evaluator and its roots; a faulting predicate passes its entry over. Its shape refusals (blank, ast-only, a `data` read) are live at parse." } } }, From daacf5e34d389fdc76b30d28b06fdc9b3d3e6d28 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 23:12:44 +0000 Subject: [PATCH 04/12] docs(spec): the app landing list is read by the console Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibw9TMGcWfAYsNMXXRo --- content/docs/ui/apps.mdx | 12 +++++------- packages/spec/src/ui/app.zod.ts | 14 ++++++++------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/content/docs/ui/apps.mdx b/content/docs/ui/apps.mdx index a7851ab6853..7f86e0513a9 100644 --- a/content/docs/ui/apps.mdx +++ b/content/docs/ui/apps.mdx @@ -49,7 +49,7 @@ const crmApp = defineApp({ | `navigation` | `NavigationItem[]` | optional | Navigation tree | | `areas` | `NavigationArea[]` | optional | Partition navigation by business domain — see [Areas](#areas) | | `contextSelectors` | `AppContextSelector[]` | optional | Sidebar scope dropdowns whose value is injected into navigation items — see [Context Selectors](#context-selectors) | -| `landing` | `AppLandingEntry[]` | optional | **Planned.** Per-audience landing: which navigation entry each audience opens on — see [Landing per audience](#landing-per-audience) | +| `landing` | `AppLandingEntry[]` | optional | Per-audience landing: which navigation entry each audience opens on — see [Landing per audience](#landing-per-audience) | | `branding` | `AppBranding` | optional | Visual customization | | `requiredPermissions` | `string[]` | optional | Required permissions to access | | `defaultAgent` | `string` | optional | Platform agent bound to this app's ambient AI chat — see [Default Agent](#default-agent) | @@ -766,12 +766,10 @@ Refused at parse, at the entry's own path: keeps its own `visible` / `requiredPermissions` / `requiresObject` gates, and an entry the reader cannot see is passed over. - - **Planned — not yet applied.** The key is declared and validated, but no - console reads it yet, so every reader still opens on the app's first - navigation item until the console's landing redirect ships. `os validate` - says so for any app that declares it. - +A `visible` that faults (one that names a root other than `current_user` or +`features`, such as `record` or `app`, or that the evaluator cannot answer) is +reported once in the browser console and its entry is passed over: the next +entry is asked, and with none left the app opens on its first navigation item. ## Default Agent diff --git a/packages/spec/src/ui/app.zod.ts b/packages/spec/src/ui/app.zod.ts index 38db77c59d7..f0c85a7f20a 100644 --- a/packages/spec/src/ui/app.zod.ts +++ b/packages/spec/src/ui/app.zod.ts @@ -1816,8 +1816,8 @@ export function checkAppLandingItems(app: unknown, ctx: z.RefinementCtx): void { * than trusted. And it is not a second "where everyone lands": every entry * carries a required audience predicate, so "everyone" is still the first * navigation item. The maintainer ruled it spec-first rather than - * enforce-first, so it lands `planned` in the liveness ledger, which warns an - * author that no console reads it until the landing redirect ships. + * enforce-first; the console's landing redirect followed (#28450), and the + * liveness ledger carries it `live`. */ const HOME_PAGE_ID_RETIRED = '`app.homePageId` was removed in @objectstack/spec 17.0.0 (ADR-0049). ' @@ -2036,9 +2036,11 @@ export const AppSchema = lazySchema(() => strictObject( * navigation item by `order`. Every `item` must name an entry of this app, * refused at parse otherwise ({@link checkAppLandingItems}). * - * PLANNED: declared spec-first; the console's landing redirect that reads it - * is its own card. Until it lands, an app opens on its first navigation item - * whatever this list says, and the liveness ledger tells the author so. + * The console's app-root landing reads it (#28450): each `visible` is asked + * with the evaluator a navigation entry's `visible` is asked with, over the + * same reader roots (`current_user` and `features`; no `data`). A predicate + * that faults, a root outside those included, passes its entry over, and the + * next entry is asked. * * @example Sales staff open on Leads, service staff on the service console * ```ts @@ -2049,7 +2051,7 @@ export const AppSchema = lazySchema(() => strictObject( * ``` */ landing: z.array(AppLandingEntrySchema).min(1).optional() - .describe('[PLANNED — not yet rendered] Per-audience landing: the first entry whose `visible` (CEL) holds for the reader names the navigation entry (`item`, an `id` of this app\'s own `navigation` or `areas[].navigation`) the app opens on. An `item` that names no entry of this app, or a `group` / `separator` / `action` entry, is refused at parse. No match, or no key: the app opens on its first navigation item by `order`, as before. No console reads this list yet, so until the landing redirect ships every reader still opens on the first navigation item.'), + .describe('Per-audience landing: the first entry whose `visible` (CEL) holds for the reader names the navigation entry (`item`, an `id` of this app\'s own `navigation` or `areas[].navigation`) the app opens on; an entry whose `item` the reader\'s own navigation gates hide, or whose `visible` faults, is passed over. An `item` that names no entry of this app, or a `group` / `separator` / `action` entry, is refused at parse. No match, or no key: the app opens on its first navigation item by `order`, as before.'), /** * Access Control From 7ba6f706857d3f04e1756e7d784c43719238bfdf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 23:31:13 +0000 Subject: [PATCH 05/12] chore(spec): regenerate the reference docs and liveness counts for app.landing Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibw9TMGcWfAYsNMXXRo --- content/docs/references/api/metadata.mdx | 2 +- content/docs/references/ui/app.mdx | 2 +- packages/spec/liveness/state-counts/app.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index 58fd6fc0a0d..4949af18b39 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -95,7 +95,7 @@ const result = AppDefinitionResponseSchema.parse(data); | **navigation** | `({ id: string; label?: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +8 more)[]` | optional | Full navigation tree for the app sidebar | | **areas** | `{ id: string; label: string \| Record; icon?: string; description?: string \| Record; … }[]` | optional | Navigation areas for partitioning navigation by business domain | | **contextSelectors** | `{ id: string; label: string \| Record; icon?: string; optionsSource: object; … }[]` | optional | App-level scope dropdowns whose value is injected into nav items as `{}` template vars | -| **landing** | `{ item: string; visible: string \| object }[]` | optional | [PLANNED — not yet rendered] Per-audience landing: the first entry whose `visible` (CEL) holds for the reader names the navigation entry (`item`, an `id` of this app's own `navigation` or `areas[].navigation`) the app opens on. An `item` that names no entry of this app, or a `group` / `separator` / `action` entry, is refused at parse. No match, or no key: the app opens on its first navigation item by `order`, as before. No console reads this list yet, so until the landing redirect ships every reader still opens on the first navigation item. | +| **landing** | `{ item: string; visible: string \| object }[]` | optional | Per-audience landing: the first entry whose `visible` (CEL) holds for the reader names the navigation entry (`item`, an `id` of this app's own `navigation` or `areas[].navigation`) the app opens on; an entry whose `item` the reader's own navigation gates hide, or whose `visible` faults, is passed over. An `item` that names no entry of this app, or a `group` / `separator` / `action` entry, is refused at parse. No match, or no key: the app opens on its first navigation item by `order`, as before. | | **requiredPermissions** | `string[]` | optional | Permissions required to access this app | | **defaultAgent** | `string` | optional | Platform agent bound to this app's ambient chat ('ask' is the implicit default; 'build' for authoring surfaces) — ADR-0063 §1 | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this app. | diff --git a/content/docs/references/ui/app.mdx b/content/docs/references/ui/app.mdx index 49a07b2f484..71130c1e334 100644 --- a/content/docs/references/ui/app.mdx +++ b/content/docs/references/ui/app.mdx @@ -78,7 +78,7 @@ const result = ActionNavItemSchema.parse(data); | **navigation** | `({ id: string; label?: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +8 more)[]` | optional | Full navigation tree for the app sidebar | | **areas** | `{ id: string; label: string \| Record; icon?: string; description?: string \| Record; … }[]` | optional | Navigation areas for partitioning navigation by business domain | | **contextSelectors** | `{ id: string; label: string \| Record; icon?: string; optionsSource: object; … }[]` | optional | App-level scope dropdowns whose value is injected into nav items as `{}` template vars | -| **landing** | `{ item: string; visible: string \| object }[]` | optional | [PLANNED — not yet rendered] Per-audience landing: the first entry whose `visible` (CEL) holds for the reader names the navigation entry (`item`, an `id` of this app's own `navigation` or `areas[].navigation`) the app opens on. An `item` that names no entry of this app, or a `group` / `separator` / `action` entry, is refused at parse. No match, or no key: the app opens on its first navigation item by `order`, as before. No console reads this list yet, so until the landing redirect ships every reader still opens on the first navigation item. | +| **landing** | `{ item: string; visible: string \| object }[]` | optional | Per-audience landing: the first entry whose `visible` (CEL) holds for the reader names the navigation entry (`item`, an `id` of this app's own `navigation` or `areas[].navigation`) the app opens on; an entry whose `item` the reader's own navigation gates hide, or whose `visible` faults, is passed over. An `item` that names no entry of this app, or a `group` / `separator` / `action` entry, is refused at parse. No match, or no key: the app opens on its first navigation item by `order`, as before. | | **requiredPermissions** | `string[]` | optional | Permissions required to access this app | | **defaultAgent** | `string` | optional | Platform agent bound to this app's ambient chat ('ask' is the implicit default; 'build' for authoring surfaces) — ADR-0063 §1 | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this app. | diff --git a/packages/spec/liveness/state-counts/app.md b/packages/spec/liveness/state-counts/app.md index 0e3986f5688..0afb2dd6423 100644 --- a/packages/spec/liveness/state-counts/app.md +++ b/packages/spec/liveness/state-counts/app.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `app` | 51 | 0 | 0 | 0 | 6 | 57 | +| `app` | 53 | 0 | 0 | 0 | 4 | 57 | From a339b58ffc8bed612eb4c0061f534f11d01758fb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 23:49:30 +0000 Subject: [PATCH 06/12] chore(spec): regenerate the reference docs and liveness counts after merging main Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibw9TMGcWfAYsNMXXRo --- content/docs/references/api/metadata.mdx | 1 + content/docs/references/ui/app.mdx | 8 ++++++++ packages/spec/liveness/state-counts/app.md | 2 +- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index 4949af18b39..35031293750 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -106,6 +106,7 @@ const result = AppDefinitionResponseSchema.parse(data); | **_packageId** | `string` | optional | Owning package machine id. | | **_packageVersion** | `string` | optional | Owning package version. | | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | +| **overrides** | `{ package: string; basedOn: string }` | optional | Override declaration (ADR-0146 D2): `{ package, basedOn }` — this item of "``.custom" replaces the base package's same-named item whole. `basedOn` is recorded and never judged. | --- diff --git a/content/docs/references/ui/app.mdx b/content/docs/references/ui/app.mdx index 71130c1e334..0429563e262 100644 --- a/content/docs/references/ui/app.mdx +++ b/content/docs/references/ui/app.mdx @@ -89,6 +89,7 @@ const result = ActionNavItemSchema.parse(data); | **_packageId** | `string` | optional | Owning package machine id. | | **_packageVersion** | `string` | optional | Owning package version. | | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | +| **overrides** | `{ package: string; basedOn: string }` | optional | Override declaration (ADR-0146 D2): `{ package, basedOn }` — this item of "``.custom" replaces the base package's same-named item whole. `basedOn` is recorded and never judged. | ### Nested Shape: `App.branding` @@ -318,6 +319,13 @@ Documentation entry on the app menu (ADR-0046). Targets a `book` and/or a `doc` | **reason** | `string` | ✅ | User-visible reason shown when the lock blocks an action. | | **docsUrl** | `string` | optional | Optional URL the Studio banner links to for more context. | +### Nested Shape: `App.overrides` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **package** | `string` | ✅ | The base package id whose same-named item this item replaces whole (e.g. "com.acme.crm"). Only an item of the base's paired "``.custom" package declares an override. | +| **basedOn** | `string` | ✅ | Recorded, never judged: the base package's `manifest.version` this override was taken from (e.g. "3.2.0"), not a history version of the item. No door compares it with the installed base, no upgrade diff is produced from it, and nothing is refused on it; it tells a person re-basing the override where it started (ADR-0146 D2). | + --- diff --git a/packages/spec/liveness/state-counts/app.md b/packages/spec/liveness/state-counts/app.md index 0afb2dd6423..65913cfcd46 100644 --- a/packages/spec/liveness/state-counts/app.md +++ b/packages/spec/liveness/state-counts/app.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `app` | 53 | 0 | 0 | 0 | 4 | 57 | +| `app` | 53 | 0 | 0 | 0 | 6 | 59 | From 6d8ea83026a0a6d38a60ce85104e18fa85fb0c05 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 23:54:14 +0000 Subject: [PATCH 07/12] test(console): type the console.warn spy in the declared-landing pins Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibw9TMGcWfAYsNMXXRo --- .../__tests__/AppContent.declaredLanding-28450.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/packages/app-shell/src/console/__tests__/AppContent.declaredLanding-28450.test.tsx b/ui/packages/app-shell/src/console/__tests__/AppContent.declaredLanding-28450.test.tsx index 5be164a9f0b..56eea698a88 100644 --- a/ui/packages/app-shell/src/console/__tests__/AppContent.declaredLanding-28450.test.tsx +++ b/ui/packages/app-shell/src/console/__tests__/AppContent.declaredLanding-28450.test.tsx @@ -24,7 +24,7 @@ */ import '@testing-library/jest-dom/vitest'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, type MockInstance } from 'vitest'; import React from 'react'; import { render, screen } from '@testing-library/react'; import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom'; @@ -182,7 +182,7 @@ const agents = "'service_agent' in current_user.positions"; const reps = "'sales_rep' in current_user.positions"; describe('AppContent — an app opens on its declared per-audience landing (objectstack#28450)', () => { - let warn: ReturnType; + let warn: MockInstance; beforeEach(() => { viewerRole = 'member'; viewerPositions = []; From 0ba3d126b55a097fb33a214a4acea35ebc47a756 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 01:00:34 +0000 Subject: [PATCH 08/12] test(types): account for the app node's landing refinement in the import-boundary and retired-actions pins Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibw9TMGcWfAYsNMXXRo --- .../types/src/__tests__/app-actions-retired-7469.test.ts | 6 +++++- .../types/src/__tests__/imported-defaults-8317.test.ts | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/ui/packages/types/src/__tests__/app-actions-retired-7469.test.ts b/ui/packages/types/src/__tests__/app-actions-retired-7469.test.ts index 8d005c36ba8..31004eff64e 100644 --- a/ui/packages/types/src/__tests__/app-actions-retired-7469.test.ts +++ b/ui/packages/types/src/__tests__/app-actions-retired-7469.test.ts @@ -47,6 +47,7 @@ */ import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; import { AppSchema as SpecAppSchema } from '@objectstack/spec/ui'; import type { AppComponentSchema } from '../app'; // @ts-expect-error — `AppAction` is retired (objectui#7469); re-exporting it turns this directive unused @@ -144,7 +145,10 @@ describe('objectui#7469 (c) — the platform\'s `AppSchema` refuses the key too' describe('objectui#7469 (d) — CONTROL: a deleted arm would KEEP the array under `.passthrough()`', () => { it('the mirror with the arm omitted parses the retired array green and keeps it — what a deletion would have left', () => { const value = RETIRED_VALUES[0]![1]; - const r = AppMirror.omit({ actions: true }).safeParse(app({ actions: value })); + // Rebuilt from the mirror's own shape: zod refuses `.omit()` on an object that + // carries refinements, and the mirror chains the spec's `checkAppLandingItems`. + const { actions: _retired, ...withoutArm } = AppMirror.shape; + const r = z.object(withoutArm).passthrough().safeParse(app({ actions: value })); expect(r.success).toBe(true); expect((r.data as Record).actions).toEqual(value); }); diff --git a/ui/packages/types/src/__tests__/imported-defaults-8317.test.ts b/ui/packages/types/src/__tests__/imported-defaults-8317.test.ts index 77a6ad1c69a..77ae155039c 100644 --- a/ui/packages/types/src/__tests__/imported-defaults-8317.test.ts +++ b/ui/packages/types/src/__tests__/imported-defaults-8317.test.ts @@ -149,6 +149,7 @@ import { checkPageDefaultPlacement, checkPageMainRegionWidth, checkPageRecordBindingPlacement, + checkAppLandingItems, VIEW_METADATA_MEMBERS as SpecViewMetadataMembers, // objectui#12736 — the form option's spec shape: the form-view option. FormSelectOptionSchema as SpecFormSelectOptionSchema, @@ -745,6 +746,9 @@ describe('the import boundary strips every imported default (objectui#8317)', () // `ListViewSchema`, attached on the authored page `list-view` node's bag // (`ListViewPropsBag`), whose `type` is the spec's view kind. ['checkListViewChartBinding', checkListViewChartBinding], + // objectstack#28450: the spec's `AppSchema.landing` check, re-attached on + // `AppComponentSchema` under the objectui#7715 ruling. + ['checkAppLandingItems', checkAppLandingItems], ]); /** From d4500482a3eaa5e37158b7bfaceb272e44dc5a95 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 02:08:42 +0000 Subject: [PATCH 09/12] feat(spec)!: a landing entry may not name a url navigation entry A url entry opens an external address and the app-root landing is an in-app redirect, so a url landing landed nobody, in silence. The landing check now refuses it at landing.N.item beside group, separator and action, and the refusal message no longer lists url among the entries that open something. The changeset declares the narrowing, says a doc landing opens the in-app docs portal under the server's audience gate, and names how to derive from the refined objectui app node. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibw9TMGcWfAYsNMXXRo --- .changeset/28450-app-landing-console.md | 12 ++++++++---- content/docs/references/api/metadata.mdx | 2 +- content/docs/references/ui/app.mdx | 6 +++--- content/docs/ui/apps.mdx | 4 ++-- packages/spec/liveness/app.json | 2 +- packages/spec/src/ui/app-landing.test.ts | 9 +++++++-- packages/spec/src/ui/app.zod.ts | 24 +++++++++++++++--------- 7 files changed, 37 insertions(+), 22 deletions(-) diff --git a/.changeset/28450-app-landing-console.md b/.changeset/28450-app-landing-console.md index 1771644c20c..ddd5cb016b0 100644 --- a/.changeset/28450-app-landing-console.md +++ b/.changeset/28450-app-landing-console.md @@ -7,15 +7,19 @@ feat(console)!: an app opens on the first `landing` entry whose `visible` holds for the reader Clause-②: no (narrowing) -Metadata-protocol: semantic +Metadata-protocol: narrowed - + -**BREAKING** accept-set narrowing at objectui's `app` node (`@object-ui/types` `AppComponentSchema`, and the published `objectui validate` door on it), for a document the platform already refuses. +**BREAKING** accept-set narrowing in two places: `@objectstack/spec` refuses a `landing` entry that names a `url` navigation entry, and objectui's `app` node (`@object-ui/types` `AppComponentSchema`, and the published `objectui validate` door on it) runs the spec's landing check, so it refuses what the spec refuses. - **The console reads `landing`.** On the app root (`/apps/:app`), the console asks the app's `landing` entries in order and opens the navigation entry named by the first one whose `visible` holds for the signed-in member. Before, every reader opened on the app's first navigation item whatever the list said. No match, or no `landing` key, keeps that rule: the first navigation item by `order`. - **The predicate's scope.** `visible` is asked with the evaluator the app's navigation `visible` uses, over the same roots: `current_user` (and its `user`, `ctx.user` and `os.user` aliases) and `features`. No `data` is bound. A predicate naming any other root (`record`, `app`) faults. - **A fault passes the entry over.** A predicate the evaluator cannot answer is reported once in the browser console (the navigation gate's `withholding` line) and its entry is skipped; the next entry is asked. The landing only decides where a reader opens: every navigation entry keeps its own `visible`, `requiredPermissions` and `requiresObject` gates. -- **Only an entry the member is shown.** The `item` is looked up, at any depth, in the app's `navigation` and then each `areas[].navigation`, as the member is shown them. An entry the member's navigation gates hide is passed over, and so is a `url` entry, which opens nothing inside the app. +- **Only an entry the member is shown.** The `item` is looked up, at any depth, in the app's `navigation` and then each `areas[].navigation`, as the member is shown them. An entry the member's navigation gates hide is passed over. A `doc` entry lands: it opens the in-app docs portal, and which docs a reader may open stays the server's audience gate on the doc. - **objectui refuses what the spec refuses.** `AppComponentSchema` now runs `checkAppLandingItems`: a `landing` entry whose `item` names no entry of the app's own navigation, or names a `group`, `separator` or `action` entry, is refused at `landing.N.item`, and a `visible` that reads `data` at `landing.N.visible`, with the spec's own message. To fix such a document, name an entry the app declares that opens a page, and gate on `current_user` or `features`. +- **Deriving from `AppComponentSchema`.** It now carries a refinement, and zod refuses `.extend()`, `.omit()` and `.pick()` on an object that carries one. Derive from it with `AppComponentSchema.shape` or `.safeExtend()`. +- **`@objectstack/spec` refuses a `url` landing.** A `url` navigation entry opens an external address, and the app-root landing is an in-app redirect, so a `url` landing would land nobody. `checkAppLandingItems` now refuses it at `landing.N.item` with the `group` / `separator` / `action` refusal, and the message no longer lists `url` among the entries that open something. + + FROM → TO: `landing: [{ item: 'nav_help_site', visible: … }]` naming a `url` entry → `landing: [{ item: 'nav_help', visible: … }]` naming an in-app entry the app declares (an `object`, `page`, `dashboard`, `report`, `component` or `doc` entry). - **The liveness row flips.** `app.landing` (with `item` and `visible`) is `live` in `@objectstack/spec`'s liveness ledger, and its author warning is dropped. diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index 35031293750..74a98a27656 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -95,7 +95,7 @@ const result = AppDefinitionResponseSchema.parse(data); | **navigation** | `({ id: string; label?: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +8 more)[]` | optional | Full navigation tree for the app sidebar | | **areas** | `{ id: string; label: string \| Record; icon?: string; description?: string \| Record; … }[]` | optional | Navigation areas for partitioning navigation by business domain | | **contextSelectors** | `{ id: string; label: string \| Record; icon?: string; optionsSource: object; … }[]` | optional | App-level scope dropdowns whose value is injected into nav items as `{}` template vars | -| **landing** | `{ item: string; visible: string \| object }[]` | optional | Per-audience landing: the first entry whose `visible` (CEL) holds for the reader names the navigation entry (`item`, an `id` of this app's own `navigation` or `areas[].navigation`) the app opens on; an entry whose `item` the reader's own navigation gates hide, or whose `visible` faults, is passed over. An `item` that names no entry of this app, or a `group` / `separator` / `action` entry, is refused at parse. No match, or no key: the app opens on its first navigation item by `order`, as before. | +| **landing** | `{ item: string; visible: string \| object }[]` | optional | Per-audience landing: the first entry whose `visible` (CEL) holds for the reader names the navigation entry (`item`, an `id` of this app's own `navigation` or `areas[].navigation`) the app opens on; an entry whose `item` the reader's own navigation gates hide, or whose `visible` faults, is passed over. An `item` that names no entry of this app, or a `group` / `separator` / `action` / `url` entry, is refused at parse. No match, or no key: the app opens on its first navigation item by `order`, as before. | | **requiredPermissions** | `string[]` | optional | Permissions required to access this app | | **defaultAgent** | `string` | optional | Platform agent bound to this app's ambient chat ('ask' is the implicit default; 'build' for authoring surfaces) — ADR-0063 §1 | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this app. | diff --git a/content/docs/references/ui/app.mdx b/content/docs/references/ui/app.mdx index 0429563e262..ff4b4a37885 100644 --- a/content/docs/references/ui/app.mdx +++ b/content/docs/references/ui/app.mdx @@ -78,7 +78,7 @@ const result = ActionNavItemSchema.parse(data); | **navigation** | `({ id: string; label?: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +8 more)[]` | optional | Full navigation tree for the app sidebar | | **areas** | `{ id: string; label: string \| Record; icon?: string; description?: string \| Record; … }[]` | optional | Navigation areas for partitioning navigation by business domain | | **contextSelectors** | `{ id: string; label: string \| Record; icon?: string; optionsSource: object; … }[]` | optional | App-level scope dropdowns whose value is injected into nav items as `{}` template vars | -| **landing** | `{ item: string; visible: string \| object }[]` | optional | Per-audience landing: the first entry whose `visible` (CEL) holds for the reader names the navigation entry (`item`, an `id` of this app's own `navigation` or `areas[].navigation`) the app opens on; an entry whose `item` the reader's own navigation gates hide, or whose `visible` faults, is passed over. An `item` that names no entry of this app, or a `group` / `separator` / `action` entry, is refused at parse. No match, or no key: the app opens on its first navigation item by `order`, as before. | +| **landing** | `{ item: string; visible: string \| object }[]` | optional | Per-audience landing: the first entry whose `visible` (CEL) holds for the reader names the navigation entry (`item`, an `id` of this app's own `navigation` or `areas[].navigation`) the app opens on; an entry whose `item` the reader's own navigation gates hide, or whose `visible` faults, is passed over. An `item` that names no entry of this app, or a `group` / `separator` / `action` / `url` entry, is refused at parse. No match, or no key: the app opens on its first navigation item by `order`, as before. | | **requiredPermissions** | `string[]` | optional | Permissions required to access this app | | **defaultAgent** | `string` | optional | Platform agent bound to this app's ambient chat ('ask' is the implicit default; 'build' for authoring surfaces) — ADR-0063 §1 | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this app. | @@ -308,7 +308,7 @@ Documentation entry on the app menu (ADR-0046). Targets a `book` and/or a `doc` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **item** | `string` | ✅ | Id of the app's own navigation entry this audience lands on (any depth of `navigation` or `areas[].navigation`). Refused at parse when no such entry exists, or when it is a `group`, `separator` or `action` entry, which open nothing. | +| **item** | `string` | ✅ | Id of the app's own navigation entry this audience lands on (any depth of `navigation` or `areas[].navigation`). Refused at parse when no such entry exists, or when it is a `group`, `separator`, `action` or `url` entry, which open nothing inside the app. | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | ✅ | Audience predicate (CEL), the dialect of a navigation entry's `visible`, e.g. P`'service_agent' in current_user.positions`. Required. It reads the signed-in reader (`current_user`, `features`); there is no `data` here, and a `visible` that reads `data` is refused at parse. | ### Nested Shape: `App.protection` @@ -374,7 +374,7 @@ Documentation entry on the app menu (ADR-0046). Targets a `book` and/or a `doc` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **item** | `string` | ✅ | Id of the app's own navigation entry this audience lands on (any depth of `navigation` or `areas[].navigation`). Refused at parse when no such entry exists, or when it is a `group`, `separator` or `action` entry, which open nothing. | +| **item** | `string` | ✅ | Id of the app's own navigation entry this audience lands on (any depth of `navigation` or `areas[].navigation`). Refused at parse when no such entry exists, or when it is a `group`, `separator`, `action` or `url` entry, which open nothing inside the app. | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | ✅ | Audience predicate (CEL), the dialect of a navigation entry's `visible`, e.g. P`'service_agent' in current_user.positions`. Required. It reads the signed-in reader (`current_user`, `features`); there is no `data` here, and a `visible` that reads `data` is refused at parse. | diff --git a/content/docs/ui/apps.mdx b/content/docs/ui/apps.mdx index 7f86e0513a9..c77c167cecc 100644 --- a/content/docs/ui/apps.mdx +++ b/content/docs/ui/apps.mdx @@ -756,8 +756,8 @@ Refused at parse, at the entry's own path: that exist). Items another package contributes through `navigationContributions` are not part of the app being parsed and cannot be named; -- an `item` that names a `group`, `separator` or `action` entry — none of them - opens a page; +- an `item` that names a `group`, `separator`, `action` or `url` entry — none of + them opens a page inside the app (a `url` entry leaves it); - an entry without `visible`. A landing for everyone is still "put that entry first in `navigation`"; `landing` is for different audiences; - a `visible` that reads `data` — a landing entry has no `dataSource`. diff --git a/packages/spec/liveness/app.json b/packages/spec/liveness/app.json index ba93a6f1856..19e51d6e8ac 100644 --- a/packages/spec/liveness/app.json +++ b/packages/spec/liveness/app.json @@ -319,7 +319,7 @@ "verifiedAt": "2026-10-31", "evidenceScope": "in-repo", "evidence": "ui/packages/app-shell/src/console/appDeclaredLanding.ts#resolveDeclaredLandingRoute (the read point: the entries in order, each `visible` asked with the navigation's own evaluator, the first that holds opens the entry its `item` names when the member is shown it; a faulting predicate is passed over); ui/packages/app-shell/src/console/AppContent.tsx#AppRootLanding (the app root's redirect calls it over the trees the member is shown, through `useNavMemberGates`, and keeps the first navigation item by `order` when no entry lands); packages/spec/src/ui/app.zod.ts#checkAppLandingItems (the parse-time door, mounted on AppSchema and on objectui's `AppComponentSchema`: an `item` naming no entry of the app's own `navigation` / `areas[].navigation`, or a `group` / `separator` / `action` entry, is refused at `landing.N.item`, and a `visible` reading `data` at `landing.N.visible`)", - "note": "FLIPPED planned → live 2026-10-31 (card 28450, the domain:ui half of card 28022, ruling record 6545223246, letter A): the console's app-root landing reads the list, and `authorWarn` is dropped with the flip. Scope: the predicate is asked in the scope the app's navigation `visible` is asked in, the roots `buildExpressionScope` binds for the shell (`current_user` with its `user` / `ctx.user` / `os.user` aliases, and `features`); no `data` is bound, and a root outside that scope (`record`, `app`) faults. Fault face: a faulting entry is passed over and reported once by the navigation evaluator (`withholding`), the next entry is asked, and with none left the app opens on its first navigation item. An entry whose item the member's navigation gates hide, or whose item opens no in-app route (a `url` entry), is passed over too. Pinned in ui/packages/app-shell/src/console/__tests__/AppContent.declaredLanding-28450.test.tsx.", + "note": "FLIPPED planned → live 2026-10-31 (card 28450, the domain:ui half of card 28022, ruling record 6545223246, letter A): the console's app-root landing reads the list, and `authorWarn` is dropped with the flip. Scope: the predicate is asked in the scope the app's navigation `visible` is asked in, the roots `buildExpressionScope` binds for the shell (`current_user` with its `user` / `ctx.user` / `os.user` aliases, and `features`); no `data` is bound, and a root outside that scope (`record`, `app`) faults. Fault face: a faulting entry is passed over and reported once by the navigation evaluator (`withholding`), the next entry is asked, and with none left the app opens on its first navigation item. An entry whose item the member's navigation gates hide is passed over too; a `url` item, which opens no in-app route, is refused at parse by `checkAppLandingItems`. Pinned in ui/packages/app-shell/src/console/__tests__/AppContent.declaredLanding-28450.test.tsx.", "children": { "item": { "status": "live", diff --git a/packages/spec/src/ui/app-landing.test.ts b/packages/spec/src/ui/app-landing.test.ts index d9bfedd5f22..ecdba01b09f 100644 --- a/packages/spec/src/ui/app-landing.test.ts +++ b/packages/spec/src/ui/app-landing.test.ts @@ -27,6 +27,7 @@ const NAV = [ }, { id: 'nav_new_case', type: 'action', label: 'New case', actionDef: { actionName: 'new_case' } }, { type: 'separator', id: 'sep_one' }, + { id: 'nav_help_site', type: 'url', label: 'Help site', url: 'https://help.example.com' }, ]; const app = (extra: Record) => ({ name: 'crm', label: 'CRM', navigation: NAV, ...extra }); @@ -73,16 +74,20 @@ describe('app.landing — refused at parse, at the entry\'s own path', () => { expect(issues).toHaveLength(1); expect(issues[0]).toMatchObject({ code: 'custom', path: 'landing.0.item' }); expect(issues[0].message).toContain('`nav_missing`'); - // Openable entries are named; the group, the action and the separator are not. + // Openable entries are named; the group, the action, the separator and the url entry are not. expect(issues[0].message).toContain('`nav_leads`, `nav_service_console`'); expect(issues[0].message).not.toContain('`grp_service`'); + expect(issues[0].message).not.toContain('`nav_help_site`'); }); it.each([ ['group', 'grp_service'], ['action', 'nav_new_case'], ['separator', 'sep_one'], - ])('refuses an item naming a `%s` entry, which opens no page', (type, item) => { + // A `url` entry leaves the app; the app-root landing is an in-app redirect, + // so a `url` landing would land nobody (#28450). + ['url', 'nav_help_site'], + ])('refuses an item naming a `%s` entry, which opens no page inside the app', (type, item) => { const issues = issuesOf(AppSchema.safeParse(app({ landing: [{ item, visible: 'true' }] }))); expect(issues).toHaveLength(1); expect(issues[0]).toMatchObject({ code: 'custom', path: 'landing.0.item' }); diff --git a/packages/spec/src/ui/app.zod.ts b/packages/spec/src/ui/app.zod.ts index accadd3bb95..e2714eaa286 100644 --- a/packages/spec/src/ui/app.zod.ts +++ b/packages/spec/src/ui/app.zod.ts @@ -1594,8 +1594,8 @@ export type AppContextSelectorParsed = z.infer; * `item` is an id into the same tree, and the difference is the refusal: * {@link checkAppLandingItems} runs on every app parse, so an `item` that * names no entry of this app's own `navigation` or `areas[].navigation`, or - * names an entry that opens nothing (a `group`, a `separator`, an `action`), - * is refused at its own path before the app is stored. The landing is a + * names an entry that opens nothing inside the app (a `group`, a `separator`, + * an `action`, a `url`), is refused at its own path before the app is stored. The landing is a * sidebar entry, so the sidebar highlights what the reader landed on; carrying * the target inline instead would have made a second copy of a navigation * target that the sidebar knows nothing about. @@ -1635,7 +1635,7 @@ export const AppLandingEntrySchema = lazySchema(() => strictObject( * and cannot be named here. */ item: SnakeCaseIdentifierSchema - .describe("Id of the app's own navigation entry this audience lands on (any depth of `navigation` or `areas[].navigation`). Refused at parse when no such entry exists, or when it is a `group`, `separator` or `action` entry, which open nothing.") + .describe("Id of the app's own navigation entry this audience lands on (any depth of `navigation` or `areas[].navigation`). Refused at parse when no such entry exists, or when it is a `group`, `separator`, `action` or `url` entry, which open nothing inside the app.") .meta({ title: 'Navigation item' }), /** @@ -1652,8 +1652,12 @@ export type AppLandingEntry = z.input; /** Post-parse shape of {@link AppLandingEntry} — defaults applied, transforms run (ADR-0122). */ export type AppLandingEntryParsed = z.infer; -/** Navigation entry types a landing may not name: none of them opens a page. */ -const LANDING_TARGET_REFUSED_TYPES: ReadonlySet = new Set(['group', 'separator', 'action']); +/** + * Navigation entry types a landing may not name: none of them opens a page + * inside the app. A `url` entry opens an external address, and the app-root + * landing is an in-app redirect, so a `url` landing would land nobody (#28450). + */ +const LANDING_TARGET_REFUSED_TYPES: ReadonlySet = new Set(['group', 'separator', 'action', 'url']); /** Every navigation entry of one tree, at any depth, by `id`, first declaration wins. */ function collectNavEntriesById(items: unknown, into: Map): void { @@ -1722,8 +1726,8 @@ export function checkAppLandingItems(app: unknown, ctx: z.RefinementCtx): void { path: ['landing', index, 'item'], message: `\`landing[${index}].item\` names \`${entry.item}\`, a \`${type}\` navigation entry, which opens no page, ` - + 'so a reader could not land on it. Name an entry that opens something (an object, page, dashboard, report, ' - + 'component, doc or url entry), e.g. one of the entries inside that group.', + + 'so a reader could not land on it. Name an entry that opens something inside the app (an object, page, dashboard, ' + + 'report, component or doc entry), e.g. one of the entries inside that group.', }); } } @@ -2041,7 +2045,9 @@ export const AppSchema = lazySchema(() => strictObject( * with the evaluator a navigation entry's `visible` is asked with, over the * same reader roots (`current_user` and `features`; no `data`). A predicate * that faults, a root outside those included, passes its entry over, and the - * next entry is asked. + * next entry is asked. A `doc` entry is a landing like any other: it opens + * the in-app docs portal, and which docs a reader may open is the server's + * audience gate on the doc (objectstack#19790), not the landing's. * * @example Sales staff open on Leads, service staff on the service console * ```ts @@ -2052,7 +2058,7 @@ export const AppSchema = lazySchema(() => strictObject( * ``` */ landing: z.array(AppLandingEntrySchema).min(1).optional() - .describe('Per-audience landing: the first entry whose `visible` (CEL) holds for the reader names the navigation entry (`item`, an `id` of this app\'s own `navigation` or `areas[].navigation`) the app opens on; an entry whose `item` the reader\'s own navigation gates hide, or whose `visible` faults, is passed over. An `item` that names no entry of this app, or a `group` / `separator` / `action` entry, is refused at parse. No match, or no key: the app opens on its first navigation item by `order`, as before.'), + .describe('Per-audience landing: the first entry whose `visible` (CEL) holds for the reader names the navigation entry (`item`, an `id` of this app\'s own `navigation` or `areas[].navigation`) the app opens on; an entry whose `item` the reader\'s own navigation gates hide, or whose `visible` faults, is passed over. An `item` that names no entry of this app, or a `group` / `separator` / `action` / `url` entry, is refused at parse. No match, or no key: the app opens on its first navigation item by `order`, as before.'), /** * Access Control From 910aded591e09fd1e66c366188bc857a13c21518 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 02:08:43 +0000 Subject: [PATCH 10/12] docs(console): a declared landing lands on a doc entry under the server's audience gate The nav gates docblock said no consumer of navEntriesShownToMember lands on a doc; the app-root landing now does. Say so, and say the landing's pass-over of a target with no in-app route is defence behind the spec's parse refusal. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibw9TMGcWfAYsNMXXRo --- .../app-shell/src/console/appDeclaredLanding.ts | 10 +++++++--- ui/packages/app-shell/src/hooks/useNavMemberGates.ts | 5 ++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/ui/packages/app-shell/src/console/appDeclaredLanding.ts b/ui/packages/app-shell/src/console/appDeclaredLanding.ts index e620c3dd014..683876e84ec 100644 --- a/ui/packages/app-shell/src/console/appDeclaredLanding.ts +++ b/ui/packages/app-shell/src/console/appDeclaredLanding.ts @@ -37,9 +37,13 @@ * as this member is SHOWN them (the app's `navigation` first, then each of its * `areas[].navigation`): an entry the member's own navigation gates hide is * passed over, as the spec's `AppSchema.landing` docblock states. An entry - * whose route the console cannot open inside the app (`routeOf` answers `''`: - * a `url` entry, or an entry with no target) is passed over too. When no entry - * lands, the caller keeps today's rule: the first navigation entry by `order`. + * whose route the console cannot open inside the app (`routeOf` answers `''`) + * is passed over too: the spec refuses such a target at parse (a `group`, + * `separator`, `action` or `url` entry), so for a parsed app this pass-over is + * defence only. A `doc` entry lands: it opens the in-app docs portal, and which + * docs a reader may open is the server's audience gate (objectstack#19790). + * When no entry lands, the caller keeps today's rule: the first navigation + * entry by `order`. * * Internal module: not re-exported by the package entry. */ diff --git a/ui/packages/app-shell/src/hooks/useNavMemberGates.ts b/ui/packages/app-shell/src/hooks/useNavMemberGates.ts index 41c3a8f1fb5..6b9e663337b 100644 --- a/ui/packages/app-shell/src/hooks/useNavMemberGates.ts +++ b/ui/packages/app-shell/src/hooks/useNavMemberGates.ts @@ -412,7 +412,10 @@ export function useNavMemberGates(forApp?: Record): NavMemberGa * console's sidebar always wires an action dispatcher, so it draws one. A `doc` * entry is kept as served: the sidebar's doc-readability answer is defence in * depth behind the server, which already drops an unreadable one - * (objectstack#19790), and no consumer of this function lands on a `doc`. + * (objectstack#19790). The console's app-root landing reads these trees and + * does land on a `doc` entry an app's `landing` names (objectstack#28450): it + * opens the in-app docs portal, under the server's audience gate on the doc, + * without the sidebar's doc-readability answer. */ export function navEntriesShownToMember(items: NavigationItem[], gates: NavMemberGates): NavigationItem[] { const guards: NavigationVisibilityOptions = { From 65522b14a47ad98a5f173c00d57fe63c35aafc12 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 02:09:36 +0000 Subject: [PATCH 11/12] feat(spec): register the url landing refusal for os migrate meta The changeset carries a FROM -> TO line for the url landing refusal, so the ADR-0087 disposition is a registered semantic entry rather than the no-migration-prescription exemption. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibw9TMGcWfAYsNMXXRo --- .changeset/28450-app-landing-console.md | 6 ++-- .../18.app-landing-url-item-refused.ts | 29 +++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) create mode 100644 packages/spec/src/migrations/entries/semantic/18.app-landing-url-item-refused.ts diff --git a/.changeset/28450-app-landing-console.md b/.changeset/28450-app-landing-console.md index ddd5cb016b0..34c81a19611 100644 --- a/.changeset/28450-app-landing-console.md +++ b/.changeset/28450-app-landing-console.md @@ -9,7 +9,7 @@ feat(console)!: an app opens on the first `landing` entry whose `visible` holds Clause-②: no (narrowing) Metadata-protocol: narrowed - + **BREAKING** accept-set narrowing in two places: `@objectstack/spec` refuses a `landing` entry that names a `url` navigation entry, and objectui's `app` node (`@object-ui/types` `AppComponentSchema`, and the published `objectui validate` door on it) runs the spec's landing check, so it refuses what the spec refuses. @@ -17,9 +17,9 @@ Metadata-protocol: narrowed - **The predicate's scope.** `visible` is asked with the evaluator the app's navigation `visible` uses, over the same roots: `current_user` (and its `user`, `ctx.user` and `os.user` aliases) and `features`. No `data` is bound. A predicate naming any other root (`record`, `app`) faults. - **A fault passes the entry over.** A predicate the evaluator cannot answer is reported once in the browser console (the navigation gate's `withholding` line) and its entry is skipped; the next entry is asked. The landing only decides where a reader opens: every navigation entry keeps its own `visible`, `requiredPermissions` and `requiresObject` gates. - **Only an entry the member is shown.** The `item` is looked up, at any depth, in the app's `navigation` and then each `areas[].navigation`, as the member is shown them. An entry the member's navigation gates hide is passed over. A `doc` entry lands: it opens the in-app docs portal, and which docs a reader may open stays the server's audience gate on the doc. -- **objectui refuses what the spec refuses.** `AppComponentSchema` now runs `checkAppLandingItems`: a `landing` entry whose `item` names no entry of the app's own navigation, or names a `group`, `separator` or `action` entry, is refused at `landing.N.item`, and a `visible` that reads `data` at `landing.N.visible`, with the spec's own message. To fix such a document, name an entry the app declares that opens a page, and gate on `current_user` or `features`. +- **objectui refuses what the spec refuses.** `AppComponentSchema` now runs `checkAppLandingItems`: a `landing` entry whose `item` names no entry of the app's own navigation, or names a `group`, `separator`, `action` or `url` entry, is refused at `landing.N.item`, and a `visible` that reads `data` at `landing.N.visible`, with the spec's own message. To fix such a document, name an entry the app declares that opens a page, and gate on `current_user` or `features`. - **Deriving from `AppComponentSchema`.** It now carries a refinement, and zod refuses `.extend()`, `.omit()` and `.pick()` on an object that carries one. Derive from it with `AppComponentSchema.shape` or `.safeExtend()`. - **`@objectstack/spec` refuses a `url` landing.** A `url` navigation entry opens an external address, and the app-root landing is an in-app redirect, so a `url` landing would land nobody. `checkAppLandingItems` now refuses it at `landing.N.item` with the `group` / `separator` / `action` refusal, and the message no longer lists `url` among the entries that open something. - FROM → TO: `landing: [{ item: 'nav_help_site', visible: … }]` naming a `url` entry → `landing: [{ item: 'nav_help', visible: … }]` naming an in-app entry the app declares (an `object`, `page`, `dashboard`, `report`, `component` or `doc` entry). + FROM → TO: `landing: [{ item: 'nav_help_site', visible: … }]` naming a `url` entry → `landing: [{ item: 'nav_help', visible: … }]` naming an in-app entry the app declares (an `object`, `page`, `dashboard`, `report`, `component` or `doc` entry). `landing` arrived after `@objectstack/spec` 18.2.0, so only an app written against an unreleased build carries one; `os migrate meta` lists the change (`app-landing-url-item-refused`), with no conversion. - **The liveness row flips.** `app.landing` (with `item` and `visible`) is `live` in `@objectstack/spec`'s liveness ledger, and its author warning is dropped. diff --git a/packages/spec/src/migrations/entries/semantic/18.app-landing-url-item-refused.ts b/packages/spec/src/migrations/entries/semantic/18.app-landing-url-item-refused.ts new file mode 100644 index 00000000000..2524027b9b9 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.app-landing-url-item-refused.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #28450 — an app's `landing` entry may not name a `url` navigation entry. +// The console's app-root landing is an in-app redirect and a `url` entry opens +// an external address, so such an entry landed nobody and the reader opened on +// the first navigation item with no word said. The spec now refuses it at +// parse beside `group`, `separator` and `action`. Semantic only: which in-app +// entry that audience should open on is the author's choice, so no conversion +// maps it. `landing` arrived after `@objectstack/spec` 18.2.0, so only an app +// written against an unreleased build can carry one. +export const entry: SemanticMigration = { + id: 'app-landing-url-item-refused', + surface: 'an app\'s `landing[N].item` naming a navigation entry of `type: \'url\'` (in the app\'s own ' + + '`navigation` or any `areas[].navigation`, at any depth)', + replacement: 'name an in-app entry the app declares instead: an `object`, `page`, `dashboard`, `report`, ' + + '`component` or `doc` entry, e.g. `{ item: \'nav_help_site\', visible: … }` naming a `url` entry becomes ' + + '`{ item: \'nav_help\', visible: … }` naming a `page` entry that carries the same help; or drop the ' + + 'landing entry, and that audience opens on the first navigation item by `order`.', + reason: 'A landing decides where a reader opens inside the app, and the console\'s app-root landing is an ' + + 'in-app redirect: a `url` entry opens an external address, so the console found no in-app route for it and ' + + 'passed the entry over in silence, which is the silent fallback `landing` was designed to refuse. ' + + '`checkAppLandingItems` now refuses a `url` target at `landing.N.item`, on `AppSchema` and on objectui\'s ' + + '`app` node, and its message no longer lists `url` among the entries that open something. No conversion is ' + + 'registered: which in-app entry the audience should open on is the author\'s choice.', + acceptanceCriteria: 'Run `objectstack validate`: it reports no finding at any app\'s `landing.N.item`. Sign in ' + + 'as a member of each audience the landing names, open the app root, and confirm each opens on its entry.', +}; From 531565de8fad4f0eab038a170f98327ee89025e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 02:57:51 +0000 Subject: [PATCH 12/12] chore(spec): the landing liveness evidence names url among the refused targets Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibw9TMGcWfAYsNMXXRo --- packages/spec/liveness/app.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/spec/liveness/app.json b/packages/spec/liveness/app.json index 19e51d6e8ac..b8d3a4ea82c 100644 --- a/packages/spec/liveness/app.json +++ b/packages/spec/liveness/app.json @@ -318,7 +318,7 @@ "status": "live", "verifiedAt": "2026-10-31", "evidenceScope": "in-repo", - "evidence": "ui/packages/app-shell/src/console/appDeclaredLanding.ts#resolveDeclaredLandingRoute (the read point: the entries in order, each `visible` asked with the navigation's own evaluator, the first that holds opens the entry its `item` names when the member is shown it; a faulting predicate is passed over); ui/packages/app-shell/src/console/AppContent.tsx#AppRootLanding (the app root's redirect calls it over the trees the member is shown, through `useNavMemberGates`, and keeps the first navigation item by `order` when no entry lands); packages/spec/src/ui/app.zod.ts#checkAppLandingItems (the parse-time door, mounted on AppSchema and on objectui's `AppComponentSchema`: an `item` naming no entry of the app's own `navigation` / `areas[].navigation`, or a `group` / `separator` / `action` entry, is refused at `landing.N.item`, and a `visible` reading `data` at `landing.N.visible`)", + "evidence": "ui/packages/app-shell/src/console/appDeclaredLanding.ts#resolveDeclaredLandingRoute (the read point: the entries in order, each `visible` asked with the navigation's own evaluator, the first that holds opens the entry its `item` names when the member is shown it; a faulting predicate is passed over); ui/packages/app-shell/src/console/AppContent.tsx#AppRootLanding (the app root's redirect calls it over the trees the member is shown, through `useNavMemberGates`, and keeps the first navigation item by `order` when no entry lands); packages/spec/src/ui/app.zod.ts#checkAppLandingItems (the parse-time door, mounted on AppSchema and on objectui's `AppComponentSchema`: an `item` naming no entry of the app's own `navigation` / `areas[].navigation`, or a `group` / `separator` / `action` / `url` entry, is refused at `landing.N.item`, and a `visible` reading `data` at `landing.N.visible`)", "note": "FLIPPED planned → live 2026-10-31 (card 28450, the domain:ui half of card 28022, ruling record 6545223246, letter A): the console's app-root landing reads the list, and `authorWarn` is dropped with the flip. Scope: the predicate is asked in the scope the app's navigation `visible` is asked in, the roots `buildExpressionScope` binds for the shell (`current_user` with its `user` / `ctx.user` / `os.user` aliases, and `features`); no `data` is bound, and a root outside that scope (`record`, `app`) faults. Fault face: a faulting entry is passed over and reported once by the navigation evaluator (`withholding`), the next entry is asked, and with none left the app opens on its first navigation item. An entry whose item the member's navigation gates hide is passed over too; a `url` item, which opens no in-app route, is refused at parse by `checkAppLandingItems`. Pinned in ui/packages/app-shell/src/console/__tests__/AppContent.declaredLanding-28450.test.tsx.", "children": { "item": {