Stage 1: module navigation contract — manifest v9, module routes, return-to-origin #110

Merged
Joey merged 3 commits from feat/module-navigation-contract into main 2026-10-15 14:32:49 +00:00
Member

This is Stage 1 PR 7, the module navigation contract. Modules get a declared Lucide icon and primary pages in the global sidebar, real nested routes, a host navigation capability, and module → Settings → Back to the exact origin. Released manifest v8 modules keep working unchanged. Decisions are recorded in ADR 0063.

This PR is contract and foundation only. Signage is untouched and not migrated. It adds no Overview widgets, Discover features, settings forms, Displays work, or group editor.

⚠️ One precedent change for review: v8 stays accepted beside v9

Every previous manifest bump (v1→…→v8) rejected its predecessor outright: "no dual acceptance", per ADRs 0006, 0014, 0021 and 0050. You asked for v8 compatibility, so v8 and v9 are both accepted.

  • manifestVersion selects one strict schema, via a discriminated union.
  • A document is only ever read as the version it declares. A v8 document that names navigation is refused, never upgraded.
  • No released manifest, package byte, or digest is rewritten.
  • v7 and earlier are still refused.
  • How long v8 stays accepted is left to a future ADR.

v9 is the right version number: v8's top-level object is strict, so a new field is a version bump by the repository's own rule.

Manifest v9

v9 is exactly v8 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): one of MODULE_NAVIGATION_ICONS, a curated set of 93 generic Lucide names. Omitted, the module gets the generic module icon.
  • pages (optional): at most 12, in declared order.
    • id: kebab-case, stable, unique.
    • label: trimmed, 1–60 characters, no control or line-separator characters.
    • path: 1–4 lowercase kebab-case segments relative to the module root. It can't contain a leading or trailing slash, an empty segment, . or .., percent-encoding, a query, a fragment or a scheme, and can't be a duplicate, sit beneath another page, or be the root.
  • navigation is only valid with entrypoints.ui. It is validated by discovery, modules:validate, the packager and package verification before activation.
  • No page icons. The collapsed sidebar only ever shows the module item, so page icons would never be seen.
  • No settings declaration. configuration already answers whether a module has settings (ADR 0061).
  • No widgets or groups.

Icons

  • Validated against the list. Manifests are checked against the curated set, so typos are caught at pack and discovery time.
  • Imported by name. The shell imports exactly those icons from lucide-react (browser/module-icon.tsx); the full library of about 6,300 icons is never bundled.
  • One list, held together by tests. Surfaces can't import @ekklesia/contracts (ADR 0024), so the shell keeps the names in a framework-free subpath, @ekklesia/shell/module-icons.
    • An apps/api test holds that copy equal to the contracts list.
    • A shell test proves each entry is the real Lucide component.
  • Fallback. A name a host can't draw falls back to the generic icon.
  • Decorative only. Icons are always aria-hidden, with the name in the label or tooltip.
  • There is no module → icon table anywhere.

Routes

/<id>              landing view (never itself a declared page → no duplicate identities, no redirect)
/<id>/<page>       declared page
/<id>/<page>/…     anything beneath a page: module-owned detail routes, e.g. /media/sermons/123
anything else      host "Page not found"; the module is not mounted
  • Refresh, deep links, and Back/Forward all land on the same route. Moving between a module's own pages never remounts it, because the frame is keyed by module identity.
  • v8 modules: root only. Anything deeper is not-found, exactly as before.
  • Disabled modules: "unavailable" at the root and every subpath, and nothing mounts.
  • Uninstalled modules: not served (unchanged).
  • New guard: api, assets and display (HOST_NAMESPACES) are never pages at any depth. Without it, nested module routes would have let /api/v1/principal parse as a route of a module called api, and so be accepted as a return target. A test holds DASHBOARD_PLATFORM_PAGES + HOST_NAMESPACES equal to Core's RESERVED_TOP_LEVEL_NAMES.

