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]>
16 KiB
0027: Dashboard navigation is presentation configuration, and modules stay flat
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:
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), nullablegroup_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 nullmakes 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:
{ "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:
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.namemanifest field. Rejected:manifest.namealready is the display name, and adding a second would mean two names for one product and a version 5 cutover for nothing. suggestedGroupnow. 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. 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.