# 0053: A module builds outside the monorepo, from Ekklesia packages published over the npm registry protocol - Status: Proposed - Date: 2026-10-07 - Extends: [0049](0049-module-package-v1.md). This is the step ADR 0049 deferred: "publishing the Ekklesia SDK packages for module builds outside a platform workspace". A registry is used only to build a module. Installing one stays registry-free: an installed package is self-contained, as ADR 0049 decided. - Related: [ADR 0002](0002-pnpm-workspace-and-typescript.md), [ADR 0006](0006-versioned-capabilities-and-compiled-module-runtime.md), [ADR 0021](0021-modules-as-hostable-products.md), [ADR 0023](0023-module-ui-build-pipeline.md), [ADR 0039](0039-shared-design-tokens-and-elements.md), [ADR 0047](0047-module-ci-harness.md), [ADR 0048](0048-generic-image-and-installed-module-storage.md), [ADR 0050](0050-module-database-abi-v1.md), [ADR 0051](0051-module-package-license-material.md), [ADR 0052](0052-reviewed-license-supplements.md) ## Context Modules are independently versioned products in their own repositories (ADR 0021). Yet no module can be built outside Ekklesia's monorepo today. Signage's continuous integration clones the platform at a pinned commit, places Signage under `modules/signage`, and installs and builds inside that workspace, because its Ekklesia dependencies exist only as `workspace:*` packages. An investigation of the current packages found the following. - **One coordinated version.** Every `@ekklesia/*` package, and the platform, is version 0.1.2. The release process already bumps them together. The platform reports its Module SDK version, which manifests' `compatibility.moduleSdk` ranges are checked against (ADR 0006), by reading `@ekklesia/module-sdk`'s own package version. - **The tarballs already stand alone.** `pnpm pack` rewrites `workspace:*` ranges to exact versions, and each tarball carries only its built `dist`, declarations, and `LICENSE`. But every package's `prepack` runs a command at the monorepo root, and `@ekklesia/typescript-config` is marked `private`. - **The SDK exposes everything.** `@ekklesia/module-sdk` re-exports the whole `@ekklesia/contracts` barrel. That includes contracts no module needs, such as authorization and identity, and the legacy Drizzle `ModuleDatabase` type. `@ekklesia/contracts` depends on `drizzle-orm` for that type, so every module install pulls Drizzle in. - Neither the module template nor Signage imports `@ekklesia/contracts` directly. - **Frameworks are declared twice.** `@ekklesia/ui` lists `react`, and `@ekklesia/module-sdk` lists `drizzle-orm`, as both a dependency and a peer dependency. That invites a second copy of a runtime that must be single within one module bundle. - **The canonical packager assumes the monorepo**, in two ways: - it finds the module's Node.js range only through an enclosing `pnpm-workspace.yaml`; - it decides that an Ekklesia package is the platform's own, not third-party, because its real path does not contain `node_modules`. An `@ekklesia/*` package installed from a registry would therefore be treated as third-party. Packaging would omit it from `incorporates` and skip the Module SDK compatibility check. - **The registry and its hosts.** - The current private host is a Gitea 1.27.2 server. Its npm registry is enabled for the organization, and that endpoint and the repositories are currently readable anonymously. - pnpm leaves a tarball URL out of its lockfile only when the URL has the canonical `//-/-.tgz` form. Gitea advertises another form, so lockfiles produced against Gitea name the Gitea host. - The integrity hashes in those lockfiles are independent of the host. ## Decision ### The npm registry protocol, not a provider Ekklesia's module-development packages are distributed over the **standard npm registry protocol**, and modules install them with ordinary pnpm or npm commands. - **No provider is part of the architecture.** Not Gitea, GitHub, npmjs, or any other. A module repository depends on the protocol and on a scope-to-registry mapping in normal npm/pnpm configuration. - **Gitea may host the private phase.** It is a host for this phase, not a permanent decision. - **No host-neutral lockfile or Ekklesia proxy is required.** Moving to another registry may need a one-time change of a module's registry configuration and a regeneration of its lockfile. The integrity hashes keep verifying the package content across such a move. A module repository's registry configuration is, for example: ```ini # .npmrc, committed: where the @ekklesia scope lives, and nothing secret. @ekklesia:registry=https:/// ``` ```ini # User or CI configuration, never committed: the credential, from the environment. ///:_authToken=${EKKLESIA_PACKAGES_TOKEN} ``` ### The public package set Only packages a module author needs are published. Nothing is published merely because it exists in the workspace. | Category | Packages | | --- | --- | | Author API | `@ekklesia/module-sdk`, `@ekklesia/module-database`, `@ekklesia/module-ui-sdk`, `@ekklesia/design-tokens` | | Tooling | `@ekklesia/module-ui-build`, `@ekklesia/module-pack`, `@ekklesia/typescript-config` | | Pre-1.0 component API | `@ekklesia/ui`: publishable, explicitly unstable before 1.0 | | Implementation dependency only | `@ekklesia/contracts`: may be published because the packages above depend on it, but it is **not** a supported direct module-author API | | Never published | `@ekklesia/core`, the applications, `@ekklesia/module-ui-host`, `@ekklesia/module-template`, and every other platform internal | ### A curated SDK surface Before the first external publication, the author-facing SDK is narrowed. - **A curated API.** `@ekklesia/module-sdk` exposes what a module author needs, and no longer re-exports the complete `@ekklesia/contracts` barrel. - **No forced Drizzle.** The stable SDK root does not make Drizzle a dependency of modules that do not use it. - **No legacy database contract.** The legacy Drizzle database contract (ADR 0016's facade type) is not part of the standalone author-facing surface. Core still serves the legacy facade for its deprecation window (ADR 0050). This decision only keeps its type out of what new standalone modules build against. - **Drizzle support lives in one place.** Drizzle-specific helpers belong to the `@ekklesia/module-database` Drizzle adapter (ADR 0050). ### Dependency rules These rules apply to the published packages: - **React and ReactDOM** are peer dependencies of the public UI packages, plus development dependencies for those packages' own builds and tests. This does not change ADR 0023: a module UI artifact still bundles React. The peer only ensures there is one React in that bundle, the module's own. - **Drizzle** is an optional peer dependency only where the Drizzle adapter needs it. - **Real implementation dependencies,** at run time or build time, remain normal dependencies. These rules apply to a module repository: - **Runtime and module-code packages** go in `dependencies`: the Author API packages, `@ekklesia/ui`, React, and the module's ORM if it uses one. - **Build tooling and configuration** go in `devDependencies`: `@ekklesia/module-ui-build`, `@ekklesia/module-pack`, `@ekklesia/typescript-config`, and type packages. ### Versioning - **One coordinated version during 0.x.** Every public Ekklesia module-development package uses one release version, equal to the platform release version. The version the platform reports for `moduleSdk` compatibility is therefore the version of the packages a module was built with. - **No mixed trains.** Within a published train, Ekklesia-owned dependencies name the coordinated, tested version, never a version from another train. - **Ranges in source, exact artifacts in lockfiles.** A module may declare compatible ranges in its source `package.json`; its lockfile pins exact artifacts with their integrity hashes. - **`@ekklesia/module-pack` is pinned exactly.** A module repository pins it in its source `package.json`, not only in its lockfile, because its version affects the package it produces. - **Independent package versions are deferred.** ### Standalone packaging - **No monorepo checkout.** A standalone module builds and packages from its own repository and its installed dependencies. - **Its own Node.js range.** The module declares its own Node.js compatibility, preferably in `engines.node`, and the canonical packager reads it from there or from an explicit option. It never requires an enclosing `pnpm-workspace.yaml`. - **Ekklesia packages recognized by identity.** The canonical packager recognizes Ekklesia-owned installed packages by explicit package identity and metadata, never by workspace path. So `incorporates` and the Module SDK compatibility check (ADR 0049) work the same whether a package came from the monorepo or from a registry. Ekklesia packages are never reported as third-party software (ADR 0051). - **No monorepo-root commands at install time.** Publishing workflows for the public packages run their build in the platform's release pipeline. Nothing in a published package runs a monorepo-root command when a consumer installs or packs it. ### Supply chain - **Immutable versions.** A version is never republished with other content, and never withdrawn to be reused. - **Lockfiles.** pnpm lockfile integrity is part of the reproducibility model: modules install with a frozen lockfile. - **Release CI proves the tarballs before publication.** It must: 1. build and pack the public package set; 2. inspect every tarball: only the intended contents, a `LICENSE`, and no `workspace:` range; 3. install those tarballs into a clean standalone module fixture, outside any monorepo; 4. prove there that the fixture builds, packages with the canonical packager, and verifies. Only then are the versions published. - **Signing and provenance are deferred.** ### Registry access and the private phase - **No secrets in module repositories.** Registry configuration for the scope is normal npm/pnpm configuration. - **Credentials from the environment.** Read and publish credentials come from environment or user configuration: a read credential in module CI where reads need one, and a publish credential only in Ekklesia's release pipeline. - **No permanent provider.** - **Verify access before the first publication.** Check the chosen registry's actual private and read-access behaviour first. Ekklesia's packages must not be published unintentionally to a registry that serves them anonymously while Ekklesia is not public. ### The release workflow During 0.x, public package releases form a **coordinated SDK train** associated with platform releases: 1. build; 2. pack; 3. standalone tarball smoke (see [Supply chain](#supply-chain)); 4. publish immutable versions; 5. downstream module CI installs and tests those versions. ### Signage The intended result is a normal standalone Signage repository: - versioned `@ekklesia/*` dependencies; - its own lockfile; - continuous integration that installs, builds, packages, and verifies on its own. That CI must not clone Ekklesia or place Signage under `modules/`. The migration itself is a separate change. ## Consequences - A module repository is an ordinary pnpm project. Clone it, install, build, and package it with the canonical packager, with no Ekklesia checkout. - Module authors see a smaller, deliberate API, and the platform can change its internal contracts without breaking them. - One coordinated version keeps compatibility simple: SDK version X is what platform version X reports and accepts. The cost is republishing unchanged packages with each platform release. - A registry move costs each module a configuration change and a lockfile regeneration, but no source change. Package content stays verifiable by integrity hash throughout. - Before the first publication, the SDK surface must be curated and the packager's two monorepo assumptions removed. Both are breaking for code that relied on them. - ADR 0047, still proposed, assembles a module into the platform workspace to test it. Before it is accepted it must instead consume published packages and canonical module packages, consistent with this decision and with ADR 0048. ## Alternatives - **Git URL, tag, or commit dependencies.** Rejected: they tie modules to one Git host, need a build on every install because `dist` is not committed, and cannot express version ranges. - **Release-asset tarball URLs.** Rejected: each dependency becomes a host-specific URL, and ranges cannot be resolved. - **OCI artifacts for packages.** Rejected: npm and pnpm cannot install from them. (A harness image is a different matter; see ADR 0047.) - **Vendoring Ekklesia packages into each module repository.** Rejected: the copies drift, updates are manual, and there would be no single canonical SDK. - **Requiring host-neutral lockfiles, or an Ekklesia registry proxy that rewrites tarball URLs.** Not required: a one-time lockfile regeneration on a registry move is an acceptable cost, and integrity hashes already keep content verifiable. Ekklesia may still choose a stable endpoint later. - **Publishing `@ekklesia/contracts` as a direct author API.** Rejected: it holds platform-wide contracts. Modules get a curated surface through the SDK packages. - **Externalizing React from module UI bundles to share one runtime.** Rejected again: ADR 0023's reasoning stands, and peers only prevent duplicates within one bundle. - **Independent versions per package now.** Deferred: during 0.x a coordinated train is simpler for module authors and matches how the platform reports its SDK version. ## Deferred - Provisioning a registry, and its credentials. - Public publication on npmjs or any other public registry. - Package signing and provenance. - Independent package versioning. - Dashboard and UI redesign. - The Signage migration itself.