The Menu Contribution Boundary
Menus as Data makes a menu entry's presentation editable, and
UI Extensibility makes an entry itself contributable as a
UiContribution node. This page draws the line between them and the compiled providers: what may
be data, what must be code, and why the node menu's own defaults stay where they are.
The rule
π¨ Gates SUBTRACT. They never SELECT. A contribution declares a fixed entry β one label, one icon, one area, one destination. The closed gate vocabulary decides only whether it appears. An entry whose label, icon, area, href or action is a function of state β the node's or the viewer's β stays compiled.
Everything below follows from that sentence. It is the same rule that already keeps behaviour out of the lane ("New thread" is compiled because its destination is resolved from the circuit's viewer at click time), extended to the other half: state-dependent presentation is not a gate, and a vocabulary that could express it would stop being closed.
Concretely, a conditional in the data would have to pick a branch when the evidence is missing. Gates fail closed β an unresolved node means "do not show", which is always safe. A selector has no safe default: offering Resume synchronization on a node that is in fact synced is a misleading action, which is worse than a missing entry, and no amount of care in the data prevents it.
The four node-menu defaults that are inexpressible
Measured against the vocabulary as it stands (UiContributionGates: NodeTypes,
ExcludePartitionRoot, AdminOnly, SyncedOnly, ExcludeViewerHome, plus a single
RequiredPermission):
| Entry | Why it cannot be a contribution |
|---|---|
Presentation (HideInPresentation / ShowInPresentation) |
Label, icon, area and tooltip all flip on the viewer's own profile (User.HiddenPaths). Viewer profile state is not a gate input at all β the vocabulary evaluates node shape, effective permission, admin, and the viewer's partition key. Worse, the compiled provider deliberately Seeded()s that stream so a viewer with no User node cannot stall the menu; folding it into the shared gate evaluation would put that stall risk behind every contribution. |
| StopSync | Two independent walls. The label and icon flip on SyncBehavior β SyncedOnly covers the Stop half's shape only, and the inverse is deliberately absent. And its permission gate is Update OR Sync; RequiredPermission is one [Flags] value checked with HasFlag, so an OR is inexpressible whatever happens to the label. |
| Recycle | Carries Action = MenuActions.Recycle β a command id a renderer runs in place. See the next section: this one is a security wall, not an expressiveness wall. |
| Create (Mesh menu) | Its Href carries a ?type= query string only when the anchoring node is a NodeType. The {node} token substitutes a path; it cannot make a segment conditionally present. |
Note that this is four, not the two named when the question was raised β and two of them fail for reasons that have nothing to do with labels. The count matters: "two special cases" reads like an exception to sweep up later, and "four, for three unrelated reasons" reads like a boundary.
π¨ Action must never become a contribution field
NodeMenuItemDefinition.Action is a command id, and its own contract states that applicability
stays with the provider that emitted the entry β nothing downstream re-checks
RequiredPermission. So a contribution able to declare action: "recycle" beside
requiredPermission: Read would hand every reader of a node a button that tears its hub down.
That is a widening, and the one thing the closed vocabulary exists to make impossible. Adding
Action would not be a vocabulary extension; it would be a hole. Two ratchets in
UiContributionProjectionTest pin it: a projected entry always carries a null Action, and
UiContribution declares no property that could name a command.
Why the other ten stay compiled too
Design #1645 planned to retire the node/mesh defaults into a pre-installed pack, leaving a minimal compiled fallback β Data + Versions + Delete, enough to operate a zero-plugin mesh. Ten of the fourteen entries are expressible in the vocabulary, so this was mechanically possible. It is not being done, for a reason the design could not see until the lane existed:
For a core node-menu default, the contribution lane buys nothing an admin does not already have, and costs availability.
- Buys nothing new. The editability half of "menus as data" is already delivered for these
entries by the
MenuPresentationcatalog: re-word, re-icon, re-order, re-group and hide, live, no build. That covers what an operator actually wants to do to a built-in entry. - Costs availability.
UiContributionCatalogis fail-soft to empty by design β a faulted query degrades to the last known set, and a hub without a mesh data source gets an empty catalog on purpose. That is exactly right for a package's front door, and exactly wrong for the menu that is how you edit and delete anything. Making Edit / Move / Copy conditional on a query succeeding trades a guarantee for a convenience nobody asked for. - Does not shrink the surface. Four entries stay compiled regardless, so the provider, its permission composition and its node-stream plumbing all survive the migration. The decongestion would be ten table rows, not a component.
The lane's value is additive: it lets a package ship an entry that could not be compiled into core at all, and it decongested ~20 settings-tab registrations that genuinely came from many modules. Core's own node operations are not a package's front door.
What this does not say. The contribution lane is not in question β it ships the platform settings tabs, the AI menu's catalog entries, and every package front door. Only the retirement of the node/mesh menu defaults into it is closed.
One more thing the retirement would have got wrong
The retirement was framed as a cross-repo change, moving the entries into the PlatformUI pack in
MeshWeaver.Plugins. That is not how the shipped slices work: platform entries are seeded from the
assembly that owns the area β PlatformSettingsTabAreas in core, AiMenuContributions in the AI
module β while the pack carries only the Apps menu declaration. Splitting an entry from its area
across a repo boundary would create a new silent-break class: a core area rename would leave the
pack's entry pointing nowhere, and neither repo's static check can see both halves. Keep an entry
and its area in one assembly, where UiContributionSeedValidation's registeredAreas argument can
check them together.
Separators are derived, never declared
A contributed separator is not part of the vocabulary, and it should not be: a declared divider renders next to whatever survives, including nothing.
Dividers are computed last, by NodeMenuItemsExtensions.WithSectionDividers, from the finished
list β after every provider is merged, after the MenuPresentation overlay, after normalization.
Two adjacent entries in different sections get one divider between them; a divider can therefore
never lead, trail, or double, and an empty middle section produces one rule rather than two.
The Node menu declares its section boundaries once (Order 20 and 40, matching the documented bands);
every other context is flat and derives nothing, keeping only the dividers its providers declare
minus the dangling ones. In a banded context an incoming _separator is dropped and re-derived,
so an in-mesh provider this build cannot recompile cannot reintroduce the defect.
That defect was live in both directions, which is why the derivation is not merely tidier:
- an admin hiding the last entry of a section through
Admin/Menu/Nodeleft the divider behind it β and hiding entries is what the catalog is for; - on a viewer's own home every compiled section-1 entry is suppressed, so no divider was emitted at 20 β and a contributed or DI-provided entry in that band ran straight into Files with no rule between them.
Neither was expressible from inside a single provider, because a provider sees only its own slice
and runs before the overlay. MenuSectionDividerTest pins both.
Deciding where a new entry belongs
| Your entry⦠| Lane |
|---|---|
| is a fixed link a package ships with its own area | UiContribution β UI Extensibility |
| is a settings tab from a module | UiContribution, Settings or NodeSettings context |
| needs different wording for the same operator action | Compiled provider |
| runs a command instead of navigating | Compiled provider β Action is never data |
| needs a permission the vocabulary cannot spell (an OR, a role) | Compiled provider |
| just needs re-wording, re-ordering, re-grouping or hiding | Neither β edit the MenuPresentation catalog (Menus as Data) |
Related pages: UI Extensibility Β· Menus as Data Β· Node Menu Β· Access Control