diff --git a/deploy/README.md b/deploy/README.md index ff0b3cb..bd78452 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -359,6 +359,12 @@ Assembling the context (checking out the platform at a release tag, each module commit, and the instance's own lockfile) belongs to the installation's instance composition, not to this file. +> **Being replaced.** [ADR 0048](../docs/architecture/decisions/0048-generic-image-and-installed-module-storage.md) +> supersedes this composition model. The image will contain no modules and ignore `modules/` in its +> build context. Installed modules will live in installation-owned storage +> (`EKKLESIA_MODULES_DIR`, `/var/lib/ekklesia/modules` in the image), mounted read-only. Until that +> lands, the image behaves exactly as described here. Do not start a new per-installation assembly. + | Stage | Does | | --- | --- | | `base` | `node:24.18.0-trixie-slim`, pinned by its multi-architecture index digest. Debian, so glibc, because module dependencies such as sharp ship glibc prebuilt binaries. Every stage starts here. | diff --git a/docs/architecture/decisions/0047-module-ci-harness.md b/docs/architecture/decisions/0047-module-ci-harness.md index eceaa35..9eeb0bb 100644 --- a/docs/architecture/decisions/0047-module-ci-harness.md +++ b/docs/architecture/decisions/0047-module-ci-harness.md @@ -13,6 +13,10 @@ [ADR 0043](0043-display-offline-code-fallback.md), [ADR 0044](0044-self-hosted-reference-deployment.md), [ADR 0045](0045-split-dashboard-and-api-origins.md) +- Must be revised against: [ADR 0048](0048-generic-image-and-installed-module-storage.md) — + steps 1 and 3 below build the module into a deployment image, which ADR 0048 rules out. Before + this proposal is accepted, the harness must run the module from module storage in the generic + image. ## Context diff --git a/docs/architecture/decisions/0048-generic-image-and-installed-module-storage.md b/docs/architecture/decisions/0048-generic-image-and-installed-module-storage.md new file mode 100644 index 0000000..c70bb0f --- /dev/null +++ b/docs/architecture/decisions/0048-generic-image-and-installed-module-storage.md @@ -0,0 +1,311 @@ +# 0048: Ekklesia ships one generic image, and installed modules live outside it + +- Status: Accepted +- Date: 2026-10-07 +- Supersedes (partial): [0044](0044-self-hosted-reference-deployment.md) — only its "Composition + from pinned sources" section, its topology table's statement that the `api` image contains "the + enabled modules", and its consequence that modules are built inside a platform workspace with a + per-instance lockfile. The one reference deployment, its topology, TLS everywhere, object storage, + explicit migrations, health endpoints, backups, and secrets remain accepted and unchanged. +- Supersedes (partial): [0009](0009-transactional-module-migrations.md) — only its rule that an + applied migration absent from the current inventory is rejected, which that ADR left to "a future + explicit platform policy". This ADR is that policy, for modules only. The fixed history contract, + the per-module exact-prefix rule, checksums, locking, the single transaction, approvals, and + forward-only execution remain accepted and unchanged. +- Amends: [0006](0006-versioned-capabilities-and-compiled-module-runtime.md) — installing a module + "by cloning it into `modules/`, installing workspace dependencies, building the workspace" becomes + the development workflow only. Inert manifests, atomic discovery, compiled ESM entrypoints, and + the trusted-plugin model remain accepted and unchanged. +- Extends: [0028](0028-module-operational-lifecycle.md), + [0030](0030-host-provided-module-object-storage.md) +- Related: [ADR 0007](0007-module-owned-drizzle-schemas.md), + [ADR 0008](0008-module-owned-migration-contracts.md), + [ADR 0016](0016-module-database-access.md), + [ADR 0021](0021-modules-as-hostable-products.md), + [ADR 0027](0027-dashboard-navigation-and-flat-modules.md), + [ADR 0029](0029-module-configuration-and-secrets.md), + [ADR 0047](0047-module-ci-harness.md) + +## Context + +ADR 0044 builds an installation's image from an **instance composition**: a separate repository +per installation pins the platform and each module, commits its own lockfile, assembles them into +one workspace, and builds the platform's Dockerfile over it. River of Glory Church runs exactly +that. In practice: + +- Every module change, however small, is a new image build and a new image. The image an + installation runs is never the image the platform's CI built and tested. +- The Dockerfile copies whatever `modules/` contains into the image. What a platform build + produces therefore depends on what happens to be in the source checkout. +- A module resolves its runtime dependencies only through the platform's own workspace install. No + module can run anywhere except inside an Ekklesia workspace built together with it. +- Each installation needs its own assembly repository, lockfile, and build pipeline. + +Removing a module has a further problem. Its stored platform state (enablement, configuration, +Dashboard placement, grants) and its data (tables, buckets) are already retained by design +(ADR 0027, ADR 0028, ADR 0029, ADR 0030). Its migration history is not: ADR 0009 rejects any +applied migration that is missing from the current inventory. Once a module with migrations is +removed, no migration can be planned or applied again, including Core's own on a platform upgrade, +until the module's code is put back. + +Ekklesia will eventually install modules from a marketplace. That needs a platform whose image does +not change when its modules do. Designing the marketplace is not part of this decision. + +## Decision + +### One generic platform image, with no modules in it + +Ekklesia produces **one** platform image for every installation. It contains Core, `apps/api`, the +Dashboard and display host, the operator commands, and the platform packages they need. It contains +**zero feature modules**: not the module template, and not a test module. + +Building the image does not depend on which modules exist in the source checkout. A `modules/` +directory in the build context is never a build input: a platform build produces the same platform +whether or not one is present. + +The build takes no installation-specific input: no module list, no per-installation lockfile, no +branding, no configuration. Per-installation application images and per-installation assembly +repositories are not part of the deployment model. An installation that already runs one may keep +it as a rollback path until it has moved; the platform does not support building new ones. + +### Installed modules live in installation-owned storage + +Installed modules live **outside the image**, in persistent storage the installation owns. + +- **`EKKLESIA_MODULES_DIR`** selects the modules directory, as an absolute path. It is read once, + through the host configuration the API and Doctor already share. The API and every operator + command (migration plan and apply, module and migration validation, module enablement, Doctor) + honour the same value. There is one modules directory and one discovery path; discovery itself is + unchanged. +- **The shipping image sets it to `/var/lib/ekklesia/modules`.** +- **Unset**, as in a development checkout, it defaults to `/modules`. Workspace + development, module CI, and the platform's tests keep working as they do now. +- **A configured directory that is missing or unreadable fails closed** with a sanitized error. A + mistyped mount must never quietly start the platform with no modules. +- **Nothing in the platform locates its own files relative to the modules directory.** Core's + migrations and the platform's package metadata are found where the platform is installed, never + beside the modules. + +In the reference deployment, the modules directory is persistent host storage mounted into the +`api` and `migrate` containers, and the mount is **read-only to Ekklesia**. The Ekklesia process +never writes to module storage. Whatever installs a module writes to that storage from outside the +process: today an operator, later perhaps an installer. Giving Ekklesia itself write access is a +separate, future decision. Module storage is installation state, so it belongs in the +installation's backups beside the database and the buckets. + +Changes to module storage are discovered **only when a process starts**. The API sees them at a +controlled restart, and an operator command sees them when it runs. There is no file watching, hot +loading, or hot unloading. Changing modules is therefore a sequence, never an edit to a running +API: stop the API, change module storage, plan and explicitly apply migrations, start the API. A +running API may still read browser artifacts from disk, so changing files under it is unsupported. + +Discovery's existing rules carry over unchanged. Core scans the immediate, non-hidden directories, +rejects a symbolic-link module directory, and rejects the whole inventory if any entry is invalid. +Two consequences matter for anything that writes module storage: + +- the directory holds nothing but module directories; +- hidden entries are ignored, so a module can be staged under a hidden name and then renamed into + place in one step. + +### An installed module directory is self-contained + +An installed module directory is **self-contained for runtime use**: every file the platform reads +or loads from it, and everything those files import at run time, resolves inside that directory. + +It contains: + +- `package.json`, whose version matches the manifest's; +- `ekklesia.module.json`; +- every built artifact its manifest declares: the server entrypoint, the schema entrypoint when it + owns tables, the jobs entrypoint, and the primary, display, and contribution browser artifacts; +- its migrations directory, with the journal and Ekklesia metadata, when it has migration history; +- the **production runtime dependencies** of its server, schema, and jobs code, inside the module + directory. That includes `@ekklesia/module-sdk`, `@ekklesia/contracts`, Drizzle, and every + third-party package the module imports. + +Copying `dist/` alone does not produce an installed module. Source, tests, and development +dependencies are not required. + +**The platform lends a module nothing.** Node resolves a module's imports from the module's own +directory upwards, and the platform's own `node_modules` is never on that path. Ekklesia does not +add it there through links, `NODE_PATH`, loader hooks, aliases, or import maps. A module whose +imports do not resolve fails to load, and because startup is atomic, the API does not start. That +failure is sanitized like any other. + +**Native dependencies** must be built for the platform image's runtime: its Node.js version and ABI, +its C library, and its CPU architecture. The platform documents that runtime for each image. + +**Browser artifacts** are already self-contained bundles (ADR 0023, ADR 0040). Nothing changes for +them. + +**Drizzle, for now.** A module defines its tables with its own copy of Drizzle, and Core runs that +module's queries through the platform's Drizzle instance (ADR 0016). Objects from two copies of the +library only interoperate when the copies are interchangeable. **The current implementation may +therefore require a module to carry exactly the Drizzle version the platform uses.** This is a +constraint of the current implementation, not a permanent part of the module contract. A stable +module database ABI, or a compatibility rule that permits a range, replaces it when one exists. + +Core already validates a module's runtime and schema definitions structurally, against its own +contracts. It never relies on object identity with the module's copy of the SDK. The module's +`compatibility.moduleSdk` range continues to be checked against the platform's SDK version. + +The archive or package format a module is delivered in, how a package is signed, and how it is +transported to an installation are deferred. + +### Disable, uninstall, and purge are different operations + +| Operation | Module code | Stored platform state | Module data | +| --- | --- | --- | --- | +| **Disable** (ADR 0028) | Stays installed; not loaded | Enablement record set to disabled | Kept | +| **Uninstall** | Removed from module storage | Kept and ignored: enablement, configuration, Dashboard placement, grants | Kept: tables and rows, buckets and objects, migration history | +| **Purge** | Not installed | Deleted | Deleted | + +**Purge is deferred.** Until it exists, nothing in the platform deletes module data, and removing +module code never does. No table is dropped, no bucket or object is deleted (ADR 0030), and no +migration history or stored platform record is removed just because a module is absent. There are +no down migrations (ADR 0008, ADR 0009). + +**A removed module may be reinstalled** with the same module id, and it recovers its retained state: + +- its enablement decision, configuration, Dashboard placement, and grants apply again; +- its tables and objects are where it left them; +- its migration history is validated strictly again (next section). + +Reinstalling a newer version plans that version's new migrations on top of the retained history. + +**Dependencies are unchanged.** A present module whose *required* dependency is absent still fails +discovery, and the API does not start. Dependents are removed first. An optional dependency may be +absent, exactly as before. + +### Migration history for an absent module is retained and reported + +A module is **absent** when no module in the current discovered inventory has its module id. + +**Applied history belonging to an absent module is retained and reported, not treated as fatal.** + +- Its rows are still checked for shape and for global consistency: unique identities, and unique, + increasing applied order. A corrupt row still fails. +- They are not matched against any inventory. Migration plan, apply, and Doctor report them as + retained history for an absent module: the module id and how many migrations it has. +- Their presence never blocks planning or applying Core's migrations, or those of any present + module. + +**History belonging to a present module stays strict, including a module that has just returned.** +Its applied history must be an exact prefix of its current journal, and every row must match its +migration's module, tag, index, and checksum. Therefore: + +- a previously applied migration missing from the package fails; +- a checksum mismatch fails; +- a downgrade to a version with fewer migrations, or any incomplete or gapped history, fails. + +A different package that claims a retained module id is held to that history in exactly the same +way. + +**Core's history is never treated as absent.** Rows belonging to Core's migration source are always +validated strictly; an applied Core migration missing from Core's inventory is still fatal. + +Applying migrations never touches an absent module's database objects, and nothing runs a down +migration or deletes data because a module is absent. Deleting retained history belongs to purge. + +This is safe because nothing applied in the platform can depend on an absent module's objects: + +- a module may not hold a foreign key into another module's tables (ADR 0007); +- a module's migrations may change only its own objects (ADR 0008); +- Core never depends on a module's tables. + +### The trust boundary does not move + +Installed modules remain **trusted backend code** (ADR 0006, ADR 0015, ADR 0021). They run inside +the API process with its database role. Module storage, the read-only mount, and self-contained +packages are integrity and operational properties. **They are not a security sandbox.** The +read-only mount stops the Ekklesia process from modifying installed code. It does not limit what +installed code can do. + +What the platform can still check against an installed directory is unchanged: + +- discovery: the manifest, the package, entrypoint containment, compatibility ranges, and the + dependency graph; +- module, schema, and migration validation, run against module storage. + +The repository's boundary checks (`pnpm boundaries`) can only prove **source** modules present in a +workspace. That includes the rule that every runtime import is a declared production dependency. An +installed package normally ships no source, so the platform cannot run those checks on it. +Conformance of a published or installed module must therefore be enforced: + +- by the module's own CI; +- by packaging validation; +- by the module harness (ADR 0047). + +Until packaging validation exists, installing a module means trusting that module's CI and the +operator who installed it, exactly as installing trusted code always has. + +### Development keeps the workspace + +In a development checkout, `/modules` remains the default, and a module is still +developed as a workspace package there. Module CI and the module harness may still assemble a module +into a platform workspace to build and test it. A deployment image is never built from such a +workspace. + +## Consequences + +- Every installation runs the same platform image, and that image is the one the platform's CI + tested. Installing, updating, or removing a module needs no image build. It needs only a + controlled restart and, when the module has migrations, an explicit migration plan and apply. +- Per-installation assembly repositories become unnecessary. River of Glory Church's instance + repository keeps running its pinned image as a rollback path until the new model is proven there. + Once the platform image stops accepting modules from its build context, that repository cannot + move to a newer platform release, which is intended. +- Module authors gain a packaging step: a self-contained directory with production dependencies and, + where needed, native binaries built for the image's runtime. Packages grow, and modules that share + a library each carry their own copy. That cost is accepted. +- Removing a module no longer blocks migrations. Its data stays, and reinstalling restores it. +- Operators own module storage: they create it, keep it in backups, and keep anything but module + directories out of it. +- ADR 0047, still proposed, assembles a module into the platform and builds a deployment image from + it. Before it is accepted, it must instead run the module from module storage in the generic + image. +- This ADR records the decision; it changes no code. Selecting `EKKLESIA_MODULES_DIR`, the image + build, the retained-history policy, the read-only mount, and the CI proof that the image is + independent of installed modules follow in separate changes. Until they land, the repository + behaves as `deploy/README.md` and the development guides describe. + +## Alternatives + +- **Keep per-installation images (ADR 0044).** Rejected: every module change is an image build, no + two installations run the same image, and a platform build depends on its checkout. +- **Lend modules the platform's dependencies** through links, `NODE_PATH`, loader hooks, or import + maps. Rejected: + - it couples every installed module to the platform's private dependency graph, so upgrading a + platform dependency could silently break an installed module; + - ESM ignores `NODE_PATH`; + - a resolution hook would be a second module-loading mechanism beside Node's own. +- **Require one bundled file per entrypoint as the package format.** Not adopted as a requirement: + native dependencies cannot be bundled, and the package format is deferred. Bundling is one + permitted way to produce a self-contained directory, not the definition of one. +- **Let the API write module storage, so it can install modules itself.** Deferred, not adopted: + code that can rewrite the code it executes is a larger trust decision, and it belongs with the + marketplace. +- **Hot loading and unloading.** Rejected: ES modules cannot be unloaded, and atomic discovery, + dependency-ordered startup, and immutable permission composition all assume a fresh process. +- **Delete data, or run down migrations, on uninstall.** Rejected: migrations are forward-only, and + a mistaken removal must never destroy data. Deleting data is purge, and purge is explicit. +- **Keep rejecting history for absent modules (ADR 0009 as written).** Rejected: removing any module + with migrations would block every later migration, including Core's. +- **Accept history for absent modules only behind an operator flag.** Rejected: applying migrations + with a module absent cannot harm that module's data, and the plan already reports what is + retained. A flag would turn every routine uninstall into a special case. + +## Deferred + +- The module archive or package transport format. +- Package signatures and trust roots. +- A marketplace or catalog. +- An installer UI. +- Installation by the running platform, and any write access for Ekklesia to module storage. +- Hot loading, hot unloading, and file watching. +- The purge workflow: deleting a module's tables, buckets and objects, migration history, and stored + platform records. +- A stable module database ABI, or a compatibility rule wider than the current exact Drizzle + requirement. +- A platform-provided packaging and package-validation command. +- Publishing the platform image and automated deployment from CI, still deferred from ADR 0044. diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index b8b9858..de801d2 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -56,3 +56,4 @@ ADRs capture durable choices that affect more than one package or phase. - [0045: The Dashboard and the API may live on separate origins](0045-split-dashboard-and-api-origins.md) — Accepted - [0046: Ekklesia's branding is never forced on an installation](0046-installation-owned-branding.md) — Proposed - [0047: Every module proves itself inside Ekklesia, with one harness the platform publishes](0047-module-ci-harness.md) — Proposed +- [0048: Ekklesia ships one generic image, and installed modules live outside it](0048-generic-image-and-installed-module-storage.md) — Accepted