`manifest.storage.buckets` has existed since manifest version 1 and meant nothing — discovery validated it and no code read it. This turns it into a real capability, which is what stands between Announcements and the module system: that product currently fetches a hardcoded LAN S3 endpoint, parses ListObjectsV2 XML by hand, and hands the browser direct object URLs. - A module declares a logical bucket and receives a scoped facade at `context.storage` — list, head, get, put, delete. The contract names no provider: no endpoint, region, credential, or physical bucket is reachable from it, which is what lets the same module run under a future standalone host over its own or a shared S3-compatible service. - Core's first provider speaks S3 through the AWS SDK. SeaweedFS is the deployment target (ADR 0004), never an API name. Probing the pinned image found `CreateBucket` reporting `BucketAlreadyExists` rather than AWS's `BucketAlreadyOwnedByYou`; treating only the AWS spelling as success would have failed every second boot. A live test pins it. - Logical → physical mapping is deterministic and collision-safe: an installation prefix, the module and bucket ids joined by `--` (which cannot occur inside either identifier, so `a-b`+`c` and `a`+`b-c` cannot collide), and a digest over the full untruncated identity. Two modules declaring `assets` always get separate namespaces; neither can see the physical name. - Declaring a bucket is now a requirement: with no provider configured the module is `blocked · storage_unavailable` through ADR 0028's existing resolver, propagating to required dependents. An installation with no object storage stays a complete deployment. - Binding availability is not provider health. Eligibility asks whether storage is configured, never whether it answered; bucket creation is lazy on first use so a storage outage cannot stop the platform booting; a running module whose store blinks stays running and retries. Nothing deletes a bucket. - Credentials are deployment-supplied, never persisted, never routed through module configuration, and never visible to modules, module UI, diagnostics, or API responses. Provider errors translate to a closed code set carrying no endpoint, bucket, request id, or XML. - Retires ADR 0029's transitional `bucket` configuration key. No manifest version change. The template declares no bucket, so it still runs without S3. See ADR 0030. Co-Authored-By: Claude Opus 5 <[email protected]>
Ekklesia module template
This is the smallest supported backend-only Ekklesia module. It proves the static manifest,
compiled runtime and schema entrypoints, capability registration, a module-owned Drizzle table, a
generated module-owned migration, logical storage-bucket declarations, and one permissioned HTTP
route (GET /records, declared in register) that reads that table through
context.services.database — the platform-provided ModuleDatabase facade, never a
module-constructed Drizzle instance or a raw pg client. Migration execution and storage
provisioning are intentionally not included.
It also ships an optional React UI (src/ui/), compiled to the entrypoints.ui artifact its
manifest declares by pnpm build
(ADR 0023). UI is a choice, not
a requirement: to keep a module backend-only, delete src/ui/, tsconfig.ui.json,
tsconfig.ui-test.json, tests/module-ui.test.ts, the ui entrypoint from the manifest, and the
React and module-UI dependencies. Everything else keeps working, and ekklesia-build-module-ui
simply reports that there is nothing to build.
No host mounts a module UI yet — the Dashboard shell does not exist. tests/module-ui.test.ts builds
the artifact from source, validates it with parseModuleUiExport, mounts it into a real DOM, and
unmounts it, which is what proves the pipeline today. See
Declaring a UI entrypoint.
To create a module inside this repository:
- Copy this directory to
modules/<module-id>. - Rename the package in
package.jsonand removeprivatewhen it is ready for publication. - Update the ID, name, version, namespaces, compatibility ranges, and bucket requests in
ekklesia.module.json. - Keep the manifest capability versions and dependency ranges aligned with the contracts the module actually implements and consumes.
- Replace
src/server/index.tswith backend behavior only. ItsdefineModuleRuntimeID and version must match the manifest; do not duplicate the rest of the manifest in TypeScript. Declare HTTP routes only inregister, against a permission the manifest already declares;start,stop, and every route handler — neverregister— receivecontext.services.database, a typedModuleDatabaseview over the platform's one PostgreSQL pool. - Replace the pure Drizzle declarations in
src/schema/tables.tsand keep its table prefix aligned with the module ID. Keep thedefineModuleSchemaownership wrapper insrc/schema/index.tsaligned too, and never import another module's schema. - Update
drizzle.config.ts, generate forward migrations withpnpm migration:generate --name=lowercase_namefrom the module directory, and review the SQL. Keep each generated tag indrizzle/meta/_ekklesia.json, classify it asnoneorapproval-required, and change that file'smoduleIdwhen renaming the template. - Run
pnpm install,pnpm build,pnpm modules:validate,pnpm migrations:validate, andpnpm verifyfrom the repository root. The declared production entrypoint is the compileddist/server/index.js, not source TypeScript; the schema entrypoint is likewise compiled todist/schema/index.js.
Do not add UI components, styles, renderers, navigation, or layouts. Those belong to surfaces.
Keep imports side-effect-free, register capabilities in register, acquire resources in start,
and release them in stop. Never construct a Drizzle instance, read DATABASE_URL, or reach for a
raw pg client — context.services.database is the only supported way to query this module's own
tables, and it is never available during register.
The migration directory uses the pinned stable Drizzle Kit 0.31.10 PostgreSQL journal and
snapshot format. Migrations are forward-only and may modify only this module's objects. Offline
validation treats SQL as opaque bytes, calculates SHA-256 from the exact contents, and never opens a
database or executes SQL. Destructive declarations are review metadata, not automatic SQL
classification or approval.