Sidebar

  • v9: the declared icon. Pages are nested beneath the module while it's open, using the same in-place expansion and indentation as Settings.
  • Current item: exactly one aria-current="page". It's the open page, or the module itself on its landing view; a detail route marks its page.
  • Collapsed: pages are hidden and the module item is current, named by its tooltip and accessible name.
  • Phone drawer: the identical hierarchy with 48px targets, closing once a page is chosen.
  • v8: the generic icon and a single item.
  • Disabled modules: absent.
  • The server sends navigation: { icon, pages } beside each module's name in /api/v1/dashboard/navigation, never inside the host-neutral descriptor.

SDK: context.navigation (@ekklesia/module-ui-sdk)

interface ModuleUiNavigation {
  location(): { path: string; page: string | undefined };
  subscribe(listener): () => void;
  navigate(path, { replace? }?): void;
  link(path): { href; onClick(event) };       // real <a>; modified/middle clicks left to the browser
  readonly settings: { open(); link() } | undefined;
}
  • Relative paths only. Paths are module-relative, and a leading / means the same as none.
  • Refused paths throw ModuleUiNavigationError, with a code and a message that never echoes the path:
    • malformed or escaping paths give MODULE_NAVIGATION_PATH_INVALID;
    • undeclared pages give MODULE_NAVIGATION_PATH_UNKNOWN.
  • Cross-module navigation is impossible. No history object, window access or router is exposed.
  • Where it's present: it's an optional member (navigation?), so an existing hand-built context compiles unchanged. The Dashboard supplies it, the display host omits it, and modules feature-detect it. v8 modules get navigation too, with only their root.
  • No ABI change. The mount ABI stays at 2; this is an additive context member.

Return-to-origin: one carrier

The return destination travels in a query parameter on the Settings URL:

/settings/signage?return=%2Fsignage%2Ftimelines
  • navigation.settings builds it from the module's current location, so module authors never build or validate it.
  • It survives a refresh, a new tab, and a canonical respelling. HostHistory gains search() to support this.
  • It is validated twice:
    • parseReturnTarget accepts canonical same-origin Dashboard addresses only;
    • a module address must also name a running module and one of its declared routes.
  • Back reads "Back to Signage" and is a real link to the validated address. It never uses history.back(). An invalid target falls back to Settings.

Public package changes (no version bumps, as the release process does those separately)

  • @ekklesia/contracts: the v8/v9 manifest union; module-navigation.ts (MODULE_NAVIGATION_ICONS, page and navigation schemas, moduleManifestNavigation); MODULE_MANIFEST_VERSION is now 9; SUPPORTED_MODULE_MANIFEST_VERSIONS is [8, 9].
  • @ekklesia/module-ui-sdk: navigation.ts (types and ModuleUiNavigationError), plus an optional ModuleUiHostContext.navigation?. This is source-compatible: a context built before navigation existed still compiles unchanged, and a test proves it. Hosts without pages, including the display host, omit it, and modules feature-detect it.
  • @ekklesia/module-sdk: re-exports the navigation helpers and types.
  • Unchanged: module-ui-host, module-pack and ui.
  • Private: Core and the API publish and serve navigation. The shell adds routes, the sidebar, the capability, and the ./module-icons subpath. The template module moves to v9 with two pages, a detail route, and a Settings button.

Compatibility correction (c78f131)

navigation was first a required member (navigation: ModuleUiNavigation | undefined), which would have broken any existing hand-built context at compile time. That isn't allowed for a 0.x patch-compatible change. It is now navigation?: ModuleUiNavigation | undefined:

  • Old contexts compile. A context without the member still satisfies ModuleUiHostContext. The navigation: undefined lines this PR had added to existing hosts and tests are reverted, so those files are byte-identical to main again.
  • A test guards it. A module-ui-sdk test type-checks an exact pre-navigation context with satisfies. Making the member required again fails 5 type checks, confirmed by mutation.
  • Runtime and security are unchanged. The display host now omits the member rather than setting it to undefined.
  • After the change: verify, package release and package publication all pass again.

