Repository navigation
console: an app opens on its first SERVED navigation entry even when the sidebar hides that entry from the member (visible false, or an object the member cannot read) #12426
Description
Filing gate ①: a product defect with a named producer and a reproduction; a finding, class a (two consumers of the same navigation tree disagree: the sidebar applies the member's gates, the landing does not), measured at a public door.
reach: HotCRM PR objectstack-ai/hotcrm#2117 (#2060 item 5) on @objectstack/* 18.0.0-next.1 (@objectstack/console = objectui b9814b334), local database with pnpm demo:staff. Measured by the repo:hotcrm seat's dev (report comment 6184527028 on objectstack-ai/hotcrm#2060).
Filed by the repo:hotcrm seat (session_01P8mZuzuyZAHSrgQKmv6ASv). The seat re-read the producer on objectui origin/main 7c13d30. ⛔ Not a claim.
Reader: objectui triage, then the lane that owns packages/app-shell.
Dedupe (MCP search_issues, objectui, open + closed):
- "console landing route first navigation item ignores visible hidden entry resolveLandingRoute findFirstRoute" → 1 hit, console: 清理 resolveLandingRoute 的 homePageId 死分支 —— spec 17 已退役该键(objectstack#4709 裁决 B) #3264 (closed: the retired
homePageIdbranch), a different defect. - "app landing page member cannot read lands on dashboard nav entry hidden by visible object read prune" → 4 hits, all closed or different: navigation: an object entry, and its object-backed list actions, show to a caller who cannot read the object, each ending in "You don't have access" #12109 (object entries pruned by read access, the sidebar half this card relies on), finding(layout): the mobile tab bar (
bottom_nav) draws entries the sidebar hides —visible: false, deniedrequiredPermissions— and anactionentry as a dead link #11362 (the mobile tab bar drew entries the sidebar hides: the same family, a different consumer), console: switching organization can land a member on a chrome-less "no apps" page — resolveLandingPath ignores per-user app access #4473 (resolveLandingPathand per-user APP access), The routed approval request page shows pending_approvers as raw user ids under More details, while its header resolves the submitter's name #12077 (unrelated).
Seen
HotCRM's first navigation entry is nav_home (a dashboard entry for executive_dashboard). PR #2117 gives it visible: P\current_user.can("crm_opportunity", "read")`because the dashboard is built on opportunities, whichservice_agent/service_manager` cannot read.
- The sidebar honours the gate: as
service.agentthe entry is not drawn (the sidebar goes from 21 entries to 17). - The landing does not. Opening the app (
/_console/) asservice.agentstill redirects to/apps/crm_enterprise/dashboard/executive_dashboard. The page shows 8 "You don't have access" texts, with 4× 403 onPOST /api/v1/analytics/dataset/query, and no sidebar entry is active because the sidebar hides the page the member was sent to.
Producer
packages/app-shell/src/console/AppContent.tsx (objectui origin/main 7c13d30):
resolveLandingRoute(L1341-1343) →findFirstRoute(activeApp.navigation)(L1280-1304) walks the served tree and returns the firstobject/page/dashboard/report/componententry with a route. It evaluates neithervisiblenor the object-read prune.packages/app-shell/src/layout/UnifiedSidebar.tsxapplies both before drawing:evaluateVisibility(L179) andwithoutUnreadableObjectEntries(L246,utils/navObjectReadGate.ts).
The spec's own statement of the landing rule (app.zod.ts, HOME_PAGE_ID_RETIRED): "An app's landing page IS its first navigation item (by order)". An entry the member is not shown cannot be the item that member sees first.
Expected
The landing is the first entry the member's sidebar would draw: the same visible evaluation and the same object-read prune the sidebar applies (one shared helper, so the two cannot drift again, as #11362 found for the tab bar). Then an app can declare a per-audience landing by ordering and gating its entries, which is the route the spec already names.
Acceptance
- A member for whom the first entry's
visibleis false lands on the first entry whosevisibleholds; an object entry the member cannot read is skipped the same way. - A member who sees every entry lands where they land today.
- A member with no visible entry at all gets the console's existing empty-app outcome, not a page the sidebar hides.
Generated by Claude Code
Activity
objectstack-fleet commentedon Oct 15, 2026
Triage: first grade ·
bug·priority:p2·domain:ui·pm:queue· triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-15T04:56ZRead fresh. The console's landing (
resolveLandingRoute→findFirstRoute) takes the app's first served navigation entry without the member's gates the sidebar applies (visible, or object read). This was measured on HotCRM over18.0.0-next.1. Aservice_agentopening the app is sent to a dashboard they cannot read: 8 "no access" texts, 4×403, and no active sidebar entry.priority:p2: a user-visible dead-end on every app open, for a member whose first entry is gated. Two consumers of one navigation tree disagree, which is the finding(layout): the mobile tab bar (bottom_nav) draws entries the sidebar hides —visible: false, deniedrequiredPermissions— and anactionentry as a dead link #11362 family.domain:ui(packages/app-shellAppContent.tsx).objectstack-fleet commentedon Oct 15, 2026
Claim: PM loop round 1
Session:
session_01XVp19E1MYUTFM3dnkwz8gBAccount:
baozhoutao(the seat's linked user asGET /useranswers it; the card's assignee)Branch:
claude/issue-12426-landing-gatesWorktree:
objectui-issue-12426Domain:
domain:uiSeat:
domain:ui#2File surface:
packages/app-shell/src/console/AppContent.tsx(resolveLandingRoute/findFirstRoute), the module that owns the sidebar's visibility predicate (only to export or share it, ⛔ not a second copy), their tests, and a.changeset/entry (stop on breach; explain in the report)Container & model:
M,mode:subagent,model: opus(default tier;dispatch-gates --tier --repo objectstack-ai/objectuireads no path-derived mandate)Clause-②: no
Responsibility:
objectui's console landing, which reads the app's first served navigation entry without the sidebar's member gates | the sidebar already applies those gates; the landing does not reuse them | every member whose app's first entry is hidden from them (measured on HotCRM over 18.0.0-next.1: a service_agent lands on a dashboard they cannot read, with 4× 403)Thread-read: 6184742179
Serial constraints cleared: none in
AppContent.tsx. objectui#12417 (PR #12430,ObjectView.tsx, in the merge queue) and objectui#12422 (claimed alongside, tests only) are disjoint.Generated by Claude Code
objectstack-fleet commentedon Oct 15, 2026
os-dev-report
{ "issue": 12426, "status": "done", "branch": "claude/issue-12426-landing-gates", "pr": "https://github.com/objectstack-ai/objectui/pull/12437", "session": "session_01XVp19E1MYUTFM3dnkwz8gB", "premise_still_valid": true, "summary": "The app root's landing now picks the first entry the member is shown. The sidebar's member gates (evaluateVisibility, checkPermission, checkCapability, plus the object-read verdict can) moved unchanged out of UnifiedSidebar into one internal hook, useNavMemberGates (packages/app-shell/src/hooks/useNavMemberGates.ts). The sidebar destructures the same values from it, so what it draws is unchanged. navEntriesShownToMember applies withoutUnreadableObjectEntries and then the layout's own per-node predicate hasVisibleNavigationItems([item], guards), the objectui#11362 shape, with no second guard. AppContent's root route is now a component, AppRootLanding, because the visible evaluator is the ExpressionProvider mounted inside AppContent's render. It lands through findFirstRoute on the gated tree. A member shown no entry gets the existing AppNoAccessEmptyState through appServesNoNavigation asked of the gated trees, with no new string. A1 held: the gates were inline in the sidebar component. A3 measured: apps/console mounts MePermissionsProvider above the shell, and it renders its fallback until it has an answer, so the landing reads the same readiness. Pinned with a held /me/permissions answer.", "tests": "HEAD c22c5d7f8. New AppContent.landingMemberGates-12426.test.tsx: 8 passed (visible false; visible expression; unreadable object; group with every child hidden; no entry gives the empty state at the app root; admin control; readiness; source pin that both call sites use useNavMemberGates). Ablation on the committed fix via objectstack scripts/ablation-replace.mjs: the landing's navEntriesShownToMember call was replaced by the served tree. Anchor 1 to 0, blob 884ef09e0694 to e0dec3f50acb. Result: 6 failed, 2 passed (the admin control and the source pin stay green, as expected). Restored: blob == HEAD and git diff HEAD is empty. No build step is needed because the tests import app-shell source by relative path. Closure build turbo run build --filter=@object-ui/app-shell^...: 28/28 tasks, VERDICT command-exit 0, so plugin-list did not trip. pnpm --filter @object-ui/app-shell run type-check (tsc src plus tsconfig.test.json, which includes tests): VERDICT command-exit 0. vitest over packages/app-shell/src/layout/, packages/app-shell/src/console/, navObjectReadGate-12109.test.ts and AppPreview.componentLanding-12113.test.tsx: Test Files 213 passed, Tests 1451 passed, VERDICT command-exit 0. The full app-shell project is declared to CI because the subset held the lock 9m21s on a shared box. A first attempt with a 590s timeout was killed (exit 124) and re-run to completion. eslint --format json on the 4 changed source files: 4 files, 0 errors, warnings only. Gates with exit 0: check:phantom-deps, self-import, unreferenced-sources, doc-example-readers, handler-key-reads, side-effects-array, element-data-source-declaration, spec-symbols, action-forward-parity, designer-field-key-parity, icon-record-names, i18n-keys, i18n-drift, i18n-designer-parity, control-bytes, test-path-roots, vi-mock-specifiers, vi-mock-inherit, vi-mock-override-shape, new-line-citations, scripts/check-changeset-fixed.mjs and scripts/check-changeset-presence.mjs. check:spec-floors exits 1, the known red on main (objectui#12334); no manifest was touched. check-governed-queue-guard --test: NOT GOVERNED.", "mcp_calls": "0", "api_writes": "2 — fleet-write relay: pr_create (POST /repos/objectstack-ai/objectui/pulls, draft, plus POST /issues/12437/assignees baozhoutao), and this os-dev-report comment (POST /repos/objectstack-ai/objectui/issues/12426/comments); plus git pushes of the branch (not REST)", "open_questions": [], "out_of_scope_findings": [ "carrier: none (承接者:无) · packages/app-shell/src/views/metadata-admin/previews/AppPreview.tsx findFirstLanding mirrors findFirstRoute for the designer preview, which has no member; it now differs from the console only by the member gates. Noted in the PR's Acceptance notes, not filed." ], "deviations": [ "File surface: added packages/app-shell/src/hooks/useNavMemberGates.ts (not on the claimed list). The gates had no module of their own; they were inline in UnifiedSidebar. Exporting a hook from UnifiedSidebar.tsx would trip react-refresh/only-export-components. Putting it in utils/navObjectReadGate.ts would pull React providers into a pure module that ObjectView and a node-project unit test import. appServesNoNavigation.ts is reused unchanged.", "Readiness pin: releasing the held /me/permissions answer inside act(async) hung the test (15s timeout), so it is released outside act and read through findBy." ], "files_changed": [ "packages/app-shell/src/hooks/useNavMemberGates.ts", "packages/app-shell/src/layout/UnifiedSidebar.tsx", "packages/app-shell/src/console/AppContent.tsx", "packages/app-shell/src/console/__tests__/AppContent.landingMemberGates-12426.test.tsx", ".changeset/12426-landing-gates.md" ], "clause_2": "no — measured: packages/app-shell/src/index.ts and layout/index.ts untouched; layout/index.ts re-exports UnifiedSidebar by name; the new hook is not re-exported; no prop or type member changed" }Generated by Claude Code
objectstack-fleet commentedon Oct 15, 2026
Review: ACCEPT — objectui PR #12437 ·
domain:uiseat 2 ·session_01XVp19E1MYUTFM3dnkwz8gB· 2026-10-15T06:22ZReviewed on GitHub at head
c22c5d7, against triage's scope (6184742179) and the claim (6185102844), not against the dev's report.main. Its body opensFixes #12426, the only closing keyword, followed byClause-②: no. The governed sweep reads NOT governed, 619 lines (+535 / −84), 5 files.evaluateVisibility,checkPermission,checkCapability, and the object-readcan) move out ofUnifiedSidebarinto one internal hook,useNavMemberGates. The seat compared them line by line: they move unchanged.navEntriesShownToMemberapplieswithoutUnreadableObjectEntries, then the layout's ownhasVisibleNavigationItemsper node: the finding(layout): the mobile tab bar (bottom_nav) draws entries the sidebar hides —visible: false, deniedrequiredPermissions— and anactionentry as a dead link #11362 shape, with no second guard.Clause-②: noholds (measured:src/index.tsandlayout/index.tsare untouched).AppRootLanding, because thevisibleevaluator is provided insideAppContent.findFirstRouteon the gated tree.AppNoAccessEmptyState, the loud absence, with no new string.MePermissionsProviderrenders its fallback until it has an answer, so the landing reads the same readiness as the sidebar. This is pinned with a held/me/permissionsanswer.visible: falseentry, avisibleexpression, an unreadable object, a group whose children are all hidden, no shown entry, an admin control, readiness, and a source pin showing both call sites use the hook. Replacing the landing's gated tree with the served tree fails 6 of them; the admin control and the source pin stay green, as expected. Restore was proven by blob hash.@object-ui/app-shell: patch.src/layout/,src/console/and the two related pins give 1451/1451 under the lock; the battery exits 0. The full app-shell project is declared to CI; its shards are read below.findFirstLandingmirrorsfindFirstRoutewithout member gates, because the preview has no member.filter=latest), all 12 test shards included.Next:
pr_ready+automerge_enable.Generated by Claude Code
objectstack-fleet commentedon Oct 15, 2026
Landed ·
domain:uiseat 2 ·session_01XVp19E1MYUTFM3dnkwz8gB· 2026-10-15T06:34Zmain6fcb2ce. The card closedcompletedby the PR'sFixes #12426, which is the only closing keyword. No other card closed with it.origin/main, all 5 files the PR touches are blob-identical to the accepted headc22c5d7. They areAppContent.tsx,hooks/useNavMemberGates.ts,UnifiedSidebar.tsx, the console: an app opens on its first SERVED navigation entry even when the sidebar hides that entry from the member (visiblefalse, or an object the member cannot read) #12426 pins and.changeset/12426-landing-gates.md.pm:dispatchedis removed in the same stroke as this comment.Generated by Claude Code