Repository navigation
Commit 80a9f99
feat(spec): an app can name a landing per audience with landing (#28449)
domain:spec#2 · os-tesla · session_01L9qibx5r5nhDMwpRs4SL6D
<!-- claude-projects-attribution: {"github_login":"os-tesla"} -->
_Requested by **Tesla** · [project
thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6Jao85bLkygRuu1iHVbxaGNzrb)_
Fixes #28022
Clause-②: yes (widening)
Metadata-protocol: added
domain:spec#2 · os-tesla · session_01L9qibwXXtueDQZXqxv2FsL
Ruling: record 6545223246 on the card, letter A (maintainer 「同意」
2026-10-31T15:10Z). This is the spec half only. Follow-up: #28450, the
domain:ui landing card (console landing redirect, plus attaching
`checkAppLandingItems` to objectui's `AppComponentSchema`, which drops
the one `notAttachable` entry this PR adds).
## 维护者速读
- 改了什么:应用新增 `landing` 键,按受众声明落地页:`[{ item, visible }]`,第一个 `visible`
成立的条目决定读者打开哪个导航项;无匹配或不写时照旧打开第一个导航项。
- 为什么改:同一个应用给销售和客服共用时,只能有一个落地页;把"销售"挪到"客服"前面,客服的人一进来就落在线索上。
- 风险与回滚:只加不减,旧应用不受影响;`item` 指向不存在的导航项、或指向分组/分隔线/动作项,保存时直接拒收(不会像退役的
`homePageId` 那样悄悄落到第一项)。回滚即还原本 PR。
- 先看:`packages/spec/src/ui/app.zod.ts`(`checkAppLandingItems` 与
`AppLandingEntrySchema`)、`packages/spec/liveness/app.json`(`landing` 标为
planned 并提示作者)、`packages/lint/src/validate-expressions.ts`(落地谓词的表达式检查)。
- 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run
src/ui/app-landing.test.ts`,应见 14 个用例全绿,其中悬空 `item` 在 `landing.0.item`
处被拒、并列出存在的导航项 id。
- 需要你做的:无,席位落地。
## What changes
- **`AppSchema.landing`** (`packages/spec/src/ui/app.zod.ts`): an
optional, non-empty list of `AppLandingEntrySchema` entries, `{ item,
visible }`, both required. The console asks the entries in order and
opens the first whose `visible` holds for the reader (and whose `item`
the reader can see). No match, or no key: the app opens on its first
navigation item by `order`, as today. The describe text states both
rules.
- **The refusal the `homePageId` tombstone asked for.**
`checkAppLandingItems`, a `superRefine` on `AppSchema`, runs on every
app parse (so `defineApp`, `os validate` / `os build` and the metadata
save door all refuse alike). It refuses, at `landing.N.item`, an `item`
that names no entry of the app's own `navigation` or
`areas[].navigation` (any depth through `children`), listing the
openable ids that exist; and an `item` that names a `group`, `separator`
or `action` entry, none of which opens a page. It also refuses, at
`landing.N.visible`, a predicate that reads `data` (a landing entry has
no `dataSource`), with the same lexer the navigation entry's own `data`
refusal uses. It is exported (and barrel-reachable) for the same reason
as `navItemVisibleDataNeedsDataSource`: a mirror attaches the function
instead of restating the rule.
- **Why an id into `navigation`, not an inline target** (the order's
either/or): the landing is a sidebar entry, so the sidebar can highlight
what the reader landed on, and there is one copy of each navigation
target. The id's old failure mode, dangling into a silent fallback, is
closed by the parse-time refusal above, so the cross-reference is
checked rather than trusted.
- **Why `visible` is required**: an entry with no predicate would land
every reader there, which is `homePageId` again under a new name. "Open
somewhere else for everyone" stays "put that entry first in
`navigation`".
- **Spellings**: `landings` / `landingPages` route to `landing`; on an
entry, `navigationId` / `navItem` / `itemId` / `target` / `page` route
to `item`, and `visibleWhen` / `visibleOn` / `when` / `condition` to
`visible`. The retired `homePageId` prescription (and its `home` /
`homepage` / `landingpage` spellings) now also names `landing`, and the
tombstone docblock's last paragraph says how `landing` answers both of
its objections.
- **Lint** (`packages/lint/src/validate-expressions.ts`): `os validate`
/ `os build` judge each landing predicate with the shared validator's
data-independent verdicts (CEL syntax, size, unknown function, `has()`
shape). It passes no object and no `record` scope, because the slot
binds no record and a `record`-scoped call would prescribe
`record.FIELD`.
- **Ledgers and tables the gates force**: liveness `app.landing` row
`planned` + `authorWarn` + `authorHint` (children `item`, `visible`);
evaluated-slot census 32 → 33; dogfood expression-conformance row
`cel-app-landing` (`experimental`, `unevaluated`); form-reconciliation
`omit` row (planned, no form control); `/meta/types` `app` count 22 →
23; `dropped-refinements.baseline.json` (+1 site in six entries, the new
object-level check, hand-edited per the ledger's rule); objectui census
`spec-object-refinements-7715.test.ts` names `checkAppLandingItems`
under `notAttachable` (test-only line, allowed by the claim amendment
6545506798), owed to the follow-up ui card.
- **Docs**: `content/docs/ui/apps.mdx` gains "Landing per audience"
(shape, order rule, refusals, a Planned callout) and a property-table
row; references regenerated.
- **Changeset**: `.changeset/28022-app-landing-per-audience.md`,
`@objectstack/spec` and `@objectstack/lint` minor.
## Why
An app shared by more than one audience has one landing: its first
navigation entry. In HotCRM, moving Sales above Service moves service
staff's landing to Leads (#27386 row 5). The maintainer ruled letter A:
an app-level landing list with `visible` predicates, first match wins.
## Tests
- `packages/spec/src/ui/app-landing.test.ts` (new, 14 cases): accepted
shapes (top-level, nested, area-only items; bare string normalised to
the CEL envelope), the control (no key parses as before), refusals at
the entry's own path (dangling item listing the existing ids, `group` /
`action` / `separator` targets, a `data` read, a missing `visible`, an
empty list, near-miss spellings), barrel identity, and both `visible`
spellings through the exported function.
- `packages/lint/src/validate-expressions.test.ts`: a non-CEL landing
predicate is an error at `app 'crm' · landing[0] ('nav_leads') visible`;
a healthy reader predicate is clean; the #5017 read-surface table gains
`appDef` (AppSchema) and `landingEntry` (AppLandingEntrySchema), and
`stack` gains `apps`.
- Ablations (one-off, then restored; restore proven by an empty `git
diff HEAD`): removing the `.superRefine(checkAppLandingItems)` mount →
`app-landing.test.ts` 5 failed / 9 passed; removing the lint
`check(...)` call → `validate-expressions.test.ts -t landing` 2 failed
(the landing case and the read-surface row).
## Verification
<details><summary>Local runs, head 1308512</summary>
Derived list (`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`), run at 4437a3b, each line with stdin from
/dev/null. Reconciled: `dispatch-gates --ran`: 108 derived, 108 run, 0
NOT-MEASURED. Five lines first exited 3 or 1 on a missing prerequisite
(an unfetched pinned commit; packages with no built declarations) and
were rerun after `git fetch` of the pinned sha and a full `pnpm build`
with declarations; the record below carries the rerun codes.
```
node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0
node scripts/check-adr-0087-registration.mjs --self-test :: exit 0
node scripts/check-adr-links.mjs :: exit 0
node scripts/check-adr-links.mjs --self-test :: exit 0
node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0
node scripts/check-closing-keyword-parity.mjs :: exit 0
node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0
node scripts/check-comment-mask-adoption.mjs :: exit 0
node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0
node scripts/check-comment-mask-corpus.mjs :: exit 0
node scripts/check-dev-prereqs.mjs --self-test :: exit 0
node scripts/check-doc-route-spelling.mjs --advisory :: exit 0
node scripts/check-doc-route-spelling.mjs --self-test :: exit 0
node scripts/check-docs-section-name.mjs :: exit 0
node scripts/check-docs-section-name.mjs --self-test :: exit 0
node scripts/check-dts-emitted.mjs --self-test :: exit 0
node scripts/check-empty-changeset.mjs --base origin/main :: exit 0
node scripts/check-empty-changeset.mjs --self-test :: exit 0
node scripts/check-issue-citations.mjs :: exit 0
node scripts/check-plugin-teardown-shape.mjs :: exit 0
node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0
node scripts/check-sdui-manifest.mjs :: exit 0
node scripts/check-sdui-manifest.mjs --self-test :: exit 0
node scripts/check-section-landing-index.mjs :: exit 0
node scripts/check-section-landing-index.mjs --self-test :: exit 0
node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0
node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0
node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0
node scripts/docs-audit/check-affected-docs.mjs :: exit 0
node scripts/docs-audit/check-drift-comment.mjs :: exit 0
node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0
node scripts/release-pending-publish.mjs --self-test :: exit 0
pnpm --dir ui exec vitest run scripts/ :: exit 0
pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 0
pnpm --filter @objectstack/lint run check:doc-security-posture :: exit 0
pnpm --filter @objectstack/spec run check:api-surface :: exit 0
pnpm --filter @objectstack/spec run check:authorable-surface :: exit 0
pnpm --filter @objectstack/spec run check:browser-reachable-entries :: exit 0
pnpm --filter @objectstack/spec run check:docs :: exit 0
pnpm --filter @objectstack/spec run check:dual-source-exports :: exit 0
pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0
pnpm --filter @objectstack/spec run check:empty-state :: exit 0
pnpm --filter @objectstack/spec run check:entry-nameability :: exit 0
pnpm --filter @objectstack/spec run check:export-origins :: exit 0
pnpm --filter @objectstack/spec run check:exported-any :: exit 0
pnpm --filter @objectstack/spec run check:generated :: exit 0
pnpm --filter @objectstack/spec run check:liveness :: exit 0
pnpm --filter @objectstack/spec run check:llms-txt :: exit 0
pnpm --filter @objectstack/spec run check:skill-examples :: exit 0
pnpm --filter @objectstack/spec run check:skill-refs :: exit 0
pnpm --filter @objectstack/spec run check:strictness-ledger :: exit 0
pnpm --filter @objectstack/spec run check:variant-docs :: exit 0
pnpm --filter @objectstack/spec run check:yaml-examples :: exit 0
pnpm check:changeset-gate-self-tests :: exit 0
pnpm check:ci-filter-parity :: exit 0
pnpm check:closed-ceremony-text :: exit 0
pnpm check:corpus-claim-drift :: exit 0
pnpm check:cross-package-test-inputs :: exit 0
pnpm check:dispatcher-error-vocabulary :: exit 0
pnpm check:doc-anchors :: exit 0
pnpm check:doc-authoring :: exit 0
pnpm check:doc-frontmatter :: exit 0
pnpm check:docs-audit-scope :: exit 0
pnpm check:docs-redirects :: exit 0
pnpm check:docs-single-h1 :: exit 0
pnpm check:docs-spec-enumerations :: exit 0
pnpm check:docs-transcript-drift :: exit 0
pnpm check:driver-memory-census :: exit 0
pnpm check:dts-closure :: exit 0
pnpm check:dual-build-cjs-loads :: exit 0
pnpm check:durability-log-level :: exit 0
pnpm check:engine-double-contract :: exit 0
pnpm check:gitlink-declared :: exit 0
pnpm check:issue-citations :: exit 0
pnpm check:keyed-text-bounds :: exit 0
pnpm check:lean-entry-closure :: exit 0
pnpm check:logger-receiver-detach :: exit 0
pnpm check:merge-driver :: exit 0
pnpm check:nul-bytes :: exit 0
pnpm check:objectql-double-limit :: exit 0
pnpm check:org-identifier :: exit 0
pnpm check:page-declaration-shape :: exit 0
pnpm check:platform-checklist :: exit 0
pnpm check:platform-object-tenancy-census :: exit 0
pnpm check:pm-changeset-deadline-census :: exit 0
pnpm check:pm-prior-rulings :: exit 0
pnpm check:published-files :: exit 0
pnpm check:published-readme-links :: exit 0
pnpm check:query-options-erasure :: exit 0
pnpm check:react-page-adapter-contract :: exit 0
pnpm check:refd-timer-probe :: exit 0
pnpm check:registry-log-declared :: exit 0
pnpm check:rest-log-spy-declared :: exit 0
pnpm check:role-word :: exit 0
pnpm check:skill-identifier-liveness :: exit 0
pnpm check:slot-lookup :: exit 0
pnpm check:sourcemap-no-sources-content :: exit 0
pnpm check:spec-docblock-symbol-anchors :: exit 0
pnpm check:spec-parsed-alias :: exit 0
pnpm check:system-context-census :: exit 0
pnpm check:test-source-alias :: exit 0
pnpm check:tier-file-adoption :: exit 0
pnpm check:type-check-coverage :: exit 0
pnpm check:type-check-debt :: exit 0
pnpm check:undeclared-dep-imports :: exit 0
pnpm check:vendor-version-stamps :: exit 0
pnpm check:watch-hint-literal :: exit 0
pnpm check:where-matcher :: exit 0
```
Then `main` was merged (8 commits touching the same generated ui
artifacts), the artifacts were regenerated (`check:generated --fix`,
then `check:generated` exit 0), and these were rerun at 1308512:
```
pnpm --filter @objectstack/spec exec vitest run src/ui src/shared src/system/metadata-form-zod-reconciliation.test.ts :: exit 0
pnpm --filter @objectstack/spec exec tsc --noEmit -p tsconfig.json :: exit 0
pnpm --filter @objectstack/lint exec vitest run src/validate-expressions.test.ts :: exit 0
pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol.meta-types-degenerate-derivation.test.ts :: exit 0
pnpm --dir ui exec vitest run packages/types/src/__tests__/spec-object-refinements-7715.test.ts :: exit 0
pnpm --filter @objectstack/dogfood exec vitest run test/expression-conformance :: exit 0
pnpm --filter @objectstack/spec run check:liveness :: exit 0
```
</details>
## Acceptance notes
- **How this meets the tombstone's "enforce-first" sentence.** The
maintainer ruled spec first. The liveness row is `planned` with
`authorWarn`, so an author who writes `landing` is told no console
applies it yet; the describe text opens with `[PLANNED — not yet
rendered]`; the docs page carries a Planned callout. The tombstone's
other objection, the silent dangling fallback, is closed now, at parse.
- **Not judged at the app save door: the predicate's CEL syntax.**
`validateStackExpressions` does not list `app` in its `runtimeTypes`, so
the lint half runs at `os validate` / `os build` only. The parse
refusals (dangling item, non-opening target, `data` read, blank /
`ast`-only predicate) do run at the save door. Widening the rule's
runtime types is a publish-gate change, left out of this card.
- **Not judged: which roots the predicate reads.** A landing predicate
reading `record.x` passes lint (no bare-identifier or root verdict on
this slot). The console redirect card decides the evaluation scope; a
root verdict belongs with it.
- **`navigationContributions` items cannot be named.** The refusal reads
the app as parsed, and a contributed item is not part of it. Stated in
the describe text and the docs.
- **Follow-up #28450:** the domain:ui landing card: the console landing
redirect reads `landing`, flips the liveness row to `live`, attaches
`checkAppLandingItems` to objectui's `AppComponentSchema` and drops the
`notAttachable` entry, and re-classifies the `cel-app-landing`
conformance row.
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `pnpm lint` passes (repo-wide lint is CI's
run).
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: its generated artifacts are
regenerated and committed.
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01L9qibwXXtueDQZXqxv2FsL)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 4bc963c commit 80a9f99
23 files changed
Lines changed: 595 additions & 31 deletions
File tree
- .changeset
- content/docs
- references
- api
- ui
- ui
- docs/audits/2026-07-unknown-key-strictness-ledger.counts
- packages
- lint/src
- metadata-protocol/src
- qa/dogfood/test
- spec
- api-surface
- authorable-surface
- declaration-map
- export-origins
- json-schema.manifest
- liveness
- state-counts
- src
- system
- ui
- ui/packages/types/src/__tests__
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
95 | 95 | | |
96 | 96 | | |
97 | 97 | | |
| 98 | + | |
98 | 99 | | |
99 | 100 | | |
100 | 101 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
| 4 | + | |
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
| |||
33 | 33 | | |
34 | 34 | | |
35 | 35 | | |
36 | | - | |
37 | | - | |
| 36 | + | |
| 37 | + | |
38 | 38 | | |
39 | 39 | | |
40 | 40 | | |
| |||
371 | 371 | | |
372 | 372 | | |
373 | 373 | | |
374 | | - | |
| 374 | + | |
375 | 375 | | |
376 | 376 | | |
377 | 377 | | |
378 | 378 | | |
379 | 379 | | |
380 | 380 | | |
381 | 381 | | |
382 | | - | |
| 382 | + | |
383 | 383 | | |
384 | 384 | | |
385 | 385 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
| 4 | + | |
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
| |||
13 | 13 | | |
14 | 14 | | |
15 | 15 | | |
16 | | - | |
17 | | - | |
| 16 | + | |
| 17 | + | |
18 | 18 | | |
19 | 19 | | |
20 | 20 | | |
| |||
78 | 78 | | |
79 | 79 | | |
80 | 80 | | |
| 81 | + | |
81 | 82 | | |
82 | 83 | | |
83 | 84 | | |
| |||
302 | 303 | | |
303 | 304 | | |
304 | 305 | | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
305 | 313 | | |
306 | 314 | | |
307 | 315 | | |
| |||
350 | 358 | | |
351 | 359 | | |
352 | 360 | | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
353 | 373 | | |
354 | 374 | | |
355 | 375 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
49 | 49 | | |
50 | 50 | | |
51 | 51 | | |
| 52 | + | |
52 | 53 | | |
53 | 54 | | |
54 | 55 | | |
| |||
283 | 284 | | |
284 | 285 | | |
285 | 286 | | |
286 | | - | |
| 287 | + | |
| 288 | + | |
287 | 289 | | |
288 | 290 | | |
289 | 291 | | |
| |||
714 | 716 | | |
715 | 717 | | |
716 | 718 | | |
| 719 | + | |
| 720 | + | |
| 721 | + | |
| 722 | + | |
| 723 | + | |
| 724 | + | |
| 725 | + | |
| 726 | + | |
| 727 | + | |
| 728 | + | |
| 729 | + | |
| 730 | + | |
| 731 | + | |
| 732 | + | |
| 733 | + | |
| 734 | + | |
| 735 | + | |
| 736 | + | |
| 737 | + | |
| 738 | + | |
| 739 | + | |
| 740 | + | |
| 741 | + | |
| 742 | + | |
| 743 | + | |
| 744 | + | |
| 745 | + | |
| 746 | + | |
| 747 | + | |
| 748 | + | |
| 749 | + | |
| 750 | + | |
| 751 | + | |
| 752 | + | |
| 753 | + | |
| 754 | + | |
| 755 | + | |
| 756 | + | |
| 757 | + | |
| 758 | + | |
| 759 | + | |
| 760 | + | |
| 761 | + | |
| 762 | + | |
| 763 | + | |
| 764 | + | |
| 765 | + | |
| 766 | + | |
| 767 | + | |
| 768 | + | |
| 769 | + | |
| 770 | + | |
| 771 | + | |
| 772 | + | |
| 773 | + | |
| 774 | + | |
| 775 | + | |
717 | 776 | | |
718 | 777 | | |
719 | 778 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
21 | 21 | | |
22 | 22 | | |
23 | 23 | | |
24 | | - | |
| 24 | + | |
25 | 25 | | |
26 | 26 | | |
27 | 27 | | |
| |||
33 | 33 | | |
34 | 34 | | |
35 | 35 | | |
36 | | - | |
| 36 | + | |
37 | 37 | | |
38 | 38 | | |
39 | 39 | | |
| |||
46 | 46 | | |
47 | 47 | | |
48 | 48 | | |
49 | | - | |
| 49 | + | |
50 | 50 | | |
51 | 51 | | |
52 | 52 | | |
53 | 53 | | |
54 | 54 | | |
55 | 55 | | |
56 | 56 | | |
57 | | - | |
| 57 | + | |
58 | 58 | | |
59 | 59 | | |
60 | 60 | | |
61 | 61 | | |
62 | | - | |
| 62 | + | |
63 | 63 | | |
64 | 64 | | |
65 | | - | |
| 65 | + | |
66 | 66 | | |
67 | 67 | | |
68 | 68 | | |
| |||
0 commit comments