feat: establish the module product contract
Ekklesia's module model has been backend-only since ADR 0001: a module owns domain behavior and data, and every presentation concern belongs to a surface. That prevents a module author — the person who knows the domain — from shipping the screen that makes their module useful, and it guarantees a module and its own UI drift apart. It also pushes toward two codebases for one domain the moment a module needs to run standalone. ADR 0021 replaces that rule. A module is an independently versioned product that owns its domain behavior, data, permissions, routes, migrations, and its own UI, hostable inside Ekklesia and — from the same implementation — by a later standalone host. Core keeps the containing application shell and every platform policy: authentication, authorization enforcement, platform context, top-level routing, navigation, global theme, error boundaries, and trusted-origin/CORS. The module graph stays flat: modules are peers, genuine module and capability dependencies remain the only relationships, and Dashboard folders are presentation configuration an administrator can reorganize, never module architecture. This pass implements the static contract only. `manifestVersion` moves to 4 and `entrypoints` gains one optional field, `ui`. Version 3's `entrypoints` is strict and rejects `ui`, so this is an incompatible shape change and version 3 is now rejected outright rather than reinterpreted, exactly as version 1 was by version 2 and version 2 by version 3. Every field version 4 adds is optional, so upgrading a backend-only manifest is a one-character edit. Discovery resolves and validates `ui` under the same rules as `server` and `schema` — package-relative, inside the package before and after symlink resolution, a real file, compiled ESM `.js`/`.mjs`, so source TypeScript is rejected rather than assumed to be compiled later. Discovery stays inert: nothing imports, evaluates, mounts, or renders a UI entrypoint, and a test proves a throwing artifact still discovers successfully. This follows `entrypoints.jobs`, declared and resolved since ADR 0006 with no runtime consumer. The name is `ui`, not `dashboard`, because a module's UI must be hostable by Ekklesia's shell and by a standalone host, so the contract cannot be named after one of them; and not `surface`, because `surfaces/*` is a separate ownership layer. No widget, route, icon, folder, menu, layout, or standalone-metadata field is added, and tests assert the manifest still rejects every unknown entrypoint key and offers no hierarchy field. Deliberately unchanged: `scripts/check-boundaries.ts` still enforces `modules-do-not-own-ui`. Relaxing that guard depends entirely on the UI authoring model a later pass chooses, and weakening a working protection before that decision exists would trade real coverage for nothing. A module may therefore declare `ui` today only for a framework-neutral compiled artifact, and the docs say so rather than implying otherwise. Restart-based discovery, explicit operator-invoked migrations, and the trusted compiled-module model are untouched — a module that ships UI is exactly as trusted as one that does not. No Dashboard shell, UI runtime, renderer, framework choice, navigation registry, grouping system, standalone host, installer, hot-loading, or feature module is added by this pass. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
94d26cffa0
commit
44ba259f58
@@ -1,7 +1,19 @@
|
||||
# Module development
|
||||
|
||||
An Ekklesia module is a backend capability package. It owns business behavior and data declarations;
|
||||
it never owns components, styles, layouts, renderers, navigation, or other UI.
|
||||
An Ekklesia module is an independently versioned, first-class product. It owns its domain behavior,
|
||||
data declarations, permissions, capabilities, HTTP routes, migrations — and, as of
|
||||
[ADR 0021](../architecture/decisions/0021-modules-as-hostable-products.md), its own UI.
|
||||
|
||||
It does **not** own the application that contains it. Authentication, authorization enforcement,
|
||||
platform context, the application shell, top-level routing, navigation, the global theme and
|
||||
design-token contract, error boundaries, and trusted-origin/CORS policy are Core's and the host
|
||||
application's. A module consumes those; it never redefines or bypasses them.
|
||||
|
||||
> **What exists today:** a module may *declare* a UI entrypoint in its manifest, and Core validates
|
||||
> it. **Nothing loads, mounts, or renders it yet** — the host shell, the UI runtime, and the
|
||||
> standalone host are all later Phase 6 work. Writing a module UI is not yet possible end to end;
|
||||
> see [Declaring a UI entrypoint](#declaring-a-ui-entrypoint) for exactly what is and is not
|
||||
> supported.
|
||||
|
||||
> **Before you publish or install a module:** modules are trusted compiled backend code. Your
|
||||
> module runs arbitrary JavaScript inside the API process and shares its PostgreSQL role, and
|
||||
@@ -54,7 +66,7 @@ without executing module code:
|
||||
|
||||
```json
|
||||
{
|
||||
"manifestVersion": 3,
|
||||
"manifestVersion": 4,
|
||||
"id": "calendar",
|
||||
"name": "Calendar",
|
||||
"version": "0.1.0",
|
||||
@@ -111,9 +123,12 @@ has its own exact semantic version because its public contract can evolve indepe
|
||||
module release. Capability dependencies use semantic-version ranges. Optional means the provider
|
||||
may be absent; if it is installed, its capability version must be compatible.
|
||||
|
||||
`manifestVersion: 3` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a
|
||||
version 2 manifest, and any other version, fails validation outright rather than being silently
|
||||
`manifestVersion: 4` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a
|
||||
version 3 manifest, and any other version, fails validation outright rather than being silently
|
||||
reinterpreted; there is no compatibility adapter or automatic migration between manifest versions.
|
||||
Version 4 adds one optional field, `entrypoints.ui`, so upgrading a backend-only version 3 manifest
|
||||
is a one-character edit and changes nothing else
|
||||
([ADR 0021](../architecture/decisions/0021-modules-as-hostable-products.md)).
|
||||
Each `permissions` entry is a complete declaration, not a bare identifier string: `id` (must begin
|
||||
with `<module-id>.`, exactly like a capability id), `baseline` (`public`, `authenticated`, or
|
||||
`restricted` — see [Authorization](../development/authorization.md#permission-registry) for what
|
||||
@@ -138,16 +153,92 @@ module's own HTTP routes, and creating relationship-derived grants automatically
|
||||
own business logic, are both still later work; this only makes a declared permission *usable*
|
||||
through Core's existing, generic surface.
|
||||
|
||||
Server and optional schema entrypoints must be compiled ESM `.js` or `.mjs` files. The optional
|
||||
migration entrypoint is a directory. All entrypoints begin with `./`, remain inside the package,
|
||||
and exist. Production never imports source TypeScript directly. Modules with no tables omit
|
||||
`entrypoints.schema`; modules with no migration history omit `entrypoints.migrations`. Unknown
|
||||
manifest properties are rejected, including presentation fields such as components or renderers.
|
||||
Server and optional schema and UI entrypoints must be compiled ESM `.js` or `.mjs` files. The
|
||||
optional migration entrypoint is a directory. All entrypoints begin with `./`, remain inside the
|
||||
package, and exist. Production never imports source TypeScript directly. Modules with no tables omit
|
||||
`entrypoints.schema`; modules with no migration history omit `entrypoints.migrations`; modules with
|
||||
no UI omit `entrypoints.ui`.
|
||||
|
||||
Unknown manifest properties are still rejected. `ui` is the only entrypoint version 4 adds — a
|
||||
`dashboard`, `surface`, `client`, `web`, or `renderers` key fails validation — and the manifest
|
||||
deliberately has no group, folder, parent, menu, navigation, icon, or ordering field. Dashboard
|
||||
organization is presentation configuration owned by the Dashboard, never module architecture.
|
||||
|
||||
Storage bucket IDs are logical names scoped to the module. Modules may request multiple buckets.
|
||||
Core will later map them to deployment-specific physical S3 buckets and provide scoped access;
|
||||
module code must not configure SeaweedFS or another S3 client directly.
|
||||
|
||||
## Declaring a UI entrypoint
|
||||
|
||||
A module may declare one optional presentation entrypoint alongside its server, schema, and
|
||||
migration entrypoints:
|
||||
|
||||
```json
|
||||
{
|
||||
"entrypoints": {
|
||||
"server": "./dist/server/index.js",
|
||||
"schema": "./dist/schema/index.js",
|
||||
"migrations": "./drizzle",
|
||||
"ui": "./dist/ui/index.js"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`ui` obeys exactly the rules `server` and `schema` already obey. It must be package-relative, stay
|
||||
inside the module package (checked before *and* after symlink resolution), resolve to a real file,
|
||||
and be compiled ESM JavaScript ending in `.js` or `.mjs`. A `./src/ui/index.ts` path is rejected at
|
||||
discovery — the platform never imports source TypeScript, and a UI artifact is a build output like
|
||||
any other.
|
||||
|
||||
### What this does and does not do today
|
||||
|
||||
Discovery parses the manifest as inert JSON, resolves the `ui` path, validates it, and records it on
|
||||
the discovered module. That is all. **Nothing in this repository imports, evaluates, mounts, or
|
||||
renders a UI entrypoint** — a UI artifact that throws on evaluation still discovers successfully,
|
||||
because it is never evaluated. The field follows the same established pattern as `entrypoints.jobs`,
|
||||
which has been declared and validated since ADR 0006 with no runtime consumer.
|
||||
|
||||
Declaring `ui` therefore has no observable runtime effect yet. It exists so the host shell, the UI
|
||||
runtime, and a future standalone host have a fixed, tested contract to build against.
|
||||
|
||||
### The authoring constraint you will hit
|
||||
|
||||
`pnpm boundaries` enforces `modules-do-not-own-ui`, which rejects UI *source* extensions (`.tsx`,
|
||||
`.vue`, `.svelte`, `.css`, `.svg`, and others) and UI *framework dependencies* (React, Vue, Svelte,
|
||||
Tailwind, and others) inside a module package. **That rule is still enforced.** ADR 0021 changed the
|
||||
architectural direction — a module may own UI — but the authoring model for module UI, including
|
||||
which parts of that deny list are relaxed and what replaces them, is a later Phase 6 decision that
|
||||
has not been made.
|
||||
|
||||
In practice this means a module can declare `entrypoints.ui` today only for a framework-neutral
|
||||
compiled artifact authored in plain `.ts`. Do not add a UI framework dependency to a module package
|
||||
expecting it to pass; it will not, and that is deliberate rather than an oversight.
|
||||
|
||||
### What a module UI will never own
|
||||
|
||||
Whatever authoring model is chosen, a module UI consumes platform policy and never redefines it.
|
||||
Authentication and session establishment, authorization enforcement, organization/platform context,
|
||||
the containing application shell, top-level routing and mount boundaries, the platform navigation
|
||||
container, the global theme and design-token contract, platform error boundaries, and
|
||||
trusted-origin/CORS policy belong to Core and the host application. This is the same boundary module
|
||||
HTTP routes already live inside: a module mounts routes, but the platform still owns the method
|
||||
vocabulary, CORS preflight, body ceilings, and permission enforcement.
|
||||
|
||||
A module UI also grants no isolation. A module that ships UI is exactly as trusted as one that does
|
||||
not — see [Security model and deployment trust boundaries](../development/security.md).
|
||||
|
||||
### Modules stay flat
|
||||
|
||||
Shipping UI creates no parent/child relationship between modules. `video`, `livestream`,
|
||||
`announcements`, `calendar`, `roster`, and `signage` are peers. The only relationships a module may
|
||||
express are a genuine module dependency and a genuine capability dependency — both version-ranged,
|
||||
both meaning *requirement*, never *ownership*.
|
||||
|
||||
A Dashboard may later group modules into folders (`Media` holding Video and Livestream, or
|
||||
`Sunday Production` holding Livestream, Signage, and Video). Those groups are Dashboard
|
||||
configuration an administrator can reorganize freely. They are not dependencies, they are not
|
||||
declared in your manifest, and your module must never require one to work correctly.
|
||||
|
||||
## Runtime server definition
|
||||
|
||||
The source entrypoint exports one typed runtime definition and does not repeat the static manifest:
|
||||
|
||||
Reference in New Issue
Block a user