ADR-0008: Navigation scope lives in the URL, not in application state
1. Context
The admin-portal’s navigation was specified as a four-tier cascade — Tenant → Workspace → Mode nav → Entity — but given only three UI surfaces: a tenant chip, a workspace chip, and one nav list. Tiers 3 and 4 therefore shared the nav list, which had to change shape depending on where the user was, and anything tenant-wide but not instance-specific had no surface at all.
The consequences were not theoretical. Ten defects were verified in the shipped sidebar, of which three show the shape clearly:
-
Entering an event appended event rows to the same list and highlighted nothing, because the URL matcher returned a key (
'eventChild') that no nav row carried. There was also no way back to the event’s own overview from the sidebar. -
The event-scoped Orders row linked to
/orders?eventId=N. Scope lived in a query parameter, and the highlight matcher deliberately stripped?before matching — so one click silently dropped the user out of event scope and deleted the navigation they were using. -
Tenant-scope rows were hidden entirely inside an event (
admin && !inEventScope), so a tenant admin had to leave an event to reach inventory.
Two conflations underlie all of it. Four tiers on three surfaces forces one surface to be overloaded. And domain conflated with scope — "workspace" listed Events / Memberships / Affiliates (what kind of thing) alongside Tenant admin / Super admin (how wide) — which is why platform scope was entered by selecting a pseudo-tenant from the tenant list, after which every other control silently changed meaning.
The forces pulling against a fix: a fourth surface costs screen space; per-event navigation is genuinely large (~17 screens); and some screens are legitimately wanted at more than one scope — all orders for the organisation, and this event’s orders, are the same screen at different widths.
The spec had already recorded the problem twice without resolving it. C01 rev 4 admitted inventory received an ad-hoc third sidebar "scope" only "because the workspace-switcher dropdown is already deferred", and E05 carried a standing request for "a follow-up design pass to capture the two sidebar modes and the transition between them."
2. Decision
Scope is a property of the URL. Each scope gets one fixed surface, and no surface changes shape as a side effect of another.
-
Three scopes, three surfaces:
-
platform — a collapsible group at the foot of the sidebar, rendered only for super admins.
-
tenant — the sidebar. Always this scope, always the same shape, organised into named groups.
-
instance — an entity header and grouped tab strip in the content area, never the sidebar.
-
-
The organisation is a path segment:
/o/:orgId[-:slug]/…. The numeric primary key is authoritative; any trailing slug is decoration ignored on read. -
Dual-use screens are declared once and anchored many times. A
ScopedScreenrecord carries the levels it may hang from; routes are generated from it (/o/:orgId/orders,/o/:orgId/events/:eventId/orders,/o/:orgId/memberships/:typeId/periods/:periodId/orders), all loading the same component. -
Scope is resolved from
ActivatedRoute, never by pattern-matchingrouter.url. Query-parameter forms are accepted as deep-link input and canonicalised into path form, so there is exactly one URL per view. -
Nothing is navigable that is not implemented. Placeholder rows are not shipped; a group renders only when at least one child row is both permitted and module-enabled.
3. Consequences
3.1. Positive
-
The class of bug that motivated this cannot recur. Scope is in the path, and the path is what the highlight derives from — a link cannot silently change scope without changing the URL.
-
Navigation state is shareable, bookmarkable, and survives a reload. Two browser tabs can hold two organisations.
-
Active-highlight logic collapses to
routerLinkActiveon static links. The hand-rolled ~90-lineactiveKey()/routeFor()matcher is deleted rather than extended. -
A tenant-level row and its instance-level alias cannot drift, because they are the same declaration rendered at two anchors.
-
Tenant-wide operational functions (master data, financial recon, cross-entity people and orders) gain a home, which removes the pressure that produced ad-hoc scopes.
-
The rule answers "where does this new screen go?" without a design conversation each time.
3.2. Negative
-
Every authenticated route gains a prefix, and the existing route table must be rewritten. This is a breaking change to every internal link and bookmark.
-
Instance navigation must be grouped to stay usable — ~17 event screens fit only because they collapse into 7 tabs. That grouping is an ongoing design obligation; a screen that fits no group is a signal to revisit the groups, not to widen the strip.
-
Two levels of navigation chrome (tabs plus an in-tab leaf row) is more structure than a flat list, and is harder to scan for a user who knows exactly where they are going.
-
Generated routes are less greppable than literal route entries. Finding "what serves `/events/:id/orders`" requires understanding the registry.
3.3. Neutral
-
The workspace switcher is removed.
Organisation.enabledModulestakes over its only load-bearing job — deciding whether a domain’s navigation appears at all. -
Per-domain accent colours survive, now applied to sidebar groups and notification rows instead of workspaces.
-
Platform scope is reached from a sidebar group rather than the organisation switcher; the switcher becomes a pure organisation control.
4. Alternatives Considered
4.1. Alternative A: Keep the four-tier cascade and build the workspace switcher
The specified design, finished as written. Rejected because it does not address the root cause: the mode nav would still carry both workspace-tier and entity-tier rows, so it would still shape-shift, and cross-domain tenant functions would still be homeless — a modal switcher forces "all orders across events and memberships" to belong to one workspace or neither. It would become the right choice if the domains were genuinely exclusive, i.e. if no user ever needed a view spanning events and memberships.
4.2. Alternative B: Instance navigation as a second, nested sidebar column
Jira, Linear and the Azure resource blade all do this, and it absorbs 20+ items natively with grouping. Rejected on space: roughly 430 px of persistent horizontal chrome on screens that are overwhelmingly wide data grids, which are the majority of this portal. It becomes the right choice if instance navigation grows past what grouped tabs can hold, or if the portal’s centre of gravity shifts away from dense tables.
4.3. Alternative C: Tabs with a More ▾ overflow menu
The conventional answer to too many tabs. Rejected because anything in the overflow is effectively undiscoverable — users do not open a More menu to find out what a product can do, so the overflow becomes where features go to be forgotten. Grouping keeps every screen reachable in two visible clicks.
4.4. Alternative D: Scope as a query parameter throughout (/orders?eventId=N)
The shipped approach, generalised rather than replaced. It is a real pattern — Jira and Linear both scope views by query state. Rejected here because the highlight, breadcrumb and entity header all need to know the scope, so every one of them would have to parse query parameters, and the failure mode is silent: a link that omits the parameter looks identical and lands the user somewhere else. Path-nesting makes the same information structural. Query forms are retained as deep-link input, but canonicalised.
4.5. Alternative E: Organisation held in session state only, no URL segment
Simpler: no prefix, no resolver, no rewrite of the route table. Rejected because the current organisation then belongs to the session rather than the page, so two tabs cannot hold two organisations — switching in one silently changes what the other returns on its next request. URLs also become non-portable between users. It remains the right choice for a strictly single-organisation product, which this is not.
5. References
-
Design journal:
design-journal/2026-08/admin-portal-navigation-architecture.adoc(sessions 1–3, 2026-08-12) -
Use case: C01 rev 6 — § Navigation Model, § Dual-use screens, § Organisation in the URL
-
Use case: E05 — the follow-up this ADR closes
-
Related: ADR-0009 (who decides which organisation a request acts on)
-
Code (pre-decision state):
admin-portal/src/main/webapp/app/shell/sidebar.component.ts,app/app.routes.ts -
ADO: Epic #533; US #704 (nav sweep that introduced the query-param Orders mirror), US #750 (nav-consistency pass)