Files
Ekklesia/docs/architecture/decisions/0063-module-navigation-contract-manifest-v9.md
T
ekklesiaandClaude Opus 5.5 c78f1318b4
CI / verify (pull_request) Successful in 1m11s
CI / infrastructure-smoke (pull_request) Successful in 42s
CI / package-release (pull_request) Successful in 48s
CI / migration-smoke (pull_request) Successful in 2m27s
CI / image-build (pull_request) Successful in 1m8s
CI / deploy-smoke (same-origin) (pull_request) Successful in 52s
CI / package-publication (pull_request) Successful in 57s
CI / deploy-smoke (split) (pull_request) Successful in 3m35s
Make the module navigation capability an optional context member
ModuleUiHostContext.navigation is now `navigation?: ModuleUiNavigation |
undefined`, so the public context change is source-compatible: a context built
before navigation existed (without the member) still compiles unchanged, as
0.x patch releases require. The Dashboard supplies navigation; hosts without
addressable pages, including the display host, omit the member; modules
feature-detect it. Existing hosts' tests are restored to their pre-PR shape,
and a module-ui-sdk test proves a pre-navigation context still satisfies
ModuleUiHostContext (and fails if the member is ever made required).
Runtime behaviour, manifest v8/v9 compatibility, and security rules are
unchanged. ADR 0063 and the module development guide say so.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 09:13:03 -05:00

17 KiB
Raw Blame History

0063: Module navigation — manifest v9 icons and pages, real module routes, and return-to-origin

  • Status: Accepted
  • Date: 2026-10-15
  • Extends: 0022, 0026, 0050, 0057, 0058, 0059, 0061
  • Supersedes, in part: 0027 (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:

"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

/<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, and an optional member (navigation?: ModuleUiNavigation). A host with addressable module pages (Ekklesia's Dashboard) supplies it. A host without them (the display host, and a standalone host without routes) omits it, and may also set it to undefined.

The member is optional so that adding it breaks no one. Ekklesia's 0.x versioning makes patch releases compatible, and a context built before navigation existed must stay a complete, valid context that compiles unchanged. A @ekklesia/module-ui-sdk test holds exactly that, and fails if the member is ever made required.

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 feature-detects it (context.navigation?.navigate(…)).

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:

/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. Every change is additive at the source level as well as at run time: existing module code, and an existing host that builds a context itself, compile and run unchanged.
  • 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.