docs: record the five Phase 8 prerequisites, and report contributions

Bring the living documentation in line with what the five passes actually
shipped, and correct the claims they superseded rather than leaving them to
mislead: the module UI context is six members, not five; module styling is
compiled CSS, not inline objects only; the strict manifest is version 7.

Also close a silent failure the new contribution mechanism introduces. A
contribution whose target capability nobody provides — or whose provider moved
outside its declared range — is not published, and the manifest is valid and
the artifact exists, so an operator sees a screen that renders nothing with no
error anywhere. `pnpm modules:validate` now lists each declared contribution
with its capability and range and says whether a compatible provider is
installed, and Doctor's discovery check reports the same per module. Both
derive from the publication registry the API serves from, so neither can
disagree with what a running platform would actually publish.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-08-27 16:16:01 -05:00
co-authored by Claude Opus 5
parent ca3f37d5f9
commit 20d5128bad
10 changed files with 375 additions and 11 deletions
+81 -2
View File
@@ -228,7 +228,8 @@ takes a DOM element and returns an unmount function, so a module's framework and
independent.
The contract lives in `@ekklesia/module-ui-sdk`, a browser-safe shared package with no runtime
dependencies, consumed by module authors and hosts alike. Its host context is five members —
dependencies, consumed by module authors and hosts alike. Its host context was five members at that
checkpoint and is six today (ADR 0041) —
`moduleId`, `api`, `principal`, `permissions`, and `realtime`. Module UI reaches its own backend through
`api.request(path)` with a path relative to the module's own API root, never by constructing
Ekklesia's `/api/v1/modules/<module-id>` prefix, which is what keeps a standalone host possible.
@@ -244,7 +245,8 @@ it, and hosts perform their own dynamic import.
`src/ui/index.tsx` into the browser ESM artifact its manifest declares, and puts every bundler
option in one `tooling` package so module authors write no build configuration. React is bundled
rather than externalized, keeping `mount()` the ABI and a module artifact hostable by any host;
styling is inline style objects only, and a stylesheet import fails the build rather than emitting an
styling was inline style objects only at that checkpoint and is real compiled CSS today (ADR 0040),
and a stylesheet import then failed the build rather than emitting an
asset nothing declares. Building remains an authoring step — Core never builds, and discovery still
only validates the artifact a module shipped. At that checkpoint the Dashboard shell and UI runtime
were still future work; later Phase 6 passes supplied them. A design system, stylesheet loading
@@ -573,3 +575,80 @@ An authenticated result preserves the response, session, and mounted UI. Display
remains deferred and the display bundle gains no Dashboard login. Public Website routing,
production Dashboard asset hosting/DNS/TLS/proxy/CSP, enrollment and recovery, shared throttling,
and the missing production first-user creation path are also outside this pass.
**Phase 8 supplies the five platform prerequisites the first production feature module needs.** None
of it is Signage, and none of it names Signage: each capability is generic, and each is exercised by
something other than a feature module.
[ADR 0039](decisions/0039-shared-design-tokens-and-elements.md) adds the design layer the platform
never had. `@ekklesia/design-tokens` is one validated, enumerable registry of **semantic** tokens —
`background.raised`, `text.danger`, `state.hover-background` — where a token's category and
`--ekklesia-*` identifier are *derived* from its name and every value must be a bounded literal, so a
registry that constructs is one that can be emitted as CSS without escaping. That validation is
exactly what makes a future theme editor implementable rather than aspirational: the values it would
edit are already labelled, described, categorised, and enumerable. `@ekklesia/ui` is the small set of
elements a real module dashboard cannot be built without, every value a token reference, with real
`:focus-visible` treatment and status that never depends on colour alone. `pnpm boundaries` opens
exactly two shared roles to modules **by name**, and pins both design packages beneath everything
else. The Dashboard and display install the defaults at their document root; custom properties
inherit into every module artifact, so a module reads `var(--ekklesia-…)` with nothing to import at
run time and nothing for a host to forget to pass.
[ADR 0040](decisions/0040-module-stylesheets-and-artifact-isolation.md) reverses ADR 0023's
inline-styles-only rule, and supplies the three things that ADR said were missing. A module imports
plain `.css`; the canonical builder compiles it **into** the declared artifact as text, so there is
still exactly one file per role and no sidecar can exist — the failure ADR 0023 was protecting
against. Two rules are enforced with their own codes: a module stylesheet may reference
`--ekklesia-*` and never declare one, and every `@import` must resolve locally. The generic loader
then mounts **every** browser artifact inside its own open Shadow DOM root with its own stylesheet
installed, unconditionally, because a conditional isolation boundary is one a module can fall out of
by editing a source file. That root is a correctness boundary for *styles* and is never described as
a sandbox: it is `open` precisely because module code is trusted, same-realm code that could reach
`document` anyway. The browser mount ABI is versioned (`MODULE_UI_ABI_VERSION`, stamped by
`defineModuleUi`) with no dual acceptance; migration is one `pnpm build`.
[ADR 0041](decisions/0041-module-browser-contributions.md) lets one module contribute a browser
renderer to a capability another module provides — the browser half of the capability system Core has
had since ADR 0006. `manifestVersion: 7` declares a namespaced id, its own version, a target
capability, a compatible range, and one artifact. That is the complete list: there is no payload
schema, slot, ordering, or rendering hint anywhere in the platform, so Core cannot learn what a
"slide" is because the declaration cannot express it. Discovery resolves the artifact through the
identical loop every other compiled entrypoint uses; publication requires that *another* eligible
module provides the capability within the declared range, and a contribution that fails is absent
rather than broken. A consumer discovers through `ModuleUiHostContext.contributions` and mounts by
id — never by building a publication URL — and can only see contributions aimed at a capability it
itself provides. The input is opaque JSON validated for serializability and nothing else, so the
domain contract stays entirely between the two modules. A contributed renderer receives its own id
and that input: no API client, realtime, principal, configuration, database, storage, or credential,
and loading one grants its module nothing.
[ADR 0042](decisions/0042-bounded-streaming-request-bodies.md) closes the write path ADR 0031 left
open. ADR 0030 gave modules a bucket and ADR 0031 took Ekklesia out of the *read* path, but declined
signed write URLs — so bytes reach a bucket only through a module's own route, which was capped at
1 MiB. A route now declares `bodyMode`: `buffered` (the default, and what every existing route
meant) keeps that ceiling and materializes the body before the handler runs; `streamed` raises it to
32 MiB and counts bytes as the handler consumes them, holding nothing. The mode is explicit rather
than inferred from the size, because inferring it would silently change how an author's own handler
receives its body. 32 MiB is also the platform's absolute ceiling in every mode, sized for one
25 MiB image plus bounded overhead, and the unit is deliberately one file per request. The platform's
own limiter replaces the buffering dependency: a declared oversize is refused before authorization,
the buffered path now counts what actually arrived rather than trusting a header, and a streamed
overflow errors the stream, cancels the source, and replaces the response with the same sanitized
`413` even if the handler swallowed the error.
[ADR 0043](decisions/0043-display-offline-code-fallback.md) gives the display host-owned offline
resilience, scoped precisely to **the shell and the declared code**. A service worker scoped to
`/display/` caches the page, the host script, the module's display artifact, and every contributed
renderer that display mounted — and never the descriptor, `/api/v1/principal`, any API response, the
realtime stream, a pairing exchange, or any cross-origin request, which is what a signed
object-delivery URL is. The consequence is stated rather than papered over: a display cannot render
module content while offline, because mounting needs an authorization-bearing descriptor that is
never cached, so a revoked device stays revoked and cached code can never manufacture current
authorization. What it buys is that a kiosk rebooting mid-outage boots into Ekklesia's own offline
state and recovers by itself, and that reconnecting mounts with nothing left to download. Caching is
network-first everywhere, so a screen can never be trapped on obsolete code.
Still deliberately absent after Phase 8: a theme editor, a brand builder, or any second theme; a
module store or installer; a standalone host; signed write URLs, resumable uploads, or any body above
the absolute ceiling; module-owned offline *content*; and every real feature module, Signage
included. Nothing in this pass claims any of them exists.
+1 -1
View File
@@ -1232,7 +1232,7 @@ its role-specific URL only from the protected descriptor below.
## Module display host
Manifest version 6 adds one independently optional display declaration
The manifest carries one independently optional display declaration
([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)):
```json
+3 -2
View File
@@ -137,7 +137,8 @@ enforces those permissions while the module is disabled, because it mounts no ro
## Module permission composition
A module manifest's `permissions` array (`manifestVersion: 4`,
A module manifest's `permissions` array (introduced in `manifestVersion: 4`; the current strict
manifest is version 7 — see
[Module development](../module-development/README.md#canonical-manifest)) declares permissions in
the exact same `PermissionDeclaration` shape — but declaring one there only proves it is
well-formed and correctly namespaced under the module's own id; it does not, by itself, register
@@ -667,7 +668,7 @@ module database usage), and exactly one `pool.end()` call regardless of how many
- `packages/contracts/tests/authorization.test.ts` — `PermissionDeclaration` shape validation:
every baseline, unknown-property/missing-baseline/invalid-baseline/blank-or-oversized-description
rejection, malformed permission id rejection.
- `packages/contracts/tests/module-manifest.test.ts` — `manifestVersion: 4` acceptance and version
- `packages/contracts/tests/module-manifest.test.ts` — exact-version acceptance and version
2/string-array rejection, per-declaration validation (mirroring the contracts-level cases above),
module-namespace enforcement, duplicate permission id at its exact `permissions[index].id` issue
path, and default-empty-list isolation across separate parses.
+6 -2
View File
@@ -37,7 +37,10 @@ the root verification command discovers them automatically.
`pnpm modules:validate` builds the workspace, then scans `modules/*` without a central registry. A
missing or empty modules directory is valid and reports zero installed modules. Installed modules
must produce the compiled JavaScript entrypoints declared by their static manifests. Optional
must produce the compiled JavaScript entrypoints declared by their static manifests. Each module's
declared browser contributions are listed with their target capability and range, and marked
according to whether a compatible provider is installed — a contribution nobody provides is otherwise
silent, since the manifest is valid and the artifact exists while nothing renders (ADR 0041). Optional
schema entrypoints are loaded and validated for table ownership, namespacing, duplicates, and
foreign-key isolation; this command does not connect to PostgreSQL or run migrations.
@@ -82,7 +85,8 @@ not a broken deployment).
Doctor reports a fixed, deterministic set of checks:
- The active Node.js version against the repository's declared engine range.
- Static module discovery and dependency-first ordering.
- Static module discovery and dependency-first ordering. A module that declares browser
contributions also reports how many of them have a compatible provider among the installed set.
- Schema discovery, including validated module and table counts. This imports compiled schema
definitions, which the module contract requires to perform no configuration reads, I/O, or
database connection.
+1 -1
View File
@@ -201,7 +201,7 @@ separate deployment and threat-model decision.
### A display page is authorized; its artifact bytes are not authority
Manifest version 6 binds a display artifact to one permission declared by the same module
The manifest binds a display artifact to one permission declared by the same module
([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)). The declaration rejects
a `public` baseline: the current display host requires an ordinary authenticated cookie principal,
and public/device identity is not approximated with an anonymous exception. Both the stable
+71
View File
@@ -69,6 +69,77 @@ protocol-relative, backslash-prefixed, and scheme-bearing values cannot become n
Modularity checks keep authentication out of the module UI SDK/context and Dashboard login code out
of the separate display bundle.
Phase 8 adds evidence for the five platform prerequisites.
**Design tokens and shared elements** (ADR 0039). The token registry's tests prove it is
deterministic and enumerable, that every token derives a unique `--ekklesia-*` identifier from its
name and carries a label, description, and category, that each status family pairs a colour with a
non-colour symbol, and that a duplicate name, an unknown category, an invalid or single-segment name,
and a value carrying a declaration terminator, a rule block, a comment, an at-rule, `url(`,
`!important`, an escape, a control character, or markup are each rejected — with an error that never
echoes the rejected declaration. The elements' tests assert accessible structure (label/hint/error
association, icon-button naming, tab roles and arrow-key movement, announced error and loading
states), that status never depends on colour alone, that a dialog renders in place rather than
portalling out of an isolated root, and — mechanically — that the stylesheet and every component
source contain no literal colour, font stack, or theme value and reference only registered tokens.
Both packages compile module-shaped source under the exact browser project a module uses, where
`process.env` must fail to typecheck, so the browser boundary is measured rather than asserted.
**Module stylesheets and artifact isolation** (ADR 0040). The builder's tests prove a plain
stylesheet is compiled into the declared artifact with no `.css` emitted beside it, that a local
`@import` is inlined, and that an unresolved `@import`, a stylesheet declaring an `--ekklesia-*`
variable, and a non-CSS dialect are each rejected with their own sanitized code — while a private
alias derived from a registered token is accepted. The loader's tests prove every artifact mounts
inside an open shadow root, that the artifact receives that root rather than the host's element, that
exactly one `<style>` is installed per mount and never accumulates across remounts, that the host's
base rules precede the artifact's so a display can override `:host`, that unmount removes the whole
root even when the module's cleanup throws, that a failed mount leaves neither DOM nor styles behind,
that an environment without Shadow DOM fails closed rather than mounting unisolated, and that an
artifact built against the previous ABI is refused with `MODULE_UI_ABI_UNSUPPORTED`. The template's
suite builds real React artifacts through the canonical pipeline and asserts the compiled stylesheet
travels inside the one declared file.
**Browser contributions** (ADR 0041). Manifest tests cover namespacing, the single local segment,
duplicate ids and artifact paths, collision with an entrypoint, and a contribution targeting a
capability its own module provides. Discovery tests prove a contribution artifact inherits the exact
path, symlink, containment, extension, and file-kind protections every other compiled entrypoint has,
including two contributions linked to one file. Publication tests prove a contribution is published
only when another eligible module provides its capability *within the declared range*, that the
contributing module never sees its own contribution, that a lookup fails closed on an unknown,
mismatched, or non-string identity, and that the published record carries no domain vocabulary at
all. Route tests prove the artifact is served byte-for-byte at a URL that cannot select a neighbour,
and that an unknown id, another module's id, a traversal, a source map, a wrong version, an absent
provider, and an incompatible provider all collapse into one indistinguishable `404`. The loader's
tests cover mount, update-without-remount, a renderer that declares no `update`, deterministic and
idempotent unmount, a sanitized update failure, and input that is not genuinely JSON — a `Date`, a
function, `NaN`, `undefined`, and a cycle — refused *before* anything is imported and without ever
echoing the consumer's data. The flagship shell fixture drives the whole relationship: a `signage`
module discovers, mounts, updates, and unmounts an `announcements` React renderer with neither module
importing the other, the consumer seeing identity but never a URL, the host resolving publication,
and a contribution id the server withheld refused without a fetch.
**Bounded streaming uploads** (ADR 0042). Contract tests pin the two ceilings, that `maxBodyBytes` is
checked against the ceiling the declared mode selects, that 32 MiB holds a 25 MiB image and one byte
more is refused, and that no mode admits an unbounded body. HTTP tests cover exact-boundary success,
a declared `Content-Length` refused before the handler runs, a misleading smaller `Content-Length`
that cannot smuggle a megabyte past the limit, a chunked overflow, a `413` that still wins when the
handler swallowed the stream error, proof that the body is genuinely not buffered (a handler sees the
first chunk before the last is produced), an aborted upload surfacing as an ordinary failure rather
than a `413`, an unread body being released, authorization ordering in both directions, and a
rejection that names no limit, size, or body content. The live suite uploads a real 25 MiB body over
a real socket end to end and proves the server is healthy immediately afterwards.
**Display offline fallback** (ADR 0043). The worker's caching logic is exercised directly, because it
touches no global: first online load, offline reload after one successful visit, a missing uncached
artifact failing honestly, contributed-renderer fallback, version upgrade with pruning that keeps
every artifact of the current version and never touches another module's, superseded cache
generations removed while unrelated ones survive, network-first behaviour picking up rebuilt bytes,
and an explicit inventory proving the descriptor, principal, module API, realtime stream, pairing
exchange, auth routes, cross-origin signed object URLs, and the worker itself are **never** stored —
so a revoked device's refusal is what a reconnected display sees. Page-level tests cover the quiet
offline indicator, a cache fallback reported while the browser still claims to be online, automatic
re-resolution when the network returns, and a revoked device dropping back to pairing on reconnect.
`pnpm verify` is intentionally Docker-free. It never plans or applies migrations against a live
database.