Menu reads are not raw table queries. They pass through visibility resolution, ancestor closure, projection, and caching. Each stage has distinct security and data-integrity semantics.
1. Visibility decision
admin is a case-insensitive role-name shortcut. It is neither a permission code nor a check of Authorization module assignments.
2. Role names to module codes
A non-admin passes current role names to IMenuStore.GetVisibleModuleCodesAsync; the store calls IAuthorizationAssignmentReader.GetGrantedModuleCodesAsync. Authorization owns the relation rows, and Menu never reads their persistence types directly.
// ① Roles come from authenticated ICurrentUser, never arbitrary query input.var roles = currentUser.User.Roles;if (roles.Contains("admin", StringComparer.OrdinalIgnoreCase)) return null; // null means “all,” not a failure.
// ② No roles explicitly means no visible modules.if (roles.Count == 0) return Array.Empty<string>();
// ③ Every other path uses Authorization's narrow reader.return await menuStore.GetVisibleModuleCodesAsync(roles, cancellationToken);3. Ancestor closure
If only orders.detail is granted, its parents must still render a path. The builder finds each visible Code and walks ParentId upward, adding every ancestor Id.
The upward walk stops when resultIds.Add sees an existing Id, so that phase has some repetition protection. The later recursive tree build has no visited set.
4. Code lookup boundary
The builder calls ToDictionary for non-null Codes. The unique index normally prevents duplicates, but historic bad data, database collation, or adapter differences can still surface an exception.
A granted Code missing from the catalog is silently ignored. That tolerates retired modules but can hide relation drift without operational diagnostics.
5. Three projections
| Method | Output | Filtering |
|---|---|---|
| GetFlatAsync | MenuListItem[] | Enabled + visible/ancestors; keeps non-menu rows |
| GetTreeAsync | MenuNode[] | same, recursively nested |
| GetNavigationAsync | NavigationItem[] | same, then IsMenu=true only |
Flat omits Description, legacy MVC fields, IsMenu, and Scope. Tree includes Code, IsMenu, and Enabled. Navigation omits Code, OrderSort, Scope, and Enabled.
6. Tree construction
Root keys normalize null and 0: lookup uses ParentId ?? "0" and the entry call is BuildChildren(null). Children order by OrderSort ascending.
// ① The service has already filtered visibility and included ancestors.var tree = await menuTreeBuilder.GetTreeAsync(currentUser, cancellationToken);
// ② Never infer permission from Name; Code is the stable module key.foreach (var node in tree) RenderNode(node.Id, node.Name, node.LinkUrl, node.Children);
// ③ The target endpoint authorizes again when the link is opened.// A menu response is not a capability token.7. Non-menu parent trap
Navigation removes IsMenu=false rows before building its lookup. A menu child under a non-menu parent keeps the missing ParentId and becomes unreachable from the root.
Tree and Flat can still contain it. That creates intentional or accidental disagreement among the three APIs, so a management validator must reject or explicitly support this shape.
8. Disabled parent behavior
The store loads only Enabled rows. Disabling a parent while leaving a child enabled makes the child unreachable. Toggle changes only the selected row and does not cascade state.
Re-enabling the parent makes still-enabled descendants appear again. This is projection hiding, not a persisted cascade.
9. Cycles and orphans
BuildChildren has neither visited tracking nor a maximum depth. A root-reachable cycle can recurse indefinitely and cause StackOverflow; a disconnected cycle simply disappears. Current writes allow both shapes.
GA needs transactional write checks, defensive read guards, a startup integrity scan, repair tooling, and metrics for rejected graphs.
10. Cache keys
Areas are menu-tree, menu-flat, and menu-nav, with User scope. The segment is:
- fixed
adminfor administrators; visxxxxxxxx, from sorted visible Codes throughSystem.HashCode, otherwise;CachePolicy.Medium(), currently 15 minutes;- tagged with the
menusarea tag.
A changed grant set creates a new key, and write broadcasts clear older entries. System.HashCode is not a durable cross-process hash, but these keys serve only local cache lifetimes today.
11. Authentication and resource authorization
Navigation is authenticated because generated endpoints authorize by default. It omits IAuthorizedRequest, so it needs no menu-admin view permission. Tree, Flat, and Detail require both authentication and view permission.
12. Required boundary tests
Fix case-insensitive admin, empty roles, leaf ancestor closure, unknown Code, non-menu parents, disabled parents, orphans, self-parent, two-node cycles, equal order, cache grant changes, and independent denial by a target endpoint.
Current focused application tests cover only Detail success and not-found. The tree policy has no dedicated unit suite.
13. Inspection commands
# Read the complete visibility and tree algorithms.sed -n '1,280p' src/Platform/Menu/*Application/MenuTreeBuilder.cssed -n '1,180p' src/Platform/Menu/*Application/MenuVisibilityResolver.cs
# Verify that generated navigation still requires authentication.rg -n "RequireAuthorization.*true|Append\(\"\.RequireAuthorization" src/Framework/BitzOrcas.Endpoint* -g '*.cs'