Files
Ekklesia/docs/architecture/decisions/0061-settings-hierarchy-and-module-settings-routes.md
T
ekklesiaandClaude Opus 5.5 4671622b9d Add the module navigation contract: manifest v9, module routes, return-to-origin
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]>
2026-10-15 08:21:50 -05:00

11 KiB

0061: Settings is one host-owned hierarchy; module lifecycle is Discover, and a module's settings live at /settings/<module-id>

Context

DESIGN.md makes Settings the one home for shared administration (§9): church-wide configuration in Settings, personal configuration under the account area (§9.2), every module's settings reachable through Settings rather than an independent system inside the module (§9.3), and people without management permission not shown administration they cannot carry out (§9.2). ROADMAP §5.3 asks that the navigation and ownership model exist before every category is functional.

ADR 0057 reserved /settings and served the Dashboard page throughout it, but defined no page there. Three things had to be decided before any section could be built: the address of each page, how the Settings hierarchy sits in the shell's one sidebar (ADR 0058), and how the browser learns which Settings destinations a person may see — it knew only that they were signed in, plus each module's own permission hints (ADR 0026).

Decision

The URL model

/settings                           the Settings landing page
/settings/general                   General: how this installation works for everyone
/settings/branding                  Branding: the church's name, logo, and colours
/settings/displays                  Displays: paired screens
/settings/discover                  enters Discover at Browse (respelled in place, below)
/settings/discover/browse           Browse: find and install modules
/settings/discover/installed        Installed: see and manage installed modules
/settings/discover/updates          Updates: module and platform updates
/settings/system                    System: version, health, and diagnostics
/settings/<module-id>               that module's settings
/settings/<module-id>/…             reserved for a module's deeper settings pages

The distinction is the decision:

  • /settings/discover/… is module lifecycle — finding, installing, turning on and off, inspecting, updating, and eventually removing modules.
  • /settings/<module-id> is configuring that module. It is never nested under Discover or under a modules segment: a module's settings are a first-class Settings destination.

"Modules" is the product's word, in the interface as in the architecture — never "features", "apps", "extensions", or "add-ons". "Discover" is the name of a navigation destination, not a replacement for it: inside Discover the pages say "Discover modules", "Installed modules", and "Module updates". Implementation terms — Core, a platform module — never appear.

Entering Discover opens Browse, at one address. /settings/discover reads as Browse, whose canonical spelling is /settings/discover/browse; the shell respells it in place (replace, no new history entry), exactly as it corrects a trailing slash (ADR 0057). There is one meaningful Browse address rather than two, and Discover's own item in the navigation links to it.

/settings is a real landing page, never a redirect to the first section: the two groups the person may open, each item named and described in a line. It is an index, not a dashboard.

