Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/28450-app-landing-console.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@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: narrowed

<!-- adr-0087: registered app-landing-url-item-refused -->

**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. 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`, `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). `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.
2 changes: 1 addition & 1 deletion content/docs/references/api/metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ const result = AppDefinitionResponseSchema.parse(data);
| **navigation** | `({ id: string; label?: string \| Record<string, string>; 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<string, string>; icon?: string; description?: string \| Record<string, string>; … }[]` | optional | Navigation areas for partitioning navigation by business domain |
| **contextSelectors** | `{ id: string; label: string \| Record<string, string>; icon?: string; optionsSource: object; … }[]` | optional | App-level scope dropdowns whose value is injected into nav items as `{<id>}` 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` / `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. |
Expand Down
6 changes: 3 additions & 3 deletions content/docs/references/ui/app.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ const result = ActionNavItemSchema.parse(data);
| **navigation** | `({ id: string; label?: string \| Record<string, string>; 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<string, string>; icon?: string; description?: string \| Record<string, string>; … }[]` | optional | Navigation areas for partitioning navigation by business domain |
| **contextSelectors** | `{ id: string; label: string \| Record<string, string>; icon?: string; optionsSource: object; … }[]` | optional | App-level scope dropdowns whose value is injected into nav items as `{<id>}` 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` / `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. |
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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. |


Expand Down
16 changes: 7 additions & 9 deletions content/docs/ui/apps.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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`.
Expand All @@ -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.

<Callout type="warn">
**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.
</Callout>
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

Expand Down
8 changes: 4 additions & 4 deletions packages/qa/dogfood/test/expression-conformance.ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading
Loading