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' …], so ApprovalsServicePlugin loads and contributes four items. GET /api/v1/meta/app → group_approvals has its four children; the sidebar renders 审批 with 审批中心 / 审批申请 / 审批历史 / 审批委派(外出). Correct.
examples/app-crm — requires: ['ui', 'automation'], no approvals plugin. GET /api/v1/meta/app returns:
- 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 objectui 8aad9fd50b16, sqlite driver, single tenancy, zh-CN.
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 withchildren: []— only for a group whose children were non-empty and were then filtered away: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.tsdeclaresgroup_approvals(and its siblings) withchildren: []and lets plugins fill them vianavigationContributions. When the contributing plugin is absent, the slot survives filtering as an empty group.Reproduction (measured)
Two runtimes, same build (objectstack
main88154bee1, vendored console rebuilt at.objectui-sha=8aad9fd50b16), bothobjectstack dev --ui --seed-adminon 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: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 (
AppSidebarskipping 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
main@88154bee1,@objectstack/*@17.0.0-rc.5, vendored console at objectui8aad9fd50b16, sqlite driver, single tenancy, zh-CN.