Anything deeper than these addresses is not-found in the shell until its page exists. The API already serves the page throughout /settings/*, and the proxies already forward it (ADR 0057), so deeper pages need no server or deployment change.

Module ids cannot collide with a Settings section

A module id is a stable URL identifier: its page is /<module-id> and its settings /settings/<module-id>. So the platform's own Settings section names join the top-level names in Core's one list, RESERVED_MODULE_IDS (core/src/modules/reserved-ids.ts), now in two documented groups:

Group Names
Top-level namespaces (/<name>) account, api, assets, display, settings
Settings sections (/settings/<name>) branding, discover, displays, general, system

Discovery refuses a module that declares any of them (MODULE_ID_RESERVED), at startup, in pnpm modules:validate, and in package verification. Look-alikes stay valid — general-info, discovery, display-board, systems — and so does modules, since module management is Discover. Discover's views live beneath /settings/discover/, so they cannot collide with a module and are not reserved. apps/api tests hold Core's Settings group equal to the shell's SETTINGS_SECTIONS, so a section cannot be added to one without the other.

Settings sits in the same sidebar

Settings is one item in the shell's one navigation (ADR 0058), after the modules and above the account area. While a Settings page is open it expands in place into two labelled groups — Platform (General, Branding, Displays, Discover, System) and Modules (each module's settings) — and inside Discover, Browse, Installed, and Updates beneath it. The rest of the navigation stays where it is, so leaving Settings is one click on any other item; it is not a second application with its own shell. The phone drawer shows the identical hierarchy. Exactly one item carries aria-current="page".

Which destinations a person sees is evaluated by the server

GET /api/v1/dashboard/settings (apps/api/src/dashboard-settings-routes.ts), readable by any signed-in person (core.modules.ui.read, like the navigation beside it), answers which sections and which modules' settings this person may see, through Core's one authorization evaluator:

Destination Shown to
Displays core.devices.manage
Discover core.modules.lifecycle.manage — deciding which modules run
a module's settings core.modules.configuration.manage (ADR 0029), for every module that declares configuration
General, Branding, System the platform owner only, until each defines its own permission

General, Branding, and System have no permission yet because nothing in them exists to authorize. Rather than invent three speculative permissions, they are shown only to the platform owner — the one principal who already holds every permission — and each gains a delegable permission in the work that builds it. Platform updates in Discover → Updates will likewise get their own permission then.

A module has settings when its manifest declares configuration (ADR 0029). That is the existing, authoritative signal; no manifest change is needed. A disabled or blocked module with settings is listed with available: false.

These are presentation hints, not authority, as ADR 0026's are. Every route behind a section keeps its own enforcement, unchanged, and a test proves the data behind each is still refused without its permission. An evaluation failure is a 503, never an empty answer that would look like a permissions problem. The browser decides nothing about permissions itself.

What each Settings address shows

  • A section or module the person may see shows its page.
  • A section the person may not see, or a module they know of but cannot configure (or that has no settings), shows These settings aren't available — without saying which.
  • A disabled module's settings show This module is turned off. Never a silent redirect.
  • A name that is no module the person knows of is Page not found.
  • When the Settings view cannot be loaded, the Settings link is withheld and its pages say so, with Retry; the rest of the Dashboard keeps working.
  • When a person can see nothing in Settings, the Settings link is not shown at all.

Pages are honest placeholders

Every section, Discover view, and module's settings page in this decision is a placeholder: Back to Settings (PageHeader, ADR 0059), a title and one-line description, and Not available yet with what will happen there. No fake controls, no dead fields. The work that builds each replaces its placeholder.

Back goes to the page's parent, Settings. A validated return target (ADR 0059) takes precedence once something carries one, so /signage → /settings/signage can return to /signage; how a module links into its settings with a return target is the module navigation contract's to define.

Alternatives

  • /settings/modules for module management, and /settings/modules/<module-id> for a module's settings. Rejected: it nests a module's configuration under its lifecycle and makes "configure Signage" two levels deep. Discover is lifecycle; /settings/<module-id> is configuration.
  • /settings/discover as a page of its own beside /settings/discover/browse. Rejected: two meaningful addresses for one view.
  • Redirecting /settings to the first section. Rejected: a landing page says what Settings holds, and the first section differs per person.
  • Replacing the module navigation with the Settings hierarchy while in Settings. Rejected: DESIGN.md keeps one sidebar with Settings in it (§4.2), and leaving Settings should not require a second navigation model.
  • Probing each section's API for a 403 from the browser. Rejected: several requests per page view, and it confuses "may not" with "failed".
  • Defining General, Branding, and System permissions now. Rejected as speculative; the owner-only interim rule is deny-by-default with the owner's existing bypass.
  • A manifest field declaring "has settings". Unnecessary: declared configuration already says so; module-owned settings pages arrive with the module navigation contract.
  • Reserving Discover's view names. Unnecessary: they live under /settings/discover/.

Consequences

  • Every later Settings section has an address, a navigation entry, a permission rule, and a placeholder to replace.
  • A module may not be called branding, discover, displays, general, or system. No known module is; one that was would fail discovery and must be renamed.
  • The shell asks one more endpoint at bootstrap, alongside the navigation.
  • A person without any administrative permission sees no Settings link.

Deferred

The real General, Branding, Displays, Discover (catalog, installation, lifecycle, updates), and System pages, and each one's permission; module settings screens and their deeper pages; how a module links to its settings with a validated return target (the module navigation contract, manifest v9); moving the transitional device panel into Settings → Displays; and the account area.