Tests

  • Contracts:
    • v9 single- and multi-page manifests, the icon and its fallback, invalid icons (unknown, PascalCase, markup, URL, object), and 18 unsafe path forms;
    • malformed ids and labels, duplicate ids and paths, nesting, the bound, and navigation without a UI;
    • deterministic order; exactly versions 8 and 9 accepted;
    • the released Signage 0.1.5 manifest, byte-identical to the one inside the published package (SHA-256 checked), accepted as v8 with no navigation; a v8 document carrying navigation refused.
  • Core: discovery of the released v8 Signage module and a v9 module side by side, and refusal of an escaping page.
  • API:
    • the navigation view carries the v9 icon and pages, and {icon:null, pages:[]} for v8;
    • an unsafe v9 manifest fails discovery;
    • icon parity with the shell; host-namespace parity with Core's reserved names.
  • Shell (module-navigation.test.tsx, 44 tests), with real artifacts mounted through the loader's Shadow DOM boundary:
    • routing: root, declared page, detail route, and unknown pages (including near-misses); v8 root-only; disabled modules at every depth;
    • history: Back/Forward/refresh with one mount and no remount; focus moving on a page change;
    • sidebar: v9 icon, v8 generic icon, pages only while open, collapsed state, the drawer, and an unknown icon falling back;
    • SDK: navigate (add or replace), 9 refused paths with no leak, real links with plain vs modified clicks, every subscriber notified on host-driven moves;
    • Settings: module → Settings → Back to the exact page, refresh, respelling, and 9 unsafe return targets falling back.
  • Also updated: route and return-target tests (the host namespace, empty segments, deep routes); history query tests; SDK error tests; the template v9 manifest and page rendering through the real built artifact.

Results

  • TURBO_FORCE=true pnpm verify: passed. contracts 286, core 1194, api 573, shell 470, module-ui-sdk 38, template 22, plus every package.
  • pnpm test:package-release: passed.
  • pnpm test:package-publication: passed.
  • pnpm test:migrations:integration: passed, 428 tests.
  • pnpm infrastructure:config and pnpm deploy:config: passed.
  • Deploy smoke, same-origin: passed.
  • Deploy smoke, split, with the v9 template, in real Chromium: passed, 397 checks. All five browser passes ran the new steps:
    • sidebar sub-navigation, and a page change without a remount;
    • a direct deep link, Back and Forward;
    • an undeclared page not found, and the detail route;
    • module → Settings via the capability, then Settings Back to the exact origin after a refresh;
    • an off-site return ignored, and the phone drawer.
  • Deploy smoke, split, with the real released Signage 0.1.5 (manifest v8) package: passed, 340 checks. It covers v8 behaviour (generic or own icon, no sub-navigation, not-found beneath the root), plus the display, pairing, kiosk and offline scenarios that guard Stage 0 safety.

Visual check (headless Chromium 151):

  • light and dark at 1280px, focus visible on a nested page;
  • the collapsed sidebar;
  • a 390px phone drawer (no overflow, 48px targets);
  • the Settings return flow.

This caught one bug: the nested pages weren't indented, fixed in ae360ef.

Deferred

  • Migrating Signage to v9.
  • When v8 stops being accepted (its own ADR).
  • Overview widgets.
  • A module reporting not-found for its own detail routes.
  • Return targets for platform pages beyond Settings.
  • Navigation groups and their editor.
  • Growing the icon set.

🤖 Generated with Claude Code

