Files
Ekklesia/docs/architecture/decisions/0061-settings-hierarchy-and-module-settings-routes.md
T
ekklesiaandClaude Opus 5.5 89ef4b5ff6
CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / package-release (pull_request) Successful in 51s
CI / migration-smoke (pull_request) Successful in 2m29s
CI / image-build (pull_request) Successful in 1m4s
CI / deploy-smoke (same-origin) (pull_request) Successful in 54s
CI / package-publication (pull_request) Successful in 58s
CI / deploy-smoke (split) (pull_request) Successful in 2m56s
feat(shell): Settings hierarchy, Discover, and /settings/<module-id>
The Settings foundation (ADR 0061):

- URL model. /settings is a real landing page. The platform sections
  are /settings/general, /branding, /displays, and /system. Module
  lifecycle is Discover: /settings/discover/browse, /installed, and
  /updates. /settings/discover is respelled in place to Browse's one
  canonical address. A module's settings are /settings/<module-id>,
  never nested under Discover. Deeper Settings addresses are
  reserved. "Modules" stays the user-facing word: "Discover modules",
  "Installed modules", "Module updates".
- Collisions. Core's one reserved list gains the Settings section
  names (branding, discover, displays, general, system) as a second
  documented group, so no module id can shadow a section; `modules` is
  an ordinary id. apps/api tests hold it equal to the shell's sections.
- Navigation. One Settings item in the same sidebar expands in place
  into Platform and Modules (and Discover's three views) while in
  Settings, and the phone drawer has the same hierarchy. The rest of
  the navigation stays.
- Permissions. GET /api/v1/dashboard/settings evaluates, through
  Core's evaluator, which destinations the viewer may see:
  - Displays: core.devices.manage
  - Discover: core.modules.lifecycle.manage
  - a module's settings: core.modules.configuration.manage, for each
    module that declares configuration (ADR 0029)
  - General, Branding, System: the platform owner, until each has its
    own permission
  These are presentation hints; every data route keeps its own
  enforcement. With nothing visible, there is no Settings link.
- Pages are honest placeholders with Back to Settings. A disabled
  module's settings say it is turned off; a section or module the
  person cannot see says so without detail; an unknown name is not
  found; a failed load withholds the link and offers Retry.
- The deploy smoke opens Settings by deep link, refreshes a section,
  enters Discover, opens the module's settings, uses the phone
  drawer, and returns to the Dashboard.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:22:50 -05:00

178 lines
11 KiB
Markdown

# 0061: Settings is one host-owned hierarchy; module lifecycle is Discover, and a module's settings live at `/settings/<module-id>`
- Status: Accepted
- Date: 2026-10-15
- Extends: [0027](0027-dashboard-navigation-and-flat-modules.md),
[0028](0028-module-operational-lifecycle.md),
[0029](0029-module-configuration-and-secrets.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)
## 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
```text
/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.