[C01] Application Structure: Shell, Scope Model, Navigation, Notifications

Summary

The application shell — the durable chrome around every screen in admin-portal: the organisation switcher, the tenant-scope sidebar, the instance-scope entity header, the topbar with breadcrumb / Jump-to / notifications, and sidebar collapse. C01 owns the structure and the rule for where a new screen goes; it does not own the data shown inside any landing.

Rev 6 (2026-08-12) replaced the navigation model. The four-tier cascade (Tenant → Workspace → Mode nav → Entity) had four tiers on three UI surfaces, so the mode nav had to shape-shift and tenant-wide-but-not-instance-specific functions had no home. The workspace switcher is removed. See Navigation Model — three surfaces, three scopes. Rationale and the ten defects that motivated it are in the design journal named in the header.

The canvas matches this spec as of rev 6c (2026-08-13). C01-structure/Structure.html was rebuilt as a live prototype — five interactive frames covering organisation switching, entering and leaving an event, tab changes, scope-chip dismissal, the secondary-entity swap, and platform scope. The pre-rev-6 canvas is retained read-only under C01-structure/superseded/.

Scope discipline: C01 is structural only.

  • Events landing content (Resume / Active / Recent / Glance / Attention) → E05 Events Control Centre

  • Memberships landing → owned by an M0x use case when designed (mirror the E05 pattern until then)

  • Tenant-admin screens → T01

  • Platform (super-tenant) screens → S01

  • Single-event drill-down → E01 Event Overview

  • Pre-auth public landing → C03 Public Landing

Actor & Context