This is Stage 1 PR 7, **the module navigation contract**. Modules get a declared Lucide icon and primary pages in the global sidebar, real nested routes, a host navigation capability, and module → Settings → Back to the exact origin. Released manifest v8 modules keep working unchanged. Decisions are recorded in [ADR 0063](docs/architecture/decisions/0063-module-navigation-contract-manifest-v9.md). This PR is contract and foundation only. Signage is untouched and not migrated. It adds no Overview widgets, Discover features, settings forms, Displays work, or group editor. ## ⚠️ One precedent change for review: v8 stays accepted beside v9 Every previous manifest bump (v1→…→v8) rejected its predecessor outright: "no dual acceptance", per ADRs 0006, 0014, 0021 and 0050. You asked for v8 compatibility, so **v8 and v9 are both accepted**. - `manifestVersion` selects one strict schema, via a discriminated union. - A document is only ever read as the version it declares. A v8 document that names `navigation` is refused, never upgraded. - No released manifest, package byte, or digest is rewritten. - v7 and earlier are still refused. - How long v8 stays accepted is left to a future ADR. v9 is the right version number: v8's top-level object is strict, so a new field is a version bump by the repository's own rule. ## Manifest v9 v9 is exactly v8 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): one of `MODULE_NAVIGATION_ICONS`, a curated set of 93 generic Lucide names. Omitted, the module gets the generic module icon. - **`pages`** (optional): at most 12, in declared order. - `id`: kebab-case, stable, unique. - `label`: trimmed, 1–60 characters, no control or line-separator characters. - `path`: 1–4 lowercase kebab-case segments relative to the module root. It can't contain a leading or trailing slash, an empty segment, `.` or `..`, percent-encoding, a query, a fragment or a scheme, and can't be a duplicate, sit beneath another page, or be the root. - `navigation` is only valid with `entrypoints.ui`. It is validated by discovery, `modules:validate`, the packager and package verification before activation. - **No page icons.** The collapsed sidebar only ever shows the module item, so page icons would never be seen. - **No settings declaration.** `configuration` already answers whether a module has settings (ADR 0061). - **No widgets or groups.** ## Icons - **Validated against the list.** Manifests are checked against the curated set, so typos are caught at pack and discovery time. - **Imported by name.** The shell imports exactly those icons from `lucide-react` (`browser/module-icon.tsx`); the full library of about 6,300 icons is never bundled. - **One list, held together by tests.** Surfaces can't import `@ekklesia/contracts` (ADR 0024), so the shell keeps the names in a framework-free subpath, `@ekklesia/shell/module-icons`. - An `apps/api` test holds that copy equal to the contracts list. - A shell test proves each entry is the real Lucide component. - **Fallback.** A name a host can't draw falls back to the generic icon. - **Decorative only.** Icons are always `aria-hidden`, with the name in the label or tooltip. - There is no module → icon table anywhere. ## Routes ```text /<id> landing view (never itself a declared page → no duplicate identities, no redirect) /<id>/<page> declared page /<id>/<page>/… anything beneath a page: module-owned detail routes, e.g. /media/sermons/123 anything else host "Page not found"; the module is not mounted ``` - Refresh, deep links, and Back/Forward all land on the same route. Moving between a module's own pages **never remounts** it, because the frame is keyed by module identity. - **v8 modules:** root only. Anything deeper is not-found, exactly as before. - **Disabled modules:** "unavailable" at the root and every subpath, and nothing mounts. - **Uninstalled modules:** not served (unchanged). - **New guard:** `api`, `assets` and `display` (`HOST_NAMESPACES`) are never pages at any depth. Without it, nested module routes would have let `/api/v1/principal` parse as a route of a module called `api`, and so be accepted as a return target. A test holds `DASHBOARD_PLATFORM_PAGES + HOST_NAMESPACES` equal to Core's `RESERVED_TOP_LEVEL_NAMES`. ## Sidebar - **v9:** the declared icon. Pages are nested beneath the module while it's open, using the same in-place expansion and indentation as Settings. - **Current item:** exactly one `aria-current="page"`. It's the open page, or the module itself on its landing view; a detail route marks its page. - **Collapsed:** pages are hidden and the module item is current, named by its tooltip and accessible name. - **Phone drawer:** the identical hierarchy with 48px targets, closing once a page is chosen. - **v8:** the generic icon and a single item. - **Disabled modules:** absent. - The server sends `navigation: { icon, pages }` beside each module's name in `/api/v1/dashboard/navigation`, never inside the host-neutral descriptor. ## SDK: `context.navigation` (`@ekklesia/module-ui-sdk`) ```ts interface ModuleUiNavigation { location(): { path: string; page: string | undefined }; subscribe(listener): () => void; navigate(path, { replace? }?): void; link(path): { href; onClick(event) }; // real <a>; modified/middle clicks left to the browser readonly settings: { open(); link() } | undefined; } ``` - **Relative paths only.** Paths are module-relative, and a leading `/` means the same as none. - **Refused paths throw** `ModuleUiNavigationError`, with a code and a message that never echoes the path: - malformed or escaping paths give `MODULE_NAVIGATION_PATH_INVALID`; - undeclared pages give `MODULE_NAVIGATION_PATH_UNKNOWN`. - **Cross-module navigation is impossible.** No history object, `window` access or router is exposed. - **Where it's present:** it's an optional member (`navigation?`), so an existing hand-built context compiles unchanged. The Dashboard supplies it, the display host omits it, and modules feature-detect it. v8 modules get navigation too, with only their root. - **No ABI change.** The mount ABI stays at 2; this is an additive context member. ## Return-to-origin: one carrier The return destination travels in a query parameter on the Settings URL: ```text /settings/signage?return=%2Fsignage%2Ftimelines ``` - `navigation.settings` builds it from the module's current location, so module authors never build or validate it. - **It survives a refresh, a new tab, and a canonical respelling.** `HostHistory` gains `search()` to support this. - **It is validated twice:** - `parseReturnTarget` accepts canonical same-origin Dashboard addresses only; - a module address must also name a running module and one of its declared routes. - Back reads "Back to Signage" and is a real link to the validated address. It never uses `history.back()`. An invalid target falls back to Settings. ## Public package changes (no version bumps, as the release process does those separately) - **`@ekklesia/contracts`:** the v8/v9 manifest union; `module-navigation.ts` (`MODULE_NAVIGATION_ICONS`, page and navigation schemas, `moduleManifestNavigation`); `MODULE_MANIFEST_VERSION` is now 9; `SUPPORTED_MODULE_MANIFEST_VERSIONS` is `[8, 9]`. - **`@ekklesia/module-ui-sdk`:** `navigation.ts` (types and `ModuleUiNavigationError`), plus an **optional** `ModuleUiHostContext.navigation?`. This is source-compatible: a context built before navigation existed still compiles unchanged, and a test proves it. Hosts without pages, including the display host, omit it, and modules feature-detect it. - **`@ekklesia/module-sdk`:** re-exports the navigation helpers and types. - **Unchanged:** `module-ui-host`, `module-pack` and `ui`. - **Private:** Core and the API publish and serve navigation. The shell adds routes, the sidebar, the capability, and the `./module-icons` subpath. The template module moves to v9 with two pages, a detail route, and a Settings button. ## Compatibility correction (`c78f131`) `navigation` was first a required member (`navigation: ModuleUiNavigation | undefined`), which would have broken any existing hand-built context at compile time. That isn't allowed for a 0.x patch-compatible change. It is now `navigation?: ModuleUiNavigation | undefined`: - **Old contexts compile.** A context without the member still satisfies `ModuleUiHostContext`. The `navigation: undefined` lines this PR had added to existing hosts and tests are reverted, so those files are byte-identical to `main` again. - **A test guards it.** A `module-ui-sdk` test type-checks an exact pre-navigation context with `satisfies`. Making the member required again fails 5 type checks, confirmed by mutation. - **Runtime and security are unchanged.** The display host now omits the member rather than setting it to `undefined`. - **After the change:** verify, package release and package publication all pass again. ## Tests - **Contracts:** - v9 single- and multi-page manifests, the icon and its fallback, invalid icons (unknown, PascalCase, markup, URL, object), and 18 unsafe path forms; - malformed ids and labels, duplicate ids and paths, nesting, the bound, and navigation without a UI; - deterministic order; exactly versions 8 and 9 accepted; - **the released Signage 0.1.5 manifest, byte-identical to the one inside the published package (SHA-256 checked)**, accepted as v8 with no navigation; a v8 document carrying `navigation` refused. - **Core:** discovery of the released v8 Signage module and a v9 module side by side, and refusal of an escaping page. - **API:** - the navigation view carries the v9 icon and pages, and `{icon:null, pages:[]}` for v8; - an unsafe v9 manifest fails discovery; - icon parity with the shell; host-namespace parity with Core's reserved names. - **Shell (`module-navigation.test.tsx`, 44 tests), with real artifacts mounted through the loader's Shadow DOM boundary:** - **routing:** root, declared page, detail route, and unknown pages (including near-misses); v8 root-only; disabled modules at every depth; - **history:** Back/Forward/refresh with one mount and no remount; focus moving on a page change; - **sidebar:** v9 icon, v8 generic icon, pages only while open, collapsed state, the drawer, and an unknown icon falling back; - **SDK:** navigate (add or replace), 9 refused paths with no leak, real links with plain vs modified clicks, every subscriber notified on host-driven moves; - **Settings:** module → Settings → Back to the exact page, refresh, respelling, and 9 unsafe return targets falling back. - **Also updated:** route and return-target tests (the host namespace, empty segments, deep routes); history query tests; SDK error tests; the template v9 manifest and page rendering through the real built artifact. ### Results - `TURBO_FORCE=true pnpm verify`: **passed**. contracts 286, core 1194, api 573, shell 470, module-ui-sdk 38, template 22, plus every package. - `pnpm test:package-release`: **passed**. - `pnpm test:package-publication`: **passed**. - `pnpm test:migrations:integration`: **passed**, 428 tests. - `pnpm infrastructure:config` and `pnpm deploy:config`: **passed**. - Deploy smoke, same-origin: **passed**. - Deploy smoke, split, with the v9 template, in real Chromium: **passed**, 397 checks. All five browser passes ran the new steps: - sidebar sub-navigation, and a page change without a remount; - a direct deep link, Back and Forward; - an undeclared page not found, and the detail route; - module → Settings via the capability, then Settings Back to the exact origin after a refresh; - an off-site return ignored, and the phone drawer. - Deploy smoke, split, with **the real released Signage 0.1.5 (manifest v8) package**: **passed**, 340 checks. It covers v8 behaviour (generic or own icon, no sub-navigation, not-found beneath the root), plus the display, pairing, kiosk and offline scenarios that guard Stage 0 safety. **Visual check** (headless Chromium 151): - light and dark at 1280px, focus visible on a nested page; - the collapsed sidebar; - a 390px phone drawer (no overflow, 48px targets); - the Settings return flow. This caught one bug: the nested pages weren't indented, fixed in `ae360ef`. ## Deferred - Migrating Signage to v9. - When v8 stops being accepted (its own ADR). - Overview widgets. - A module reporting not-found for its own detail routes. - Return targets for platform pages beyond Settings. - Navigation groups and their editor. - Growing the icon set. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
ekklesia-bot added 2 commits 2026-10-15 13:40:02 +00:00
Stage 1 PR 7 (ADR 0063). A module becomes a first-class part of the Dashboard.

