@@ -0,0 +1,284 @@
# 0063: Module navigation — manifest v9 icons and pages, real module routes, and return-to-origin
- Status: Accepted
- Date: 2026-10-15
- Extends: [0022 ](0022-module-ui-authoring-and-host-contract.md ),
[0026 ](0026-minimal-ekklesia-module-ui-host.md ),
[0050 ](0050-module-database-abi-v1.md ),
[0057 ](0057-dashboard-urls-are-host-owned-routes.md ),
[0058 ](0058-dashboard-shell-navigation-and-lucide-icons.md ),
[0059 ](0059-shared-interaction-primitives.md ),
[0061 ](0061-settings-hierarchy-and-module-settings-routes.md )
- Supersedes, in part: [0027 ](0027-dashboard-navigation-and-flat-modules.md ) (a module now declares
its own icon and primary pages; grouping and ordering stay the administrator's)
## Context
DESIGN.md treats a module as a first-class application area:
- its primary pages belong in the global sidebar beneath it, with no second tab strip inside the
module (§4.4, locked decision 11);
- every meaningful page has a real route, so a refresh, a bookmark, Back and Forward work (§2.7,
locked decision 12);
- a module can deep-link to its centralized Settings page and come back to exactly the workflow
that sent the person there (§2.7, §9.3, locked decision 13).
The platform had none of this:
- **Icons.** Every module was drawn with one generic icon. ADR 0058 left a module's own icon to
"the module navigation contract (manifest v9)".
- **Routes.** `/<module-id>` was a module's only route, and everything beneath it was not-found
(ADR 0057).
- **Navigation.** The module UI context had no navigation member. ADR 0022 deferred it because "it
needs a design that does not exist", and modules may not bring a router (ADR 0026).
- **Return target.** ADR 0059 built and validated `parseReturnTarget` , and ADR 0061 gave every
module's settings `/settings/<module-id>` , but both deferred * how * a link carries a return
target.
Released modules (Signage 0.1.5 among them) are packaged, signed, and installed as manifest version
8, and every earlier manifest version was rejected outright by its successor.
## Decision
### Manifest v9: one optional `navigation` field
Manifest version 8's top-level object is strict, so a new field is a new version. Version 9 is
version 8 plus one optional field:
``` json
"navigation" : {
"icon" : "monitor-play" ,
"pages" : [
{ "id" : "playback" , "label" : "Playback" , "path" : "playback" } ,
{ "id" : "timelines" , "label" : "Timelines" , "path" : "timelines" } ,
{ "id" : "media" , "label" : "Media" , "path" : "media" }
]
}
```
- **`icon` ** (optional) is a Lucide name from a curated set (below). Omitted, the module is drawn
with the generic module icon.
- **`pages` ** (optional, at most 12) are the module's primary pages, in the order the sidebar shows
them. Each page has:
- **`id` **: stable and never displayed. Lowercase kebab-case, at most 64 characters, unique within
the manifest. The navigation API reports it as the current page.
- **`label` **: plain text, trimmed, 1– 60 characters, with no control or line-separator characters.
- **`path` **: relative to the module's root. One to four lowercase kebab-case segments joined by
`/` , unique within the manifest. It may not lie beneath another page's path.
- **What a path can never be:** a leading or trailing slash, an empty segment, `.` or `..` ,
percent-encoding, a query, a fragment, or a scheme. A declared path cannot escape `/<module-id>/`
or name anything outside the module.
- **Requires a UI.** `navigation` is only valid with `entrypoints.ui` .
- **Validated before activation.** Validation lives in `@ekklesia/contracts` , so discovery,
`pnpm modules:validate` , the packager, and package verification all refuse a bad declaration
before the module can start.
- **No page icons.** The collapsed sidebar shows the module item alone, so a page icon would never
be seen.
- **Nothing else is added.** No groups, order, Overview widgets, or settings declaration: a module
already has settings exactly when it declares `configuration` (ADR 0029, ADR 0061).
`moduleManifestNavigation(manifest)` reads the declaration the same way for every version. A version
8 manifest, and a version 9 manifest that omits the field, both have no icon and no pages.
### Version 8 stays accepted beside version 9
Until now every manifest version rejected its predecessor ("no dual acceptance": ADR 0006, ADR 0014,
ADR 0021, ADR 0050). This decision is the first exception, deliberately:
- **Why.** Version 9 adds one optional field and changes the meaning of nothing a version 8 document
says. Rejecting version 8 would stop every installed module at its next upgrade for no safety gain.
- **One document, one version.** `manifestVersion` selects one strict schema (a discriminated union
of 8 and 9). A document is only ever read as the version it declares. A version 8 document that
names `navigation` is refused, never quietly read as version 9.
- **Nothing is rewritten.** The packager still copies manifest bytes verbatim, and verification
checks them against the package digest. Core never writes a manifest back.
- **The template moves to version 9.** `MODULE_MANIFEST_VERSION` is now 9, the version a new module
declares. `SUPPORTED_MODULE_MANIFEST_VERSIONS` is `[8, 9]` . Version 7 and earlier are still
refused.
How long version 8 stays accepted is a later decision. It needs its own ADR.
### Icons: a curated Lucide set, named in the manifest, drawn by the host
A manifest names an icon. It never supplies one: no SVG, URL, component, or code.
- **The set.** `MODULE_NAVIGATION_ICONS` (`@ekklesia/contracts` ) is about ninety Lucide names chosen
for generic subjects (`calendar` , `monitor-play` , `users` , `book-open` , …). There is no entry for
any particular module, and no module-to-icon table anywhere.
- **Validated against the set.** A manifest's `icon` must be in it, so a typo is caught when a
module is packaged or discovered, not drawn silently as the generic icon.
- **Imported by name.** The Dashboard imports exactly these icons from `lucide-react`
(`browser/module-icon.tsx` ) and never bundles the library's thousands of others.
- **One list, held together by tests.** A surface may not import `@ekklesia/contracts` (ADR 0024),
so the shell keeps the names in a framework-free file, `@ekklesia/shell/module-icons` .
- An `apps/api` test holds the shell's list equal to `MODULE_NAVIGATION_ICONS` , so the platform
cannot accept an icon the Dashboard cannot draw.
- A shell test proves every entry is the real Lucide component of that name.
- **Growing the set is additive.** It needs a platform release, and a module that uses a new name
declares the platform range that has it.
- **Fallback.** A host that cannot draw a name draws the generic icon instead of failing. The
Dashboard can draw all of them; another host might not.
- **Decorative only.** Icons never carry meaning (ADR 0058). The module's name is always beside the
icon or in its control's accessible name.
### Module routes
``` text
/<module-id> the module's landing view (its root)
/<module-id>/<page-path> a declared primary page
/<module-id>/<page-path>/… a route beneath a declared page: a detail view the module owns
```
**The root stays the canonical landing view and is never itself a declared page. ** A page may not
use the root's path, so no page has two addresses, and `/<module-id>` never silently redirects.
- **Landing on a page.** A module that wants to land on, say, Playback renders Playback at its root
and simply doesn't declare a Playback page.
- **Beneath a declared page.** Any route under a page (`/signage/media/sermons/123` ) mounts the
module with that full path. Detail views therefore need no manifest or sidebar entry. Primary
navigation and every valid internal route are deliberately different things.
- **What a route can contain.** Segments are unreserved URL characters only (letters, digits, `-` ,
`.` , `_` , `~` ), at most 8 segments and 512 characters. Nothing else is a route.
- **Unknown paths.** A path that is neither the root nor at or beneath a declared page is the
host's * Page not found * , and the module is not mounted. Nothing silently opens some other page.
- **Version 8 modules.** A module that declares no pages, which includes every version 8 module, has
its root and nothing beneath it, exactly as before.
- **Disabled modules.** A module that is installed but not running gets * This page isn't available *
at its root and at every path beneath it, and nothing mounts. A module that isn't installed is not
served at all (ADR 0057).
- **No remount.** The mounted module is keyed by identity, not path, so moving between its own pages
never remounts it. Focus moves to the page on each move, as for any page change.
The API already serves the Dashboard page throughout `/<module-id>/*` (ADR 0057), so none of this
needs a server or proxy change.
**The host's own namespaces are never pages. ** `api` , `assets` , and `display` (`HOST_NAMESPACES` )
are never Dashboard pages at any depth, so `/api/v1/principal` can never read as the route
`v1/principal` of a module called `api` . Together with the Dashboard's platform pages they are
exactly Core's `RESERVED_TOP_LEVEL_NAMES` , and an `apps/api` test holds the lists together.
### The sidebar
- **A version 9 module** is drawn with its own icon. While it is open (its root, a page, or beneath
one), its pages appear beneath it, the same in-place expansion Settings uses (ADR 0061).
- **The current item.** Exactly one item carries `aria-current="page"` : the open page, or the
module itself on its landing view. A detail route marks its page.
- **Collapsed sidebar.** It shows icons only, so the pages stay hidden. The module item is then the
current one, named by its tooltip and accessible name.
- **Phone drawer.** It has the identical hierarchy and closes once a page is chosen.
- **A version 8 module** is one item with the generic icon, as before.
- **A disabled module** is absent, as before.
The server sends each module's declared icon (`null` for none) and pages beside its name in
`GET /api/v1/dashboard/navigation` , never inside the host-neutral descriptor (ADR 0026, ADR 0027).
The shell parses both strictly.
### The module's navigation capability
`ModuleUiHostContext.navigation` (`@ekklesia/module-ui-sdk` ) is a narrow host capability, not a
router. It is `undefined` where a host has no addressable pages: the display host, and a standalone
host without routes.
``` ts
interface ModuleUiNavigation {
location ( ) : { path : string ; page : string | undefined } ; // "media/sermons/123", "media"
subscribe ( listener : ( ) = > void ) : ( ) = > void ;
navigate ( path : string , options ? : { replace? : boolean } ) : void ;
link ( path : string ) : { href : string ; onClick ( event ) : void } ;
readonly settings : { open ( ) : void ; link ( ) : ModuleUiLink } | undefined ;
}
```
- **Relative paths only.** Every path is relative to the module's own root, and a leading `/` means
the same as none. There is therefore no way to name another module or a platform page.
- **Refused paths.** A path the host would not open throws `ModuleUiNavigationError` : malformed or
escaping gives `MODULE_NAVIGATION_PATH_INVALID` , and not at or beneath a declared page gives
`MODULE_NAVIGATION_PATH_UNKNOWN` . The error message never echoes the path.
- **Real links.** `link()` returns a real `href` , so a link can be opened in a new tab, copied, or
bookmarked. Its `onClick` opens the route in place on an ordinary click and leaves modified or
middle clicks to the browser.
- **Location updates.** `location()` returns a stable snapshot between moves. Every subscriber hears
every move: the module's own navigation, the sidebar, Back and Forward.
- **Settings.** `settings` is present only when the module has a Settings page this viewer may open
(it declares configuration, it is running, and Settings lists it for them). It's a presentation
hint: Settings enforces its own authority.
- **What a module never gets.** No history object, no `window` access, and no way to drive the shell
beyond its own routes. Modules still may not bring a router (ADR 0026).
- **Version 8 modules too.** They receive navigation with only their root, so a version 8 module
rebuilt against the new SDK can already link to its Settings page.
Adding a context member changes nothing about how an artifact is mounted, so the mount ABI stays at
version 2. A module that uses `navigation` checks for it, exactly as it checks `display` .
### Return-to-origin: one carrier, the `return` query parameter
The place to come back to travels as **one query parameter on the page that was opened ** :
``` text
/settings/signage?return=%2Fsignage%2Ftimelines
```
- **It is a query, not history state,** so it survives a refresh and works in a link opened in a new
tab. A canonical respelling of the address keeps it.
- **The module never builds or checks it.** `navigation.settings` builds it from the module's
current location.
- **It is only ever read through `parseReturnTarget` ** (ADR 0059): a canonical, same-origin
Dashboard address with no query, fragment, traversal, or encoded separator. The host namespaces
are never accepted.
- **A module's address is also checked against what is running.** The module must be running here,
and the route must be its root or at or beneath a declared page. Anything else is ignored, and
Back goes to the page's own parent, Settings.
- **Back follows it.** A Settings page's Back goes to exactly that address ("Back to Signage"), as
a real link, never `history.back()` .
`HostHistory` gains `search()` so the shell can read the query. The shell reads exactly this one
parameter from it.
## Alternatives
- **Keep version 8 and add `navigation` to it.** Rejected: version 8's object is strict. Older
platforms would refuse such a manifest with a misleading "invalid manifest". A version number
says what a document is.
- **Reject version 8 as earlier versions were.** Rejected: it would break every installed module
for an optional, additive field.
- **Any Lucide name, validated only by shape.** Rejected: a typo would silently draw the generic
icon, and drawing any name means bundling or code-splitting the whole library. The API serves a
closed set of files (ADR 0044), so it cannot serve a thousand chunks.
- **Icons as SVG or URLs in the manifest.** Rejected: executable or remote presentation in an inert
document, with an asset-trust story nobody needs.
- **The root as a declared page, or a redirect from the root to the first page.** Rejected: either
gives one page two addresses or surprises a bookmark.
- **Every route in the manifest.** Rejected: record URLs (`/media/sermons/123` ) cannot be listed.
Primary navigation and valid routes are different things.
- **A router in the module, or `window.history` .** Rejected: routing belongs to the host
(ADR 0026), and two owners of one address bar disagree.
- **Return target in history state, `sessionStorage` , or a path segment.** Rejected: history state
and storage don't survive a new tab, and a path segment would collide with module routes. A query
parameter is visible, refreshable, and validated on every read.
- **Page icons.** Not added: the collapsed sidebar never shows pages.
## Consequences
- **Packages.** `@ekklesia/contracts` gains `module-navigation.ts` , the v8/v9 manifest union, and
`moduleManifestNavigation` . `@ekklesia/module-ui-sdk` gains `navigation.ts` and
`ModuleUiHostContext.navigation` . `@ekklesia/module-sdk` re-exports the navigation helpers and
types. All changes are additive for module code. A host that builds a context itself must now say
`navigation: undefined` .
- **Core** publishes each module's navigation beside its name. The navigation response gains a
`navigation` member per module.
- **The shell** reads module routes at any depth, draws icons and pages, and supplies the
capability. Its route type's `module` destination gains `path` .
- **The template module** is version 9, with two pages, a detail route, and a Settings link.
- **Signage** is unaffected until it chooses to move to version 9. Its released 0.1.5 manifest is a
test fixture and runs in the deploy smoke.
## Deferred
- Migrating Signage to version 9.
- How long version 8 stays accepted.
- Overview widgets.
- A module reporting "not found" for its own unknown detail route back to the host.
- Return targets for platform pages other than Settings.
- Navigation groups and their editor (ADR 0027).
- Growing the icon set.
- Module-owned breadcrumbs.