Actors:

  • Staff user — has access to one or more organisations. Sees the sidebar groups their capabilities and the organisation’s enabledModules permit.

  • Tenant admin — staff user holding tenant-admin capabilities at one or more organisations (sourced from OrgPermission.role, see Feature #534). Additionally sees the Tenant admin group, which covers tenant-wide operational records as well as configuration (see Sidebar groups (tenant scope)).

  • Super admin — staff user with the global isSuperAdmin JWT claim (US #536). Additionally sees the collapsible Platform group.

Frequency: every login. The shell is the constant frame around all other portal work.

Vocabulary — "tenant" in this portal

The word tenant is used throughout this spec and in the UI. It needs one definition, because the same word means something different one system over.

Term Meaning in admin-portal

Tenant

The user-facing label for the organisation currently in context — the one the sidebar chip names and whose data every organisation-scope screen shows. It is a label, not a record: there is no tenant table, no tenant id, and nothing in this portal’s model called a tenant. Where a value is passed, stored or validated, it is an Organisation and it is called organisationId.

Organisation

The actual entity (Organisation, OrgPermission, linkedOrgIds). Every id, API field, URL segment and DTO uses this name.

Linked organisations

The set a user has been granted (linkedOrgIds). A user may hold one or many. Switching the chip changes which of them is the tenant for the session.

This is not the registration-portal Tenant entity. That one is a real record used for hostname resolution — register.tourdeworcester.co.za → tenant 1508 → registration system 1505 → organisation 10. Its ids live in a different number space, and a tenant id there is not an organisation id here. The two concepts share only a word.

Practical consequences:

  • Say "tenant" in UI copy and in prose where you mean "the organisation you are working in".

  • Never put a tenant id in an admin-portal URL, API field, or DTO — those carry organisationId. This is why the URL segment is /o/ and not /t/ (Organisation in the URL).

  • When reading registration-portal code or the 2026-07-29 incident notes, tenant means the other thing. Check which system you are in before assuming an id is comparable.

Precondition: user has authenticated via OIDC (OIDC ⇄ admin-service Token Exchange); admin-portal session holds a valid backend JWT (Session-Held JWT & JSESSIONID).

Organisation claims and the opaque-token boundary

The multi-organisation substrate exists today; the portal simply does not consume it. NimbusTokenProvider mints two organisation claims:

Claim Meaning

orgId

The active organisation for this token.

linkedOrgIds

The full set the principal may act on — a list of LinkedOrgClaim{orgId, accessLevel}. Membership is explicit: an entry exists only because it was granted. See Organisation hierarchy does not cascade access.

ITenantService.getOrganisation(Long requestedOrganisation) already enforces the selector contract: parameter not in the principal’s set → 403; absent parameter → the validated X-Organisation-Id header, else the sole permitted organisation, else AmbiquousOrganisationProblem. PersonResource, ImportResource, EventParticipantResourceEx, ResultSetResourceEx and MembershipResourceEx already accept and validate organisationId through it.

An organisation outside the principal’s permitted set is rejected with 403, not 404. Earlier revisions of this page specified 404 on the usual anti-enumeration grounds — don’t confirm that a tenant exists. That reasoning does not apply here, because TenantService never branches on existence: it tests membership of the permitted set and throws the same 403 either way, so a fabricated organisation id and a real-but-forbidden one are indistinguishable. There is no oracle for 404 to close.

403 is also the only honest answer for the header. X-Organisation-Id: 12 on GET /api/orders does not address organisation 12 as a resource; it selects the scope of a request whose target plainly exists, and answering 404 would assert the endpoint is missing.

404 remains correct where the organisation is the addressed resource, such as GET /api/organisations/{id}. The property to preserve is that the 403 stays uniform — a future change that adds an existence lookup before the permission check would reintroduce exactly the enumeration oracle 404 was meant to prevent.

The JWT is opaque to the admin-portal gateway. Only admin-service decodes it. The gateway holds the token in the server-side session (Session-Held JWT & JSESSIONID) and attaches it to outbound calls, but never parses its claims — not for orgId, not for linkedOrgIds, not for isSuperAdmin.

Consequences, which bind the rest of this spec:

  • Everything the shell needs about organisations and privileges arrives through admin-service endpoints, not through token introspection. GET /api/session/current on the gateway is a composite that proxies GET /api/org-permissions/current-user (US #538) plus the super-admin flag; it does not read claims locally.

  • isSuperAdmin must be exposed on an endpoint as well as minted as a claim (US #536), otherwise the gateway cannot gate the PLATFORM group. This is an addition to US #536’s scope.

  • The organisation list in the switcher therefore comes from GET /api/org-permissions/current-user, which is authoritative for the UI. The JWT’s linkedOrgIds stays authoritative for enforcement. The two must agree because both derive from OrgPermission; if they ever diverge the backend wins and the UI surfaces the 403 on the offending organisation.

  • Nothing in the SPA or gateway may shortcut this by decoding the token, even though it is technically a readable JWS. Treat it as a bearer credential with no readable interior.

organisationId is a selector within an authenticated principal’s organisation set, never a source of organisation identity. It must fail closed when the value is absent from linkedOrgIds. This is the distinction that separates the legitimate admin-portal use from the 2026-07-29 registration-portal incident (design-journal/2026-07/tenant-derived-org-context.adoc), where a client-supplied orgId was honoured on an API-key-only call carrying no user JWT. Removing ?orgId= there and re-introducing organisationId here are consistent, because only one of them is validated against a principal.

Entry point: authenticated landing — direct URL https://admin.event.idealogic.co.za/, or post-OIDC-callback redirect from the gateway. Pre-auth predecessor is the public landing (C03).

Main Flow

  1. Bootstrap. SPA APP_INITIALIZER calls GET /api/session/current on the gateway, which composes the response from admin-service rather than from the session-held token (Organisation claims and the opaque-token boundary): user, currentOrgId, linkedOrgs[] (id, name, role, enabledModules), isSuperAdmin, last-context.

  2. Resolve organisation from the URL. The organisation is the first path segment group (/o/:orgId…, see Organisation in the URL). On a bare URL, redirect to the last-used organisation, or the sole entry in linkedOrgs when there is only one.

  3. Derive capabilities. Translate the organisation’s backend role into the capability set the nav gates on (Capability gating).

  4. Render shell. Sidebar: organisation chip → grouped tenant nav → user/collapse footer. Topbar: breadcrumb, Jump-to (⌘K), notifications bell.

  5. Render the route. Tenant-scope routes render straight into the content area. Instance-scope routes (/events/:id…, /memberships/:typeId/periods/:periodId…) additionally render the entity header + tab strip above the screen (Instance navigation — entity header + grouped tabs).

  6. The user proceeds. Switching organisation, opening notifications, collapsing the sidebar, or invoking ⌘K all stay within shell concerns.

Three scope tiers, each with its own UI surface. No surface changes shape as a consequence of another. This is the rule that answers "where does this new screen go?".

Scope Surface Contains

platform

Collapsible Platform group at the foot of the sidebar. Rendered only when isSuperAdmin.

Cross-tenant configuration — tenants, global users, role templates, modules, regions, global master data, system settings, audit log. S01.

tenant

The sidebar. Always this scope, always the same shape.

Everything that spans the organisation: the events and memberships portfolios, cross-cutting operational records, inventory, insights, tenant administration.

instance

Entity header + tab strip in the content area. Never the sidebar.

One event, or one membership type + period.

The sidebar never shape-shifts. Entering an event does not remove tenant navigation, and leaving one does not remove event navigation — because event navigation was never in the sidebar to begin with.

WP Cycling                      ▾     ← organisation chip
──────────────────────────────────
  Home

EVENTS
  Events              ← list; entry to one event
  People
  Orders
  Results

MEMBERSHIPS
  Membership types    ← list; entry to one type + period
  Members
  Renewals

OPERATIONS
  Number & tag stock
  Returns
  Stock activity
  Onboard
  Manufacturer export

INSIGHTS
  Reports
  Financial recon

TENANT ADMIN                (capability-gated)
  Users & roles
  Master data
  Modules · Branding · Billing
  Audit log

PLATFORM  [ALL TENANTS]     (super admins only, collapsed)
  …
──────────────────────────────────
  Chris Thoni · Tenant admin    ⌄

A group renders when at least one of its rows is both permitted (capability, Capability gating) and enabled (Organisation.enabledModules). A tenant with only the membership module never sees the EVENTS group — enabledModules becomes the render lever that the removed workspace switcher used to be.

The OPERATIONS group keeps the order fixed by US #750: Stock → Return → Stock activity → Onboard → Manufacturer export. Bulk Flag-unfit / Dispose (T04) has no row — it is a cross-cutting dialog (BulkActionDialogComponent) launched in-context from T02 and the T03 Done stage.

Why OPERATIONS is a tenant group, not an event one. Number/tag inventory crosses every event an organisation runs (numbers are tagged once and used across many events) and stretches into pre-event onboarding. It is tenant-scope data and now sits in a tenant-scope surface. Rev 4 parked it in an ad-hoc third sidebar "scope" only because the workspace switcher was deferred; that workaround is retired.

Why the workspace switcher is removed. Events and Memberships are not mutually exclusive modes. A modal switcher makes cross-domain tenant functions (People, Orders, financial recon, reports) homeless, costs a click plus a remembered mode on every cross-domain task, and hides half the application behind a dropdown. Group headings give the same segregation without the hiding. It was never built, so nothing is lost.

Instance navigation — entity header + grouped tabs

Entering an instance renders an entity header above the content: back-chevron to the parent list, entity name, status tag, key stats. Below it, a grouped tab strip; a second row of leaf links renders only when the active tab holds more than one screen.

┌ sidebar ──┬─ content ──────────────────────────────────────────────┐
│ WP Cycling│  ‹ Events    Rooibos MTB 2026   [Reg open]   14 Mar     │
│           │ ──────────────────────────────────────────────────────  │
│  Home     │  Overview │ Setup │ Entries │ Race day │ Results │ Money│
│           │ ──────────────────────────────────────────────────────  │
│  EVENTS   │  Categories · Courses · Races · Start groups · Program  │
│ ▸ Events ◂│ ──────────────────────────────────────────────────────  │
│    People │   [ data grid ]                                         │

Event tabs:

Tab Leaves

Overview

E01

Setup

Categories · Courses · Races · Start groups · Program · Event settings (E03)

Entries

Participants (E02) · Participant detail (E09) · Pre-assignment (E07) · Import (E06) · Export (E10) · Export timing (E11)

Race day

Start processing · Timing hand-off

Results

Results (E04) · Import results (E08)

Money

Orders · Financial recon

Reports

Event reports

Membership type + period takes the same shape: Overview · Setup (pricing, criteria) · Members · Renewals · Money · Reports.

Grouping is what keeps this viable — roughly seventeen event screens fit in seven tabs, and a new screen joins an existing group instead of widening the strip. Two alternatives were rejected: a nested sidebar column (Jira/Linear/Azure-blade style) handles twenty items natively but costs ~430 px of persistent horizontal chrome on screens that are overwhelmingly wide data grids; tabs plus a More ▾ overflow makes anything in the overflow undiscoverable.

The sidebar highlights the parent list row (Events) throughout an instance, so the user’s position in the tenant hierarchy stays legible.

Dual-use screens

Some screens are wanted at tenant scope and anchored to one instance — all orders across the organisation, and this event’s orders; financial recon overall, and for one membership period. These are declared once and rendered at every anchor they permit, so a tenant-level row and its instance-level alias cannot drift apart.

export type ScopeLevel = 'tenant' | 'event' | 'membership';

export interface ScopedScreen {
    key: 'orders' | 'financial-recon' | 'results' | 'reports' | /* … */;
    label: string;
    icon: IconName;
    segment: string;                              // appended under the active anchor
    levels: ReadonlyArray<ScopeLevel>;            // anchors this screen may hang from
    tab?: EventTab;                               // its tab at instance level
    capability: Capability;
}

Routes are generated from levels, all loading the same component:

  • tenant/o/:orgId/orders

  • event/o/:orgId/events/:eventId/orders

  • membership/o/:orgId/memberships/:typeId/periods/:periodId/orders

Scope is resolved by a ScopeService from the deepest ActivatedRoute snapshot carrying an eventId / membershipPeriodId param — never by pattern-matching router.url. Query-param forms are accepted as deep-link input and canonicalised into path form (?eventId=144/o/4/events/144/orders), so there is exactly one URL per view.

Consequences:

  • Active-highlight needs no special cases. At tenant scope the sidebar row highlights; at instance scope the sidebar highlights the parent list and the tab strip highlights the leaf. Both derive from the path.

  • The rev-4/rev-5 failure mode is structurally impossible: scope used to live in a query param while the highlight matcher deliberately stripped ?, so an event-scoped link silently dropped the user out of event scope.

  • The instance header carries a scope chipScoped to: Rooibos MTB 2026 ✕ — whose dismiss navigates to the tenant-level route. That is the discoverable path from narrow to wide, and mirrors the filter-clear affordance operators know from other tools.

    The chip renders only where a tenant-scope equivalent exists — i.e. only on screens whose ScopedScreen declaration carries the tenant level: Orders, Financial recon, Results, Reports, Members, Renewals, and Participants (whose tenant-scope counterpart is People). On Setup, Race day or Import there is nowhere to land, so the chip is omitted rather than rendered with a dead .

    Raised by the 2026-08-13 design pass and adopted, because it follows directly from the rule already governing the sidebar — an affordance that cannot act is not shipped (Sidebar groups (tenant scope)). A that does nothing is the "Coming soon" row problem wearing a different costume.

    The chip is also an event-level control only, and it belongs to the Event. It persists unchanged while a Race or Start group detail page is open, because those are pages within an event-scope screen rather than scopes of their own (AF-9). There is no race-level chip because there is no race-level scope.

Current inventory:

Screen tenant event membership Note

Orders

Orders cross event boundaries by design (C06).

Financial recon

Reports

Results

Tenant level = cross-event results and leaderboards.

Number & tag stock

Inherently cross-event; the event-level view is pre-assignment (E07), a different screen.

Imports

Instance-anchored by nature; entry stays a header action, not a nav row.

People

Not dual-use with Participants — different entities, see below.

People and Participants are not the same screen. Tenant-level People lists Person (backed by wp_users + wp_usermeta via PersonWrapper); event-level Participants lists EventParticipant. They link to each other but must not share a ScopedScreen declaration.

Capability gating

Nav visibility is gated on capabilities, not on a role string. The backend role delivered by GET /api/org-permissions/current-user (US #538) against the Feature #534 role enum is translated client-side into a capability set:

export type Capability =
    | 'events.view'      | 'events.manage'
    | 'participants.view'| 'participants.manage'
    | 'results.view'     | 'results.publish'
    | 'memberships.view' | 'memberships.manage'
    | 'orders.view'      | 'orders.refund'
    | 'reports.view'
    | 'stock.view'       | 'stock.manage'
    | 'people.view'      | 'people.merge'
    | 'finance.view'     | 'finance.reconcile'
    | 'tenant.users'     | 'tenant.masterdata' | 'tenant.settings' | 'tenant.audit'
    | 'platform.tenants' | 'platform.users'    | 'platform.masterdata' | 'platform.settings';

CapabilityService.has(cap) plus an *apIfCan structural directive. Every sidebar row and every ScopedScreen declares its capability.

reports.view was added on 2026-08-14. The 2026-08-13 design pass surfaced the gap: the closed list had no reports., so the navigation model had to gate the INSIGHTS *Reports row on finance.view as a placeholder. That is wrong — a results officer or event organiser may legitimately read reports without any access to financial data, and finance.view is the wrong key to hand them. Financial recon keeps finance.reconcile; Reports takes reports.view at every anchor it hangs from.

This translation is a UI-convenience projection only. Hiding a row is not access control — admin-service remains the enforcement point, and a capability the UI hides must still return 403 when the URL is typed directly. Capabilities are derived from the role the backend issued; they are never round-tripped from the client as an authority.

Rows a role can never reach are hidden. Rows the user could plausibly gain are shown disabled with the reason, consistent with the project’s preference for explaining limited affordances inline rather than hiding them silently.

Platform scope (super tenant)

A collapsible PLATFORM group in the same sidebar, rendered only when the session carries isSuperAdmin (US #536). One nav tree; no application-wide mode to enter or lose.

  • Position: last, below TENANT ADMIN. Collapsed by default; expanded state persisted in localStorage.

  • Pill: an ALL TENANTS tag — reusing the existing Tag primitive with the near-black #0a0a0a treatment — on the group header row, beside the page title of every platform screen, and as crumb 0 of the breadcrumb in place of the organisation crumb.

  • Colour differentiation is deliberately not required. The pill carries the safety signal.

Platform screens ignore the current organisation for data scope. The organisation chip stays populated while on a platform screen — deliberately, so the user can see where exiting returns them — which makes the pill the only thing distinguishing tenant-scoped from platform-scoped data on screen. It is therefore mandatory on every platform screen, not decorative.

Rejected: pinning Super Tenant as a row in the organisation switcher (the rev-1..5 design). It makes platform scope masquerade as an organisation, and selecting it silently redefines what every other control on screen means.

Organisation in the URL

The organisation is the first path segment group: /o/:orgId[-:slug]/….

  • The numeric id is authoritative; any trailing -slug is decoration, ignored on read (parseInt(segment, 10)). /o/4-wp-cycling/events/144 and /o/4/events/144 are the same view.

  • Renames change only the decoration — old links keep resolving. No unique index, no reserved-word list, no slug→id lookup.

  • The segment value is the organisationId sent on API calls, so no translation layer exists to drift.

Why not session-held only? A session-held current organisation means two browser tabs cannot hold two organisations: switching in tab B re-mints the JWT and silently changes what tab A’s next request returns. Path-scoping removes that class of bug and makes URLs portable between users.

Why /o/ and not /t/? Tenant is a distinct live concept in this system — hostname/registration-system resolution, where register.tourdeworcester.co.za → tenant 1508 → registration system 1505 → org 10. Tenant ids and organisation ids are different number spaces. "Tenant" remains the correct user-facing label in the UI; the URL segment and the API field say organisation so the two can never be confused.

Organisation hierarchy does not cascade access

Organisation.parent exists and disciplines cascade from it, but permissions do not. Holding a permission on a parent organisation grants nothing on its children; every organisation a user may act on is added explicitly and appears in linkedOrgIds in its own right.

Therefore:

  • The switcher renders a flat list, not a tree. Parent/child relationships are a domain concern, not a navigation one.

  • The organisationId subset check is a plain set-membership test against linkedOrgIds. No ancestor walk, no transitive expansion — which also means the check cannot be widened by a future hierarchy change.

  • A tenant admin at a parent organisation sees the TENANT ADMIN group for that organisation only. Administering a child requires its own grant.

Alternative Flows

  • AF-1 — Switch organisation. User clicks the organisation chip. Dropdown: search field, then the entries from linkedOrgs as a flat list (alphabetical or recency-sorted; no parent/child nesting — see Organisation hierarchy does not cascade access). Selecting one calls POST /api/session/organisation (re-issues the admin-service JWT with the new orgId claim — see OIDC Token Exchange § Runtime Tenant Switch + US #537) and navigates to /o/:newOrgId/…, landing on the screen last used in that organisation. Interim state: until the multi-org portal work lands, the chip is populated from linkedOrgs but only the active organisation is selectable.

  • AF-2 — Enter an instance. User clicks a row in the Events list (or Membership types list). Route becomes /o/:orgId/events/:id; the entity header + tab strip render above the content; the sidebar is untouched and keeps Events highlighted. Leaving via the header back-chevron returns to the list.

  • AF-3 — Widen a dual-use screen to tenant scope. User is on /o/4/events/144/orders and dismisses the scope chip in the entity header. SPA navigates to /o/4/orders; the same component re-renders unfiltered, the entity header disappears, and the sidebar Orders row takes the highlight.

  • AF-3a — Enter platform scope. Super admin expands the PLATFORM group and picks a screen. The organisation chip stays populated (it is the exit target); the ALL TENANTS pill appears beside the page title and as crumb 0; screen data ignores the current organisation. Collapsing the group or picking any tenant-scope row returns to tenant work.

  • AF-4 — Open notifications. User clicks the bell in the topbar. A 380 px panel drops below the bell with: header (title, "N unread" subtitle, "Mark all read" button, settings icon), three tabs (All / Unread / Mentions, with unread count badge on the Unread tab), and a scrollable list of notification rows. Each row: workspace-tinted icon, title, body (truncated), source ("Tour du Worcester 2025 · Comments"), relative timestamp, unread-dot rail on the left. Click a row to navigate to its source. Empty state: "You’re all caught up." See Notifications (designed).

  • AF-5 — Collapse sidebar. User clicks the collapse toggle in the sidebar footer. Sidebar animates from 232 px to 52 px (180 ms ease) showing icons only. Hover surfaces tooltips. Collapse state persists in localStorage (per user, per browser).

  • AF-6 — Jump-to command palette. User presses ⌘K (or Ctrl-K) or clicks the topbar "Jump to…" pill. Palette opens with fuzzy search over tenants, events in current tenant, members in current tenant, recent screens. Selecting a result navigates directly. Internals out of C01 scope; flag as a sibling C-screen when it grows.

  • AF-7 — Single-organisation fast path. The user is linked to exactly one organisation. The chip renders its name with no chevron and no dropdown — there is nothing to switch to, and an affordance that opens a one-entry list is noise. It is informational only; the layout is otherwise unchanged. The chevron and dropdown appear as soon as linkedOrgs.length > 1. (Resolved 2026-08-13.)

  • AF-9 — Open a Race or Start group. The operator picks a row on the event’s Races or Start groups screen. A detail page opens within that same screen at …/races/:raceId or …/start-groups/:startGroupId. The Event entity header persists unchanged, the Setup tab keeps its highlight, and the breadcrumb extends (WP Cycling ▸ Events ▸ Rooibos MTB 2026 ▸ Races ▸ <race>). There is no header swap, no race-level tab strip, and no race-level scope. (Rewritten 2026-08-15 — see the note below. Supersedes the 2026-08-13 "swap" resolution, now retired.)

    Why the swap was retired. Rev 6c gave a Race its own entity header and a four-tab strip — Overview · Start groups · Entries · Results — treating it as a small Event. The entity model does not support that reading.

    A Race is the intersection of an EventRaceType and an EventCategory (both @ManyToOne(optional = false), plus a Course). It owns exactly one collection, StartGroup. Its other three proposed tabs are not children at all:

    • Participants is derived. EventParticipant has no Race foreign key — it carries a category and a many-to-many eventRaceTypes. A race’s participants are computed from the same intersection that defines the race, widened through EventCategory.feederCategories where the race runs a combined category (raceCategory = true), e.g. Men 50–59 drawing from Men 50–54 and Men 55–59.

    • Categories is the race’s own eventCategory plus those feeders — an attribute of the race, not a collection under it.

    • Results is scored per race but stored against the result entities.

    A tab strip signals owned child collections, the way an Event owns Participants, Races and Orders. A Race has facets, not children, and facets belong on one page as panels. The swap existed only to make room for the tab strip; with the strip gone there is nothing to swap, and keeping the Event header means the operator never loses event context — which matters because race administration is comparative ("does 50–59 run the same course as 40–49?").

    This also settles the navigation question: Races and Start groups are ordinary Setup leaves alongside Categories, and neither needs a ScopedScreen entry beyond its list. No fourth ScopeLevel, no second registry axis.

    Page composition (panels, in order):

    • Race — identity block showing the intersection as two chips ([Road Race] × [Men 50–59]); Categories, rendering feeders as Men 50–59 ← Men 50–54 · Men 55–59; Details (course, distance, duration, min/max entries); Participants, as a count plus the first rows, with "View all" deep-linking to E02 pre-filtered by race rather than duplicating that grid; Start groups.

    • Start group — identity block; Races (see below); Participants, the assigned StartGroupParticipant rows in seq order; and Candidates, listing participants eligible for this group’s races but not in it, each showing their current start group, since reassignment is a transfer (an 08:00 → 08:05 move) and the operator must see what they are moving someone out of.

    Start group ↔ Race cardinality is a known backend gap. The schema has carried a start_group_feeder_race many-to-many since 2020-06-17, matching the real domain — several races may share one start group, running the same course off the same gun while scoring independently, each with its own 1st/2nd/3rd. JPA never mapped it; StartGroup maps a single race. The Start group page therefore shows one race until the mapping lands. It is deliberately not a blocker: the routing above holds either way. Tracked separately — the removal of StartGroup.race is non-trivial because it is the organisation path for row-level security in both StartGroupQueryServiceEx and StartGroupParticipantQueryServiceEx.

  • AF-8 — User loses access to current tenant mid-session. A tenant switch returns 403; SPA shows a toast and reverts to the previous tenant; tenant list is re-fetched (GET /api/session/tenants).

Acceptance Criteria

  • After OIDC login, the SPA renders the shell + the right landing within 1.5s of /api/session/current returning.

  • The organisation chip shows the current organisation’s name truncated to fit; full name visible on hover.

  • The sidebar renders identically on a tenant-scope screen and on an instance-scope screen — entering or leaving an event changes nothing in it except which row is highlighted.

  • Every sidebar group renders only when at least one child row is capability-permitted and module-enabled. No permanently-disabled rows ship.

  • The PLATFORM group appears only when the JWT carries isSuperAdmin: true. Otherwise it is absent — not hidden via CSS, not rendered.

  • Every platform screen displays the ALL TENANTS pill beside its title and as crumb 0, and its data is not filtered by the current organisation.

  • A dual-use screen reached at instance scope shows the scope chip; dismissing it navigates to the tenant-scope route and clears the filter.

  • A screen with no tenant-scope level (Setup, Race day, Import) shows no scope chip — never a chip with an inert .

  • /o/4/events/144/orders and /o/4-wp-cycling/events/144/orders resolve to the same view; a stale slug never 404s.

  • ?eventId=N on a tenant-scope dual-use route redirects to the canonical path form.

  • Switching organisation navigates to the new /o/:orgId route; every subsequent admin-service call carries X-Organisation-Id matching that segment. The JWT is not re-minted — the active organisation is a property of the request, not of the token (US #934).

  • An organisation outside the principal’s permitted set is rejected server-side with 403 — the portal never widens its own scope. A request naming none is scoped to every permitted organisation, which is the deliberate multi-client contract, not a fallback.

  • Neither the SPA nor the gateway decodes the admin-service JWT. Organisation list, role and super-admin flag all arrive from admin-service endpoints; a grep for claim parsing outside admin-service finds nothing.

  • A user holding a permission on a parent organisation but not on its child sees only the parent in the switcher, and receives 403 if they hand-edit the URL to the child.

  • Breadcrumb segments are links; crumb 0 is the organisation (or ALL TENANTS in platform scope).

  • Sidebar collapse / expand animates smoothly (180 ms ease); collapse state persists across reloads.

  • Bell icon shows red unread-count badge when notifications are unread; badge caps at "9+". Badge is absent when unreadCount === 0.

  • Notifications panel opens below the bell on click, closes on outside-click or Esc.

  • Domain accent colours are used for chips/badges only and never as dominant UI. (Rev 6: these were "workspace" accents; the workspaces are gone but the palette survives as the accent for a sidebar group and for notification-row tinting.)

    Group Accent

    Events

    indigo #4f46e5

    Memberships

    teal #0891b2

    Affiliates

    amber #ca8a04

    Tenant admin

    burnt amber #b45309

    Platform

    near-black #0a0a0a

  • All shell elements meet WCAG AA contrast on the Linear/Vercel-style cool-neutral palette.

API Surface

C01 needs only the shell-level endpoints. Screen-specific data (events, members, glance metrics, attention items) belongs to the relevant screen’s use case (E05 etc.).

Call Purpose

GET /api/session/current (gateway)

Bootstrap. { user, currentOrgId, linkedOrgs[], isSuperAdmin, lastContext }, where each linkedOrgs entry is { orgId, organisationName, role, accessLevel, enabledModules }. Composite — the gateway proxies admin-service for the organisation list and the super-admin flag; it does not decode the session-held JWT to build this (Organisation claims and the opaque-token boundary). user comes from the OIDC principal the gateway already holds.

GET /api/session/organisations (gateway)

Refresh the linked-organisation list. Proxies admin-service GET /api/org-permissions/current-user (US #538). Renamed from /api/session/tenants in rev 6 to match the entity.

POST /api/session/organisation (gateway)

Switch active organisation. Re-mints the admin-service JWT via POST /auth/token-exchange/oauth2 with the requested orgId (US #537). Renamed from /api/session/tenant in rev 6. Rejects an orgId the principal does not hold — the gateway does not pre-validate, it relays admin-service’s decision.

GET /api/org-permissions/current-user/super-admin (admin-service)

Exposes the super-admin flag to the gateway, which cannot read the isSuperAdmin claim. New requirement layered onto US #536 by the opaque-token boundary; may be folded into the current-user payload instead of a separate call.

GET /api/notifications (admin-service)

List notifications for the current user, scoped to the current tenant by default. Pagination + filter (unread, mentions).

POST /api/notifications/{id}/read and POST /api/notifications/read-all (admin-service)

Read-state mutations. Per-user, server-persisted.

Notification-specific endpoints are still candidates — see Notifications (designed); final shape decided when WS5 starts. Existing alternatives (extend CommunicationLogResource, or define a new NotificationResource) need a small spike at WS5 kickoff.

Front-end routes

C01 fixes the shape of the in-shell routes. New screens add routes that match these prefixes; routes that diverge need a discussion before they ship, because the ScopedScreen route generator and the scope resolver both rely on the shape.

Every authenticated route is prefixed /o/:orgId[-:slug] (Organisation in the URL). The prefix is omitted from the table below for readability.

Route Purpose

/

Tenant home.

/events

E05 Events Control Centre.

/events/:id, /events/:id/<segment>

E01 + instance-scope children. <segment> comes from the ScopedScreen registry; the entity header + tab strip render above every one of them.

/memberships/:typeId, /memberships/:typeId/periods/:periodId/<segment>

Membership type + period instance scope. Same shape as events.

/orders, /orders/:id

Tenant-scope Orders list + C06 Order Detail. Also reachable as /events/:id/orders and /memberships/:typeId/periods/:periodId/orders — same component, scope from the path (Dual-use screens).

/people, /people/:id

Tenant-scope master person list. Not aliased to event Participants — different entity.

/reports, /financial-recon

INSIGHTS group. Both dual-use; instance aliases follow the standard shape.

/imports/:uuid, /events/:eventId/imports/…​, /memberships/…​/imports/…​

Async import flow (C05 host + E06/E08/M bookends). Imports has no sidebar row — entry is a header action on the screen being imported into.

/inventory/numbers, /inventory/numbers/return, /inventory/numbers/onboard, /inventory/numbers/manufacturer-export, /inventory/stock-activity, /inventory/stock-activity/:batchId

OPERATIONS group. T02 / T03 / Stock activity are real screens; Onboard (C02) and manufacturer-export remain placeholders pending the number/tag UI orchestration. Bulk Flag-unfit / Dispose (T04) has no route — it is a dialog launched in-context. Browser titles + breadcrumbs for the four batch-aware screens are derived centrally in app-shell.component.ts (US #750).

/admin/…​

TENANT ADMIN group — T01.

/platform/…​

Platform scope — S01. Not prefixed by /o/:orgId; platform screens are organisation-independent.

Route-level guards remain minimal: deep-linking to a screen the user’s capabilities do not cover renders a "not available" state, and admin-service returns 403. Visibility is the sidebar’s job; enforcement is the backend’s.

Out of Scope

  • Landing-screen content (lives in the relevant screen’s use case).

  • Single-event detail (E01-E04, M01-M02, etc.).

  • Public landing page (C03).

  • Internal command-palette mechanics.

  • Notification creation — that lives in admin-service (publishers like CommunicationLogService, PersonMergeService etc. emit; notification-fan-out lives in the Async Signal & Sweep Framework, Feature #403).

Notifications (designed)

The bell in the topbar opens a 380 px notifications panel. Designed in the 2026-04-27 Claude Design pass; reverse-engineered into this spec.

Bell

  • Topbar right, next to the Jump-to pill.

  • bell icon, 15 px, in a 28×28 px button.

  • Active state: button background + border becomes bgMuted / border.

  • Unread-count badge: top-right of the icon; red #ef4444 background, white text, ≥15 px tall, rounded-full. Caps at "9+". Absent when unreadCount === 0. White 1.5 px border to lift it off the topbar.

Panel

  • Anchored absolute below the bell, top: 38px, right: -4px.

  • 380 × ≤440 px, panel background, 1 px border, 10 px radius, soft shadow.

  • z-index: 30 so it overlays the body content.

Header

  • Title "Notifications" + "N unread" muted subtitle when applicable.

  • Right side: ghost button "Mark all read" with check icon; quiet settings icon.

  • Tab strip below: All · Unread · Mentions. Active tab marked with bottom indigo accent border. Unread tab carries a small unread count next to its label.

Notification row

Element Detail

Unread rail

6 px column on the left; renders an indigo Dot (6 px) when row is unread, empty otherwise. Reads as a soft visual rail of unread items.

Group icon

26×26 px rounded square. Background = group accent at 9% alpha; foreground = group accent. Icon picked per type: user (mention), file (import), shield (role), event (status), arrowRight (comm), swap (membership), trophy (results-published).

Title

Bold, single line, ellipsis on overflow.

Body

Short, muted text. Single line of context — quote, count, summary.

Source

Smaller still, faint colour. e.g. "Tour du Worcester 2025 · Comments". Click target.

Timestamp

Right-aligned, faint, relative ("2m", "14m", "1h", "Yesterday", "2 days ago").

Read-state background

Unread rows have a subtle indigo-tinted background (#fafbff); read rows are transparent.

Notification types (sample, from the design)

The 2026-04-27 design seeds the panel with seven sample notifications across these types:

Type Example Source pattern

mention

"Zanele mentioned you" — "@chris can you confirm the start groups for the 109 km?"

<event> · Comments

import

"Participant import completed" — 1,204 rows · 12 warnings · 0 errors

<event> · Imports

role

"Role assigned: Results officer" — Pieter de Villiers granted Results officer

Tenant admin · Users & roles

status

"Event status changed: Setup → Registration open"

<event>

comm

"Bulk email sent — 12,480 recipients" — open-rate %

<event> · Communications

membership

"83 renewals processed"

Memberships · Renewals

status (results)

"Results published"

<event>

Type-set is open — new types are added by adding a new icon mapping. Treat the list as illustrative, not exhaustive.

Tenant scope

  • Within a regular organisation: notifications scope to the current organisation. Cross-organisation notifications are not shown.

  • In platform scope: the panel shows the user’s notifications across the organisations they have access to, with a small per-row organisation badge (the design’s onSuperTenant prop wires this). Defer the badge implementation until platform scope becomes a real workflow.

Read state

Server-persisted (cross-device). POST /api/notifications/{id}/read on click; POST /api/notifications/read-all on the "Mark all read" button. Optimistic UI with rollback on 4xx/5xx.

Open follow-ups (design)

  1. Settings cog in the panel header — currently inert. Decide v1 scope: silence-by-type? quiet hours? defer to a second design pass.

  2. Empty-state per tab: the design has one ("You’re all caught up") for the All tab. Tab-specific copy ("No unread", "No mentions") could be small wins.

  3. Long-list pagination: panel caps at 440 px height. "View all" link to a dedicated /notifications page is a candidate; not designed yet.

Open Questions

Resolved 2026-04-27 (round 2)

Question Resolution

Affiliates state

Designed: disabled row, Soon pill badge, cursor: not-allowed, "Coming soon" tooltip on hover.

Memberships landing

Mirror Events landing pattern (E05). Not a design priority right now.

Public landing scope

Extracted to C03 — confirmed in design.

Super-admin claim

isSuperAdmin boolean JWT claim — US #536 captures the implementation.

Last-context persistence

Browser localStorage (per-user, per-browser).

Tenant glance composite

Belongs to E05, not C01. Endpoint design follows the gateway-side composite recommendation.

Notifications structure

Designed: bell with unread-count badge, 380 px panel, tabs (All/Unread/Mentions), per-row icon/title/body/source/timestamp, read-state row tinting, mark-all-read.

Resolved 2026-08-12 (rev 6)

Question Resolution

Two sidebar modes (portfolio vs event) flagged by E05

Superseded. There are no sidebar modes — the sidebar is always tenant scope; instance navigation moved to the content area. Closes the E05 IMPORTANT follow-up.

Where tenant-wide operational functions live

A tenant-scope sidebar with named groups (OPERATIONS, INSIGHTS, TENANT ADMIN). T01 re-scoped to cover operational records, not configuration only.

Dual-use screens (tenant-wide vs instance-anchored)

One ScopedScreen declaration, path-nested aliases over a shared component, scope from ActivatedRoute. See Dual-use screens.

Tab-strip width for instance scope

Grouped tabs with an optional second row of leaves — ~17 event screens in 7 tabs.

Super-tenant mechanism

Collapsible in-sidebar PLATFORM group with a mandatory ALL TENANTS pill. Colour differentiation not required.

Organisation identifier in the URL

Numeric PK authoritative, optional ignored slug suffix, under /o/ (not /t/, which collides with the registration-system Tenant concept).

Role gating

Capability set translated client-side from the backend role; UI projection only, backend enforces.

Organisation hierarchy

Organisation.parent does not cascade permissions. Linked organisations are added explicitly; the switcher renders a flat list and the subset check is plain set membership. See Organisation hierarchy does not cascade access.

Source of the switcher’s organisation list

GET /api/org-permissions/current-user. Forced by the opaque-token boundary — the gateway cannot read linkedOrgIds. The claim stays authoritative for enforcement. See Organisation claims and the opaque-token boundary.

Does the gateway decode the JWT?

No. The token is opaque outside admin-service; the gateway attaches it and reads everything else from endpoints. Adds an endpoint requirement to US #536.

Still open

  1. Recent events on organisation switch. Jump straight to the last-used entity in the destination organisation, or drop the user at its home? Belongs to E05.

  2. Bulk-action placement on list pages. Out of C01 scope; affects E02 / M02 / E04.

  3. Notifications: settings cog scope. Defer to a follow-up design pass.

Forward-Engineering Backlog

All items now ADO-tracked under Epic #533 (Admin Portal):

Concern Detail ADO

OrgPermission.role enum + JWT claim shape

Define TENANT_ADMIN, EVENT_MANAGER, RESULTS_OFFICER, MEMBERSHIP_ADMIN, STAFF aligned with Spring Security GrantedAuthorities. Mint per-organisation roles into the JWT. Drives the capability translation.

Feature #534

isSuperAdmin JWT claim + endpoint

Boolean flag on OrgUser; minted into the JWT by NimbusTokenProvider. Backward-compatible w.r.t. registration-portal. Gates the PLATFORM group. Rev 6 adds a scope item: because the gateway treats the JWT as opaque, the flag must also be readable from an admin-service endpoint (standalone, or folded into GET /api/org-permissions/current-user).

US #536

requestedOrgId on OAuth2TokenExchangeRequestDTO

Optional field; when present + valid, mint with this orgId. Falls back to registrationSystemId when absent.

US #537

GET /api/org-permissions/current-user endpoint

Returns user’s accessible orgs [{orgId, organisationName, role, accessLevel, enabledModules}]. Cached in Hazelcast.

US #538

Expose org claims to the portal

SessionDTO carries neither linkedOrgs nor isSuperAdmin, and the sidebar takes perms?.[0]. The backend substrate already existsNimbusTokenProvider mints orgId + linkedOrgIds, and ITenantService.getOrganisation(Long) already validates a requested organisation against the principal’s set. The gap is portal-side only: surface both on the session DTO and stop taking the first permission.

New — file under Epic #533

Send organisationId from the portal

Portal API calls must carry the active organisationId (from the URL segment) so a multi-organisation principal is never ambiguous. Several resources already accept and validate it; the portal simply never sends it.

New — file under Epic #533

Organisation.enabledModules

Enum-set column (EVENT, MEMBERSHIP). Now load-bearing: it decides whether a sidebar group renders at all, a job the removed workspace switcher used to do.

Open ticket — file under Epic #533 when first needed by a screen.

Notifications backend

NotificationResource (or extension of CommunicationLogResource). Best implemented as a reactor on Async Signal & Sweep Framework (Feature #403) once that lands.

Open ticket — file under Epic #533 when WS5 nears.

Tenant glance composite (gateway)

Owned by E05 (workspace landing). Not C01.

Belongs to E05’s API surface.

Browser-local persistence

Last-context + sidebar-collapse — localStorage. No backend ticket.

n/a

Reference Implementation

File Role

admin-portal-app/src/app/core/shell/sidebar.component.ts

Tenant-scope sidebar — grouped rows from the nav registry, capability-gated

admin-portal-app/src/app/core/nav/scoped-screen.registry.ts

The ScopedScreen declarations (Dual-use screens) — single source for sidebar rows, instance tabs, and generated routes

admin-portal-app/src/app/core/nav/scope.service.ts

Resolves Scope from the deepest ActivatedRoute snapshot; never parses router.url

admin-portal-app/src/app/core/auth/capability.service.ts

Role → capability translation + *apIfCan directive

admin-portal-app/src/app/core/shell/entity-header.component.ts

Instance-scope header: back-chevron, name, status, stats, scope chip, grouped tab strip

admin-portal-app/src/app/core/shell/topbar.component.ts

Topbar with breadcrumb / Jump-to / notifications bell

admin-portal-app/src/app/core/shell/notifications-panel.component.ts

Notifications panel (380 px dropdown)

admin-portal-app/src/app/core/services/tenant-context.service.ts

Reads /api/session/current, drives switcher

admin-portal-app/src/app/core/services/notification.service.ts

Reads /api/notifications, manages read state

admin-portal-app/src/app/core/services/last-context.service.ts

Wraps localStorage for last organisation + screen + entity

@ems/shared-ui design tokens module

Group accent colour CSS custom properties + dev banner

Notes for Implementation

  • Place shell components under admin-portal-app/src/app/core/shell/. Landings (E05 etc.) live under features/<domain>/.

  • The sidebar renders from the nav registry; it holds no scope state and no URL matching. With a stable sidebar every row is a static route, so routerLinkActive replaces the hand-rolled activeKey() / routeFor() matcher entirely.

  • Instance tabs read their ids from ActivatedRoute params, not from router.url.

  • Generate routes from ScopedScreen.levels rather than hand-writing each alias — a hand-written alias is exactly how a tenant row and its instance twin drift.

  • TenantContextService is the canonical client of /api/session/* endpoints. Sidebar binds to its observables. It must expose linkedOrgs and isSuperAdmin, not just the current id.

  • Sidebar collapse — Signal-based; persisted in localStorage on change; read at app init.

  • Last-context — write lastContext = { orgId, route, entityType, entityId, label, timestamp } on every entity-screen navigation, keyed by user and organisation (a user’s last context in org 4 must not leak into org 9). Read on bootstrap. Landing screens (E05 etc.) read this for their Resume bars.

  • Group accent colours — define as CSS custom properties at the root, e.g. --ems-events: #4f46e5. Tag/Dot components consume the property.

  • NotificationsPanelComponent opens with a Material-CDK overlay or Angular CDK overlay; not a custom positioning solution.

Change History

Date Change

2026-08-15 (rev 7)

AF-9’s entity-header swap retired; Race and Start group become detail pages, not screens. Rev 6c had a Race replace the Event in the entity header and take a four-tab strip of its own. Reviewing the entity model showed the premise was wrong: a Race is the intersection of an EventRaceType and an EventCategory, owns only StartGroup, and has no EventParticipant foreign key at all — its participants are derived from that same intersection, widened through EventCategory.feederCategories for combined race categories. Tabs imply owned collections; a Race has facets. Both Race and Start group now open as detail pages inside their existing Setup leaf, with the Event header and Setup highlight untouched. This removes the need for a fourth ScopeLevel or a second axis on the ScopedScreen registry, unblocking the Nav 2 route generator. Panel composition for both pages recorded under AF-9, including the Start group Candidates panel that makes participant reassignment a visible transfer. Also recorded the start_group_feeder_race many-to-many that has been in the schema since 2020 but unmapped in JPA, and the fact that StartGroup.race currently carries row-level organisation security — so it cannot simply be dropped when that mapping lands.

2026-08-14 (rev 6d)

reports.view added to the capability list. The 2026-08-13 design pass had to gate the INSIGHTS Reports row on finance.view because the closed list contained no reports.* — which would have required financial access to read a report. Financial recon keeps finance.reconcile. Also allocated E12 and E13, the two use-case IDs the design project’s directory rename left unreserved; E13 carries an open question on whether it is a screen in its own right or simply the tenant anchor of the dual-use reports declaration.

2026-08-13 (rev 6c)

Design canvas rebuilt to match the spec. C01-structure/Structure.html is now a live prototype rather than static frames: five interactive artboards covering organisation switching (including the one-organisation no-chevron state), entering and leaving an event with the sidebar visibly unchanged, the conditional second tab row, scope-chip dismissal with a live URL bar, the secondary-entity swap, and platform scope with the ALL TENANTS pill. Rev 5’s canvas and structure.jsx retained read-only under C01-structure/superseded/. design-url repointed at the project (the previous share-hash form is stale). One refinement adopted from the pass: the scope chip renders only where a tenant-scope equivalent exists — on Setup / Race day / Import it is omitted rather than shown with an inert , which follows from the same no-dead-affordance rule that governs the sidebar. AF-9 gained the Race tab strip (Overview · Start groups · Entries · Results, one screen each, so no leaf row).

2026-08-13 (rev 6b)

"Tenant" defined rather than removed, plus two open questions closed. A first attempt at this revision renamed every portal-facing use of tenant to organisation (sidebar group, pill, capability namespace, ScopeLevel literal). That was reverted: the word is the right user-facing label, and the real problem was only that it was never defined. New Vocabulary — "tenant" in this portal section fixes the meaning — tenant = the organisation currently in context; a label, not a record — and states plainly that it is unrelated to the registration-portal Tenant entity, whose ids live in a different number space. Nothing is renamed: TENANT ADMIN, ALL TENANTS, tenant. capabilities, the 'tenant' ScopeLevel literal, TENANT_ADMIN (Feature #534), ITenantService / TenantContextService, and the T / S use-case ID prefixes all stand. Values, fields, URL segments and DTOs continue to say organisation — the split is *label vs identifier, and /o/ stays /o/ for exactly that reason. Resolved: AF-7 — a user linked to exactly one organisation gets a chip with no chevron and no dropdown; AF-9 — drilling into a secondary entity (a Race under an Event) swaps the entity header rather than nesting it, with the breadcrumb carrying the path back.

2026-08-12 (rev 6a)

Two constraints absorbed from the design session. (1) Organisation hierarchy does not cascade permissionslinkedOrgIds membership is explicit, so the switcher is a flat list and the organisationId check is plain set membership (Organisation hierarchy does not cascade access). (2) The JWT is opaque outside admin-service — the gateway attaches the token but never decodes it, so the organisation list, role and super-admin flag all arrive via admin-service endpoints (Organisation claims and the opaque-token boundary). Knock-on effects: GET /api/session/current becomes an explicit composite; /api/session/tenants/api/session/organisations and /api/session/tenant/api/session/organisation; US #536 gains an endpoint requirement alongside the claim, because a claim the gateway cannot read cannot gate the PLATFORM group. Both prior open questions on hierarchy and switcher-list source are closed.

2026-08-12 (rev 6)

Navigation model replaced. Root cause: four tiers (Tenant → Workspace → Mode nav → Entity) on three UI surfaces, so tiers 3 and 4 shared one nav list that had to shape-shift, and tenant-wide-but-not-instance-specific functions had no home — the reason rev 4 bolted Inventory on as an ad-hoc third "scope", and the reason the membership import wizard became the entry point to the membership domain. Second conflation corrected: "workspace" mixed domain (Events/Memberships/Affiliates) with scope (Tenant admin/Super admin). Now three scopes on three surfaces (Navigation Model — three surfaces, three scopes): organisation chip → stable grouped tenant sidebar (Sidebar groups (tenant scope)) → instance entity header + grouped tab strip (Instance navigation — entity header + grouped tabs). Workspace switcher removed; Organisation.enabledModules becomes the group-render lever. Dual-use screens formalised as one ScopedScreen declaration rendered at multiple anchors with path-nested routes (Dual-use screens) — scope resolved from ActivatedRoute, query-param forms canonicalised. Super tenant becomes a collapsible in-sidebar PLATFORM group with a mandatory ALL TENANTS pill (Platform scope (super tenant)), replacing the pinned tenant-switcher row. Capability gating replaces the ROLE_ADMIN boolean (Capability gating). Organisation moves into the URL as /o/:orgId[-:slug] with the numeric PK authoritative (Organisation in the URL). Documented the existing-but-unused multi-organisation substrate (orgId + linkedOrgIds claims; ITenantService selector validation) and the trust-boundary rule separating this from the 2026-07-29 registration-portal ?orgId= incident. Closes the two-sidebar-modes follow-up E05 flagged. Superseded: rev 4’s portfolio/event/tenant sidebar scopes, and rev 3’s workspace switcher including the Affiliates "Coming soon" row. Design journal: design-journal/2026-08/admin-portal-navigation-architecture.adoc.

2026-06-24 (rev 5)

US #750 navigation-consistency pass for the four batch-aware Inventory screens (T02, T03, Stock activity list + detail). Documented the final Inventory sidebar order (Stock → Return → Stock activity → Onboard → Manufacturer export) + Stock activity’s slot + uniform admin gate; corrected the prior rev-4 listing that named "Bulk (T04)" as a sidebar row and a /inventory/numbers/bulk route — T04 is a cross-cutting dialog, not a row or route. API-surface route table updated with the real /inventory/stock-activity[/:batchId] routes. Browser <title> ("Screen · Inventory", "Batch #N · Stock activity" for detail) and breadcrumbs are now derived centrally in app-shell.component.ts. Documentation-only here; the FE change ships under US #750.

2026-05-07 (rev 4)

Added third sidebar scope tenant — admin-role-gated Inventory group (Stock/Return/Bulk/Onboard/Manufacturer export). Hidden — not rendered — for non-admins, mirroring the C01 Super-Tenant-row precedent. Five placeholder routes wired under /inventory/numbers/…​; real T02/T03/T04/C02/manufacturer-export screens land via the number/tag UI orchestration (USs #731–#735 under Features #540/#541). Documented under the then-current Sidebar Scopes section + API Surface § Front-end routes. Additive change — no existing route, scope, or sidebar entry altered or removed. Superseded by rev 6 — the three sidebar scopes and the sidebar-scopes anchor no longer exist; see Navigation Model — three surfaces, three scopes.

2026-04-27 (rev 3 — handoff-ready)

Stripped Events-specific landing content (Resume / Active / Glance / Attention) — moved to E05. Notifications section rewritten as "designed" (bell with unread-count badge, 380 px panel with tabs, per-row anatomy, sample types, read-state semantics) reverse-engineered from the 2026-04-27 design pass. Public landing extraction confirmed → C03. Affiliates "Coming soon" tooltip implementation matches design. Forward-engineering items linked to ADO Epic #533 (Features #534/#535, USs #536–538). Status promoted to handoff-ready: structural shell is settled.

2026-04-27 (rev 2)

User decisions absorbed: Affiliates "Coming soon" tooltip; Memberships landing mirrors Events; public-landing extracts to C03; super-admin = JWT flag (not org); last-context = localStorage v1; tenant glance = gateway composite. OrgPermission distinction clarified vs LinkedOrg.accessLevel. Notifications promoted from "later" to a Claude Design ask in the next pass.

2026-04-27

Initial reverse-engineered draft from Claude Design canvas. Status: in-design.