- Manifest v9 = v8 plus optional `navigation`: a Lucide icon from the curated
  MODULE_NAVIGATION_ICONS set, and up to 12 primary pages (stable id, safe
  label, kebab-case path relative to the module root; no traversal, schemes,
  queries, duplicates, or nesting). Validated before activation.
- Version 8 stays accepted beside version 9, each read strictly as itself
  (discriminated union); no released manifest is rewritten. The template
  moves to v9; the released Signage 0.1.5 manifest is a test fixture.
- Real module routes: /<id> is the landing view, /<id>/<page> a declared
  page, anything beneath a page a module-owned detail route; anything else
  is not-found and mounts nothing. api/assets/display are never pages.
- Sidebar: declared icons, pages nested under an open module, one
  aria-current, collapsed and drawer behaviour; v8 modules unchanged.
- module-ui-sdk: ModuleUiHostContext.navigation (location, subscribe,
  navigate, link, settings), refusing anything outside the module's routes;
  undefined on the display host. Mount ABI unchanged.
- Return-to-origin: one `?return=` carrier on Settings URLs, validated by
  parseReturnTarget and against running modules' declared routes; Settings
  Back returns to exactly the originating module page, across refreshes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Keep module pages indented beneath their module in the sidebar
CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 44s
CI / package-release (pull_request) Successful in 52s
CI / migration-smoke (pull_request) Successful in 2m28s
CI / image-build (pull_request) Successful in 1m9s
CI / deploy-smoke (same-origin) (pull_request) Successful in 53s
CI / package-publication (pull_request) Successful in 55s
CI / deploy-smoke (split) (pull_request) Successful in 3m34s
ae360ef318
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Zayin_Admin added 1 commit 2026-10-15 14:14:47 +00:00
Make the module navigation capability an optional context member
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
c78f1318b4
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]>
Joey approved these changes 2026-10-15 14:32:45 +00:00
Joey merged commit 186668915b into main 2026-10-15 14:32:49 +00:00
Joey deleted branch feat/module-navigation-contract 2026-10-15 14:32:49 +00:00
Sign in to join this conversation.
No Reviewers
3 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Zayin/Ekklesia#110