|
|
|
@@ -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.
|