CI / verify (pull_request) Successful in 1m7s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 49s
CI / migration-smoke (pull_request) Successful in 2m17s
CI / image-build (pull_request) Successful in 1m10s
CI / deploy-smoke (same-origin) (pull_request) Successful in 45s
CI / package-publication (pull_request) Successful in 57s
CI / deploy-smoke (split) (pull_request) Successful in 2m37s
The Dashboard had one address. The API served the page only at `/`, both reference proxies forwarded only `/`, and the open module lived in React state, so a refresh went back to the first module, nothing could be bookmarked, and Back left the Dashboard (DESIGN.md §2.7). ADR 0057 makes the address bar the source of truth: - `/` is a host-owned Overview, now the landing page; `/modules/<id>` mounts that module. An id the server's navigation does not list shows "This page isn't available" and loads nothing; any other Dashboard address is a host-owned "Page not found". - `/modules`, `/settings`, and `/account` are reserved whole, from one list (`@ekklesia/shell/dashboard-routes`) that the shell's router and the API's page routes both read. The API serves the same frozen page, ETag, and security headers anywhere inside them; look-alikes and other methods stay the sanitized 404. - A small host-owned history replaces the selected-module state, built in the entry point so it stays the only file touching `window`. Navigation items are real links; modified clicks are left to the browser. Focus moves to the page after an in-place change, and the tab is named after the page. - Sign-in still happens in place with no return value (ADR 0034), so a deep link opens its own page after sign-in. - Both reference Caddyfiles forward the new paths; the deploy smoke now reloads a module's address through the real proxy and requires it to mount again. Co-Authored-By: Claude Opus 5.5 <[email protected]>
273 lines
16 KiB
Markdown
273 lines
16 KiB
Markdown
# 0027: Dashboard navigation is presentation configuration, and modules stay flat
|
||
|
||
- Status: Accepted
|
||
- Date: 2026-08-22
|
||
- Extends: [0021](0021-modules-as-hostable-products.md),
|
||
[0026](0026-minimal-ekklesia-module-ui-host.md)
|
||
|
||
## Context
|
||
|
||
ADR 0026 gave Ekklesia a browser shell that mounts discovered module UI. Its navigation was a flat
|
||
list of **module ids** — `livestream`, `announcements` — which is not a usable long-term experience,
|
||
and it had no notion of organizing anything.
|
||
|
||
The obvious next step carries an equally obvious trap. A church wants to see:
|
||
|
||
```text
|
||
Media Planning Communication
|
||
├── Livestream ├── Calendar └── Announcements
|
||
└── Video └── Roster
|
||
```
|
||
|
||
and the tempting way to build that is to let a module say which folder it belongs to, or worse, to
|
||
model "Media" as something Livestream and Video belong *to*. Either turns a rendering preference
|
||
into architecture: a folder becomes an owner, membership starts to look like a dependency, and the
|
||
flat, independently versioned module graph ADR 0021 established quietly acquires a hierarchy nobody
|
||
decided to add.
|
||
|
||
Two facts from the repository shaped what was actually needed.
|
||
|
||
**A display name already exists.** `moduleManifestSchema` has required `name` (trimmed, 1–120
|
||
characters) since version 1, and every fixture and the template already set it — `"Example Module"`.
|
||
Nothing needed adding to the manifest to stop rendering raw ids.
|
||
|
||
**Nothing else exists.** There is no settings or configuration table, no key-value store, no
|
||
organization or tenant model, and no client-side routing anywhere in the repository. Whatever stores
|
||
an arrangement had to be built, and could not be scoped to an organization that does not exist.
|
||
|
||
## Decision
|
||
|
||
### Grouping is presentation configuration. It is never module structure.
|
||
|
||
A Dashboard group is a label on a rendering bucket. It owns no tables, no migrations, no routes, and
|
||
no lifecycle; it has no manifest, no version, and no artifact; nothing depends on it and it depends
|
||
on nothing. **It is not a module**, and "Media" existing does not make Livestream and Video related
|
||
in any way the platform can observe.
|
||
|
||
The distinction the architecture has to keep sharp:
|
||
|
||
- *Livestream depends on Video because it calls Video's functionality* — a real technical
|
||
dependency, declared through the existing module dependency contract, which orders discovery and
|
||
startup.
|
||
- *Livestream is shown under Media* — a rendering preference an administrator can change at any time,
|
||
which orders nothing.
|
||
|
||
Nothing in this pass introduces a parent module, a child module, an owning module, a container
|
||
module, or a group-derived dependency edge. The words do not appear in the contracts, and the
|
||
absence is enforced by tests rather than left to discipline (see **Consequences**).
|
||
|
||
### No manifest change, and no version 5
|
||
|
||
`manifest.name` is the display name. It is the module's **product identity** — what the product is
|
||
called — and a module is exactly the right owner of that. Adding a `presentation.name` beside it
|
||
would have created two names for one thing and forced `manifestVersion: 5`, a hard cutover for every
|
||
installed module (ADR 0006's convention: each version rejects all others) in exchange for nothing.
|
||
|
||
`PublishableModuleUi` gained `name`, carried from the manifest through the existing publication
|
||
registry. That is a Core-internal record, not a manifest change.
|
||
|
||
### `suggestedGroup` is deferred, deliberately
|
||
|
||
A module suggesting a default folder is a reasonable idea and was rejected **for this pass** on three
|
||
grounds, in order of weight.
|
||
|
||
It would force `manifestVersion: 5` — the most disruptive change available — for a *hint*. Its only
|
||
correct semantics ("used when a placement is first materialized, never re-applied over an
|
||
administrator's decision") requires configuration-initialization logic that writes rows on first
|
||
sight of a module; without that machinery the field would be either inert or authoritative, and
|
||
authoritative is the outcome this ADR exists to prevent. And it is not needed: an unplaced module is
|
||
simply ungrouped, which is deterministic, requires no write at all, and is strictly simpler than
|
||
initializing state.
|
||
|
||
Shipping the field now with semantics we could not yet honour would be adding a misleading contract
|
||
because we know we want the idea eventually. When there is a reason to bump the manifest version,
|
||
this can travel with it.
|
||
|
||
### Platform-owned, server-side, platform-global persistence
|
||
|
||
Two Core tables (`core/src/dashboard/schema.ts`), following the `ekklesia_core_*` conventions
|
||
exactly, in migration `0003_dashboard_navigation_tables`:
|
||
|
||
- `ekklesia_core_dashboard_group` — `id` (opaque stable key), `label`, `sort_order`.
|
||
- `ekklesia_core_dashboard_placement` — `module_id` (primary key: at most one placement per module),
|
||
nullable `group_id`, `sort_order`.
|
||
|
||
A church configures its Dashboard, not one browser, so this is server state rather than
|
||
`localStorage`. It is **platform-global**, because Ekklesia has no organization or tenant model;
|
||
inventing a second notion of organization to anticipate one would be worse than adding a scope column
|
||
when a real one exists. That limitation is recorded rather than hidden.
|
||
|
||
Three schema decisions carry invariants rather than convenience:
|
||
|
||
`group_id` is `on delete set null`. Deleting a group cannot leave a dangling reference and cannot
|
||
lose a module — its modules become ungrouped, enforced by the database rather than by application
|
||
cleanup that could be skipped.
|
||
|
||
`module_id` carries **no foreign key**, because modules are packages discovered on disk, not rows. A
|
||
placement for an uninstalled module is therefore normal and is retained, so uninstall-and-reinstall
|
||
preserves an arrangement instead of silently resetting it.
|
||
|
||
The group `id` is opaque and never re-derived from the label. Renaming "Media" to "Sunday Production"
|
||
changes `label` alone, so every placement survives — a rename can never re-organize the Dashboard.
|
||
|
||
### The resolution rules, stated exactly
|
||
|
||
`resolveDashboardNavigation` is a pure function of stored configuration and the currently mountable
|
||
module set. Every rule is directly testable without PostgreSQL:
|
||
|
||
- **A module with no placement is ungrouped and visible.** Installing a module is sufficient to see
|
||
it: no initialization write, no manual database edit, no migration. This is the rule that stops a
|
||
newly installed module from disappearing.
|
||
- **A stored placement for an absent module renders nothing and is retained.** No dead entry; no lost
|
||
position on reinstall.
|
||
- **A placement naming a missing group is treated as ungrouped**, not dropped. `on delete set null`
|
||
makes this unreachable in practice; a configuration inconsistency must still never make a module
|
||
invisible.
|
||
- **Empty groups are kept.** Hiding one would look like it had been deleted.
|
||
- **Ordering is total.** Groups by `(order, id)`; modules by `(configured, order, id)` — placed
|
||
modules in the administrator's order, then never-configured ones by id, so a newcomer never
|
||
interrupts an ordering someone chose. Colliding order values can never produce a
|
||
nondeterministic result; the identifier is always the final tie-break.
|
||
|
||
Simple integers, not fractional ordering or CRDTs. Reordering rewrites the values it needs to.
|
||
|
||
### Navigation is layered on the inventory, not merged into it
|
||
|
||
`GET /api/v1/module-ui` (ADR 0026) still answers *what is mountable*, host-neutrally, and is
|
||
unchanged — no display name, no group, no order. A standalone host consumes it and never learns
|
||
Ekklesia has folders.
|
||
|
||
`GET /api/v1/dashboard/navigation` answers *how this Dashboard presents those modules*, and
|
||
**embeds** each generic descriptor untouched inside a presentation wrapper:
|
||
|
||
```json
|
||
{ "groups": [ { "id": "media", "label": "Media", "order": 10,
|
||
"modules": [ { "name": "Livestream", "module": { "id": "livestream", "…": "…" } } ] } ],
|
||
"ungrouped": [] }
|
||
```
|
||
|
||
Embedding rather than flattening is the layering made visible in the payload, and both endpoints
|
||
compose the descriptor through one shared function, so they cannot disagree about what exists or
|
||
about any URL. The server is authoritative for the resolved view: the browser does no grouping, no
|
||
sorting, and no joining, and reads no manifest.
|
||
|
||
### Reading versus organizing
|
||
|
||
Reading requires `core.modules.ui.read` — the same permission the inventory requires, because this
|
||
response *contains* that inventory and serves the same audience: any signed-in user needs it to see
|
||
anything at all. A second read permission for the same data would be ceremony.
|
||
|
||
Organizing requires a new **platform** permission, `core.dashboard.navigation.manage`
|
||
(baseline `restricted`), never a module's — grouping is Ekklesia's configuration, so no module may
|
||
gate, grant, or influence it. Mutations additionally require an exact trusted `Origin` and a bounded
|
||
body before authorization work begins, exactly like every other Core mutation.
|
||
|
||
Holding it grants no module authority. Moving a module into a folder changes a presentation row; it
|
||
grants nobody access to that module's data, and every module route stays enforced against its own
|
||
declared permission.
|
||
|
||
### A narrow, structured mutation contract — no configuration blob
|
||
|
||
Three idempotent operations, each with closed validation that rejects any unexpected key rather than
|
||
dropping it:
|
||
|
||
```text
|
||
PUT /api/v1/dashboard/navigation/groups/:groupId { label, order }
|
||
DELETE /api/v1/dashboard/navigation/groups/:groupId
|
||
PUT /api/v1/dashboard/navigation/placements/:moduleId { groupId | null, order }
|
||
```
|
||
|
||
`PUT` against a client-chosen stable id makes create and rename the same operation, so a rename can
|
||
never orphan a placement. A "save the whole dashboard config" blob was rejected: it has no
|
||
invariants, no per-operation authority, and no way to reject a field nobody meant to send.
|
||
|
||
No editor UI ships in this pass. The persistence model is proven through the real API instead, which
|
||
is the repository's normal pattern.
|
||
|
||
### One level of grouping
|
||
|
||
`Group → Modules`. No nesting, no subgroups. The schema has no parent-group column, so the shape
|
||
itself forecloses it rather than a rule asking developers not to. Nested navigation can be
|
||
reconsidered when a real use case appears.
|
||
|
||
## Alternatives
|
||
|
||
- **A `presentation.name` manifest field.** Rejected: `manifest.name` already is the display name,
|
||
and adding a second would mean two names for one product and a version 5 cutover for nothing.
|
||
- **`suggestedGroup` now.** Deferred; see above. It buys a default at the cost of a manifest version
|
||
and initialization machinery, and risks becoming authoritative.
|
||
- **A module declaring its own folder authoritatively.** Rejected outright — it is the mistake this
|
||
ADR exists to prevent. An administrator must be able to move a module without touching it.
|
||
- **Re-applying a module's suggestion on every startup.** Rejected for the same reason: defaulting
|
||
and ownership are different, and a suggestion that overwrites a decision is ownership.
|
||
- **Modelling a group as a module.** Rejected: it would give a rendering bucket a manifest, a
|
||
version, a lifecycle, and a place in the dependency graph, and would make "Livestream belongs under
|
||
Media" indistinguishable from "Livestream depends on Media."
|
||
- **Browser `localStorage`.** Rejected: a church configures its Dashboard, not one browser on one
|
||
device.
|
||
- **A generalized platform settings framework.** Rejected: two purpose-built tables with real
|
||
constraints beat a key-value blob whose invariants live only in application code, and nothing else
|
||
needs settings yet.
|
||
- **Extending the module UI inventory with groups.** Rejected: it would push Dashboard semantics into
|
||
the host-neutral contract a standalone host is meant to consume.
|
||
- **Making the browser join inventory and configuration.** Rejected: it would put grouping logic —
|
||
and therefore the ordering rules — in the client, where a second implementation could drift.
|
||
- **Deleting placements for uninstalled modules.** Rejected: retaining them makes reinstall restore
|
||
an arrangement, at the cost of a few harmless rows.
|
||
- **Hiding empty groups.** Rejected: indistinguishable from deletion, from the administrator's side.
|
||
- **Fractional or CRDT ordering.** Rejected as unjustified for a list a person arranges by hand.
|
||
- **Nested folders, icons, drag and drop, a navigation editor, client routing.** All deferred; each
|
||
needs a design decision this pass should not make by accident. Icons in particular would
|
||
immediately require an icon vocabulary, an asset-publication story, and a trust decision about
|
||
arbitrary SVG.
|
||
|
||
## Consequences
|
||
|
||
Ekklesia has a real Dashboard navigation model: persistent, platform-owned, administrator-editable
|
||
through a structured API, rendered by the shell, mounting flat modules through the unchanged generic
|
||
loader. A module now shows as "Livestream" rather than `livestream`.
|
||
|
||
**The flatness invariant is executable, not aspirational.** `core/tests/dashboard-flat-modules.test.ts`
|
||
discovers real fixtures with a genuine dependency (Livestream depends on Video), then arranges them
|
||
four ways — including both modules under one "Media" folder, and the dependency deliberately inverted
|
||
in presentation — and asserts that discovery order, dependency declarations, capabilities,
|
||
permissions, and the module set come out byte-identical every time, and that no group id is ever a
|
||
module. A future change that let grouping influence any of that fails there.
|
||
|
||
The end-to-end test goes further: it organizes a real installation through the real mutation API,
|
||
bootstraps the real shell code against it, mounts a module from inside a group, moves that module to
|
||
a different group, and asserts the navigation entry is **byte-identical** across the move — same
|
||
artifact URL, same API root, same permissions, same version, same name — and still mounts and reaches
|
||
the same API.
|
||
|
||
Four things changed outside the new code. `createApiApp` takes a required `dashboard` store.
|
||
`PublishableModuleUi` carries `name`. Core has a third permission, which moved five existing
|
||
assertions (each updated to name all three permissions rather than to loosen a count). And the shell's
|
||
bootstrap now resolves navigation instead of the raw inventory, which made its client for
|
||
`GET /api/v1/module-ui` dead code; that client was **removed** rather than kept speculatively, and
|
||
its descriptor-validation coverage moved onto the navigation parser. The endpoint itself is unchanged
|
||
and remains fully covered by `apps/api`'s tests.
|
||
|
||
Two costs worth naming. Navigation configuration is platform-global and will need a scope column when
|
||
an organization model exists. And the navigation endpoint evaluates every listed module's declared
|
||
permissions per request, inheriting ADR 0026's per-request evaluation cost; for a handful of modules
|
||
that is unremarkable, and it is measured work rather than hidden.
|
||
|
||
## Deferred
|
||
|
||
> **Cross-reference — see [ADR 0057](0057-dashboard-urls-are-host-owned-routes.md).** Client-side
|
||
> routing and deep links to a selected module, deferred below, have landed; the rest of this list is
|
||
> unchanged.
|
||
|
||
Not implemented or promised here: the navigation **editor** — drag and drop, a management UI, and any
|
||
administrator-facing affordance beyond the API; nested folders and subgroups; icons, colors, badges,
|
||
and module descriptions in navigation; `suggestedGroup` and any configuration-initialization or
|
||
defaulting logic; per-module administrator label overrides; Dashboard Home, widgets, summaries, and
|
||
quick actions; permission-aware navigation visibility (whether to hide a module a viewer can do
|
||
nothing in remains an open UX policy); client-side routing and deep links to a selected module;
|
||
organization or tenant scoping; a design system, theme tokens, and CSS asset publication; and every
|
||
real feature module.
|
||
|
||
The permission-id typing gap carried since ADR 0022 is unchanged: `permissions.has` takes a plain
|
||
string, and the wire arrays are `string[]`, so a typo still silently hides a control.
|