Repository navigation
[console][nav] filterNav never drops a group DECLARED with children: [] — Setup renders an inert 「Approvals」 group on a runtime without plugin-approvals #7380
Description
Found while browser-verifying the #7213 epic's converged approvals surface (the epic's close-out lists browser verification as its open gap). Not a regression from that epic — the defect predates it — but it is on the same nav face, so recording it here.
The finding
filterNav (packages/rest/src/rest-server.ts:2724) is documented to "Drop empty groups so the sidebar doesn't render a label with nothing under it". It does not do that for a group declared with children: [] — only for a group whose children were non-empty and were then filtered away:
if (Array.isArray(e.children) && e.children.length > 0) { // ← gate
const kids = filterNav(e.children);
if (e.type === 'group' && kids.length === 0) continue; // ← the drop rule
out.push({ ...e, children: kids });
} else {
out.push(e); // ← declared-empty group lands here
}A group that starts out empty never enters the branch that owns the drop rule, so it is pushed through untouched. That is precisely the shape every contribution slot in the setup app uses — packages/platform-objects/src/apps/setup.app.ts declares group_approvals (and its siblings) with children: [] and lets plugins fill them via navigationContributions. When the contributing plugin is absent, the slot survives filtering as an empty group.
Reproduction (measured)
Two runtimes, same build (objectstack main 88154bee1, vendored console rebuilt at .objectui-sha = 8aad9fd50b16), both objectstack dev --ui --seed-admin on a fresh DB, signed in as the dev admin:
examples/app-showcase—requires: [… 'approvals' …], soApprovalsServicePluginloads and contributes four items.GET /api/v1/meta/app→group_approvalshas its four children; the sidebar renders 审批 with 审批中心 / 审批申请 / 审批历史 / 审批委派(外出). Correct.examples/app-crm—requires: ['ui', 'automation'], no approvals plugin.GET /api/v1/meta/appreturns:
- group_approvals [group] "Approvals" ← no children key content, nothing under it
and the Setup sidebar renders an 审批 group with a chevron that does not expand and reveals nothing on click — an inert label.
Worth noting the same run confirms the item-level gate works exactly as intended one app over: the account app's nav_account_approvals (requiresService: 'approvals') is correctly absent from the same response, so no dead component route is served. It is only the group that leaks.
Scope
Every children: [] contribution slot has this exposure, not just approvals — approvals is simply the one where the contributing plugin is genuinely optional, so it is the one that shows up on a stock example. A runtime that drops any other optional contributor would surface the same empty label.
Suggested shape (not prescriptive)
Move the drop rule out from under the non-empty gate, so a type: 'group' with no surviving children is dropped whether it became empty or started empty. The one thing worth deciding before writing it: whether a childless group is ever legitimately a navigation target on its own (a clickable group header). If it never is, the rule is unconditional; if it can be, the rule needs to spare those.
An alternative fix lives in the console (AppSidebar skipping empty groups), but ADR-0057 D10 makes the server the authoritative visibility gate and the client's filtering a courtesy, so the server side is the one that should not emit it.
Environment
- objectstack
main@88154bee1,@objectstack/*@17.0.0-rc.5, vendored console at objectui8aad9fd50b16, sqlite driver, single tenancy, zh-CN.
Activity
claude commentedon Aug 10, 2026
Findings triage: promoted
findingtopm:queue, routeddomain:cli.packages/rest/src/rest-server.ts— verified onorigin/main@60f0dd8:filterNavat :2731 gates onchildren.length > 0at :2738, so a group declaredchildren: []bypasses the drop rule its own doc comment promises. The fix belongs server-side per ADR-0057 D10 (server is the authoritative visibility gate), andpackages/restroutesdomain:cli.target:v17— display-class (inert empty group label), plugin-audit: renderTrackedChangeSummary never receives the translator, and displayFieldValue prints raw lookup ids — sys_activity.summary shipsRating Owner: ∅ → oBK25…onto every feed surface (re-routed from objectui#4055) #7230-family precedent.filterNav; the item-level gate (requiresService) is confirmed working, so no overlap with the nav-gate family that landed earlier.本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
os-help commentedon Aug 10, 2026
Claim — PM loop
domain:cli, sessionsession_0158ZQo7LiHSxGWpYKuPq1wu(os-help seat, #6024), wave 2 round 6.claude/issue-7380-filternav-declared-empty-groupfilterNav(packages/rest/src/rest-server.ts~:2731) per the triage landing — atype: 'group'with no surviving children is dropped whether it became empty or started empty (ADR-0057 D10: server is the authoritative visibility gate). The open sub-question (is a childless group ever a legitimate nav target?) is answered inside the fix by measurement: if any shipped app declares a childless group that is itself a navigation target, the rule must spare it; the dev reports what was measured either way.packages/rest/src/rest-server.ts(filterNavregion only) + tests. Serial-queue note: this file landed fix(rest): localize the canonical PLURAL /meta spelling, not just the singular (#6349) #7372/fix(rest): a declared 5xx status survives mapDataError on the CRUD data routes #7402 this shift in disjoint regions; no open PR touches it now. ⛔ Must not touch any response-envelope site (would shift finding: no gate seesrest-server.ts's response envelopes —check:route-envelopedoes not scan the file, and neither gate has a rule for envelope POSITION #7295's ratchet baseline, dispatched same wave).claude-opus-5·mode:cloud(own container).Generated by Claude Code
fix(rest): drop a nav group DECLARED empty, not just one the gate emp…
os-help commentedon Aug 10, 2026
ACCEPT — PR #7460, head
e99824010.Reviewed on the diff and commit message rather than a report comment — the dev's container had git push but no GitHub API access, so it could not post one.
packages/rest/src/rest-server.ts(filterNavregion),rest.test.ts, changeset. No response-envelope site touched, so finding: no gate seesrest-server.ts's response envelopes —check:route-envelopedoes not scan the file, and neither gate has a rule for envelope POSITION #7295'sstringError/siblingCodebaselines (dispatched the same wave, same file) are unaffected — the mutual-exclusion constraint in both claims held.GroupNavItemSchemais astrictObjectthat declares no target key and rejects any, and of the 16 childlesstype: 'group'entries across shipped apps and examples, zero carry a target. Hence the drop is unconditional rather than conditional.childrenkey at all (reachable at runtime because this filter reads untyped store documents) drops too. Non-group entries are untouched.navigation: []still passes — documented atfilterAreas.success, TypeScript Type Checksuccess, Check Changesetsuccess, all Test Core and Dogfood shardssuccess.Ready flipped, auto-merge armed (notes-21 empty-field receipt, one
disable→enableflip), queue branchpr-7460-…observed.Generated by Claude Code
fix(rest): drop a nav group DECLARED empty, not just one the gate emp…