Merge pull request 'docs: accept ADR 0048, one generic image with installed modules outside it' (#63) from docs/adr-0048-installed-modules into main
CI / verify (push) Successful in 50s
CI / infrastructure-smoke (push) Successful in 35s
CI / migration-smoke (push) Successful in 1m49s
CI / image-build (push) Successful in 8s
CI / deploy-smoke (same-origin) (push) Successful in 47s
CI / deploy-smoke (split) (push) Successful in 44s
CI / verify (push) Successful in 50s
CI / infrastructure-smoke (push) Successful in 35s
CI / migration-smoke (push) Successful in 1m49s
CI / image-build (push) Successful in 8s
CI / deploy-smoke (same-origin) (push) Successful in 47s
CI / deploy-smoke (split) (push) Successful in 44s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/63 Reviewed-by: The_River <[email protected]>
This commit was merged in pull request #63.
This commit is contained in:
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 `<repository>/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, `<repository>/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.
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user