@ekklesia/module-sdk imports drizzle-orm at run time but declared it only as a peer and a development dependency. A workspace package is linked into its consumers as a directory, so its imports resolve from its own node_modules, where a consumer never satisfies a peer; a production install omits development dependencies. In the production image this made every module with a schema fail: the API exited at startup and Doctor reported NOT READY. - Move drizzle-orm to module-sdk's dependencies, keeping the peer. - Move react to @ekklesia/ui's dependencies, keeping the peer. The new guard flags it the same way; it is bundled at build time today, so it was not a live failure. Both lockfile entries keep the identical resolution, so nothing new is installed and the shell bundles still contain one React. - Add the boundary rule runtime-imports-are-production-dependencies: code a package ships (src/, bin/, excluding tests and .d.ts) must declare every runtime import in dependencies or optionalDependencies. Type-only imports are exempt; .tsx/.jsx implies react (automatic JSX runtime). Fixtures cover peer-only, dev-only, JSX, undeclared, and the accepted cases. Co-Authored-By: Claude Opus 5.5 <[email protected]>
176 lines
14 KiB
Markdown
176 lines
14 KiB
Markdown
# Repository boundaries
|
|
|
|
These rules map platform ownership to filesystem and package boundaries. The executable check is
|
|
`pnpm boundaries`; CI must reject violations.
|
|
|
|
| Area | Owns | May depend on |
|
|
| --- | --- | --- |
|
|
| `apps/*` | Thin deployable composition roots | Public Core APIs, generated bootstrap, or a surface as required by the app |
|
|
| `core` | Feature-agnostic platform implementations | Public contracts and external infrastructure libraries |
|
|
| `modules/*` | Domain capability, data, and the module's own UI | Module SDK, module UI SDK, the approved design packages, public contracts, React, and external backend libraries |
|
|
| `surfaces/*` | The application shell, UI, renderers, navigation, and interaction | Surface SDK, API/auth clients, the module UI SDK and host loader, design packages, and a browser framework |
|
|
| `builders/*` | Declarative surface composition tooling | Builder/surface SDKs and public contracts |
|
|
| `packages/*` | Explicitly scoped SDKs, contracts, clients, design, testing, or tooling | Only dependencies appropriate to the package's declared level |
|
|
| `templates/module` | A small example backend module | The same dependencies allowed for any module |
|
|
|
|
## Mandatory rules
|
|
|
|
- Core never imports a feature module, surface, Builder implementation, or app.
|
|
- A module never imports Core implementations, another module, a surface, Builder code, or an app.
|
|
- A module may own UI ([ADR 0021](decisions/0021-modules-as-hostable-products.md)) and authors it in
|
|
React and TypeScript ([ADR 0022](decisions/0022-module-ui-authoring-and-host-contract.md)). A
|
|
module may therefore contain `.tsx` source and depend on `react`, `react-dom`, and
|
|
`@ekklesia/module-ui-sdk`. Since
|
|
[ADR 0040](decisions/0040-module-stylesheets-and-artifact-isolation.md) it may also contain plain
|
|
`.css`, but **only** inside a browser-artifact source root the canonical builder compiles
|
|
(`src/ui/`, `src/display/`, `src/contributions/`); a stylesheet anywhere else in a module is a file
|
|
no build reads and no host loads. It still cannot contain `.jsx`, other stylesheet dialects, single-file components,
|
|
Astro/Marko/MDX/HTML views, or SVG presentation assets, and cannot depend on any other UI framework
|
|
(including `react-router-dom`, `react-native`, and `@vitejs/plugin-react`) or on any shared role
|
|
beyond the two approved design packages named below. A module needs no bundler plugin: `@ekklesia/module-ui-build` — a `tooling` package,
|
|
a role modules could already depend on — owns the entire module UI build
|
|
([ADR 0023](decisions/0023-module-ui-build-pipeline.md)). An unsupported framework aliased behind an allowed name is resolved and rejected by its
|
|
real target.
|
|
- Module UI source is subject to the identical layer rules as module backend source: it may not
|
|
import Core, an app, a surface, a builder, or another module. It also may not import
|
|
`@ekklesia/module-ui-host`: that package is host infrastructure, and a module bundling the loader
|
|
would blur the author/host split ([ADR 0024](decisions/0024-browser-safe-ui-sdk-and-host-loader.md)).
|
|
- A module may consume exactly two shared **design** roles: `design-tokens` (`@ekklesia/design-tokens`)
|
|
and `ui` (`@ekklesia/ui`), added by
|
|
[ADR 0039](decisions/0039-shared-design-tokens-and-elements.md). This is a named allowlist, not a
|
|
general "browser-safe shared packages are fine" relaxation: `api-client`, `auth-client`,
|
|
`surface-sdk`, and `module-ui-host` remain rejected inside a module. Both approved packages are
|
|
browser-safe by construction — `design-tokens` has no dependencies at all and `ui` depends only on
|
|
it and React — so a module UI file that imports them still cannot typecheck `process.env`, proven by a real
|
|
compile under the exact browser project a module uses. Both are also pinned at the bottom of the
|
|
presentation stack by `design-packages-depend-downward-only`: neither may reach an implementation
|
|
layer, an API/auth client, or the backend contracts barrel, because a design package that could
|
|
would put all of it inside every module artifact that renders a button.
|
|
- A module **references** registered global semantic tokens and may derive private,
|
|
differently-prefixed aliases from them inside its own artifact. It may never define an
|
|
`--ekklesia-*` custom property: that would invent a global theme variable outside the registry,
|
|
which a future theme editor could not enumerate and another module could unknowingly depend on.
|
|
- `@ekklesia/module-ui-sdk` has no dependencies at all, and `@ekklesia/module-ui-host` depends only
|
|
on it. Both are browser-safe by construction: neither can pull a backend type graph — and with it
|
|
Node's global declarations — into browser code. Core still owns the containing
|
|
application shell and all platform policy, and module UI reaches the host only through
|
|
`@ekklesia/module-ui-sdk`.
|
|
- A module stylesheet is compiled **into** the one declared JavaScript artifact as text and installed
|
|
by the host at mount; there is no sidecar file, no second publication route, and no manifest field
|
|
for one. Every browser artifact is mounted inside its own Shadow DOM root the generic loader
|
|
creates, so one module's styles cannot reach the Dashboard, another module, or another renderer,
|
|
and the host's cannot reach in. That root is a correctness boundary for styles, never a sandbox:
|
|
module code remains trusted, same-realm code. The browser mount ABI is versioned
|
|
(`MODULE_UI_ABI_VERSION`) and a mismatched artifact fails closed rather than mounting under guessed
|
|
semantics.
|
|
- A module may declare **browser contributions** (`manifest.contributions`, v7): compiled renderers
|
|
it offers to capabilities *other* modules provide
|
|
([ADR 0041](decisions/0041-module-browser-contributions.md)). Core validates identity,
|
|
compatibility, and the artifact path through the identical resolution every other compiled
|
|
entrypoint uses, and knows nothing about what a contribution renders — there is no payload schema,
|
|
slot, ordering, or rendering hint anywhere in the platform. A contribution is published only when
|
|
another eligible module provides its target capability within the declared range; otherwise it is
|
|
absent rather than broken. A consuming module discovers contributions through
|
|
`ModuleUiHostContext.contributions` and mounts them by id; it never constructs a publication URL,
|
|
and it can only ever see contributions aimed at a capability it itself provides. A contributed
|
|
renderer receives its own id and the consumer's opaque JSON input, and no database, storage,
|
|
credential, session, or API client of its own.
|
|
- Manifest v7 permits independently optional primary (`entrypoints.ui`) and full-viewport display
|
|
(`entrypoints.display`) artifacts. Modules own both feature experiences; surfaces own their
|
|
containers, routes, navigation/chrome, credentials, error states, and publication wiring. The
|
|
fixed authoring sources are `src/ui/index.tsx` and `src/display/index.tsx`; both use the same
|
|
role-neutral UI SDK ABI and neither may import a surface or host loader.
|
|
- Module UI **asset publication** runs Core → `apps/api`, never the other way
|
|
([ADR 0025](decisions/0025-trusted-module-ui-asset-publication.md)). Core owns the policy
|
|
(`createModuleUiPublicationRegistry`, built only from already-validated `DiscoveredModule`
|
|
records); `apps/api` owns the HTTP route and the one canonical URL construction. Neither
|
|
`@ekklesia/module-ui-host` nor `@ekklesia/module-ui-sdk` learns anything about how Ekklesia
|
|
publishes artifacts — the loader takes a URL and stays host-neutral — and no module may reach
|
|
publication internals. This introduced no new package and no boundary relaxation: `apps/*` could
|
|
already depend on Core and on shared packages.
|
|
- A publication helper accepts module **identity** only. Neither Core's registry nor the API's URL
|
|
builder takes a filename, relative path, or any other artifact selector, so no caller can use one
|
|
to reach a second file inside a module package.
|
|
- A surface never imports Core or a feature module. It uses public API/client contracts, and reaches
|
|
the server over HTTP.
|
|
- A surface may depend only on browser-safe shared roles (`api-client`, `auth-client`,
|
|
`design-tokens`, `module-ui-host`, `module-ui-sdk`, `surface-sdk`, `ui`) plus dev-time `testing`
|
|
and `tooling`. **`@ekklesia/contracts` is deliberately excluded**, despite being a "public
|
|
contract" package: its barrel pulls Drizzle's types, and with them Node's global declarations, into
|
|
any program that imports it — measured in
|
|
[ADR 0024](decisions/0024-browser-safe-ui-sdk-and-host-loader.md), which removed exactly that
|
|
import from `@ekklesia/module-ui-sdk` to close the browser boundary. `module-sdk` re-exports the
|
|
same barrel and is excluded for the same reason. A surface declares its own types for the wire and
|
|
validates what arrives ([ADR 0026](decisions/0026-minimal-ekklesia-module-ui-host.md)).
|
|
- Dashboard navigation configuration is **Core-owned platform state**, never module-owned: Core
|
|
stores groups and placements and resolves the view, `apps/api` exposes it, and `surfaces/shell`
|
|
renders it. A group is presentation configuration and never module structure — it has no manifest,
|
|
version, artifact, or lifecycle, and nothing in discovery, dependency resolution, or module startup
|
|
may ever read it
|
|
([ADR 0027](decisions/0027-dashboard-navigation-and-flat-modules.md)). A module's manifest declares
|
|
its own display name and nothing about where it is presented.
|
|
- The browser host (`surfaces/shell`) statically imports no module. Every installed module UI it can
|
|
mount arrives at run time as a URL from the module UI inventory, is fetched by the generic loader,
|
|
and is never bundled into the shell. Its own build (esbuild, owned by that package) is a separate
|
|
concern from `@ekklesia/module-ui-build`, which compiles a *module's* declared artifact.
|
|
- Dashboard login, logout, and session-transition UX are surface-owned
|
|
([ADR 0034](decisions/0034-host-owned-browser-authentication.md)). They use the API over HTTP and
|
|
must not add credentials, cookies, tokens, or auth methods to `ModuleUiHostContext`; modules keep
|
|
receiving only the host-neutral principal and permission hints already in the UI SDK.
|
|
- The display browser application is a separate surface entrypoint under `surfaces/shell`; it also
|
|
statically imports no module and mounts only the exact display descriptor/artifact supplied by the
|
|
API. It ships a third fixed asset, a service worker scoped to `/display/`, that caches **only** the
|
|
display shell and the declared browser artifacts it actually loaded
|
|
([ADR 0043](decisions/0043-display-offline-code-fallback.md)). It never caches the descriptor,
|
|
`/api/v1/principal`, any module or platform API response, the realtime stream, a pairing exchange,
|
|
or any cross-origin request — so a revoked device stays revoked and cached code can never
|
|
manufacture current authorization. Caching is network-first, so a screen cannot be trapped on
|
|
obsolete code. It contains no Dashboard chrome and does not move display rendering into the surface—the
|
|
module still owns that renderer.
|
|
- Separate surfaces do not import each other's internals.
|
|
- Builder code composes contracts and surface renderer identifiers; it does not import module or
|
|
surface internals.
|
|
- Contract and SDK packages cannot depend upward on implementations.
|
|
- Workspace package consumers use declared `exports`, not private subpaths.
|
|
- Every internal dependency uses a `workspace:` range and every imported workspace package is
|
|
declared by the consumer.
|
|
- Code a workspace package ships (its `src/` and `bin/`, excluding tests and declaration files)
|
|
declares every runtime import in `dependencies` or `optionalDependencies`. A peer declaration may
|
|
stay beside it, but never replaces it: a workspace package is linked into its consumers as a
|
|
directory, so its imports resolve from its own `node_modules`, where a consumer never satisfies a
|
|
peer, and a production install (`pnpm install --prod`, as the production image runs) omits
|
|
development dependencies. Type-only imports are exempt; a `.tsx`/`.jsx` file counts as importing
|
|
`react`, because the automatic JSX runtime imports `react/jsx-runtime` implicitly
|
|
(`runtime-imports-are-production-dependencies`).
|
|
- Workspace aliases are forbidden because they obscure package ownership in the dependency graph.
|
|
- Workspace package names are unique and shared packages declare a recognized architectural role.
|
|
- Circular source and workspace-package dependencies are rejected.
|
|
- Cross-module data access uses services, events, capabilities, or opaque identifiers rather than
|
|
direct table imports and foreign keys. Modules may not import another module's Drizzle tables,
|
|
columns, relations, schema entrypoint, or migration internals.
|
|
|
|
The design packages are covered the same way: one fixture proves a module may depend on both, one
|
|
proves every other shared role stays rejected, and one proves neither design package may depend
|
|
upward.
|
|
|
|
The boundary test suite includes intentionally invalid fixture graphs. This proves that the guard
|
|
rejects every currently machine-checkable repository rule even before production packages fill
|
|
every directory. The UI guard rejects component, view, template, stylesheet, shared UI-role, and
|
|
common UI-framework dependencies inside modules. This deny list is defense in depth; package
|
|
review remains required when adopting a new frontend technology. ADR 0022 narrowed the guard
|
|
precisely rather than removing it: React was un-denied, every other framework stayed denied, and the
|
|
layer rules were untouched.
|
|
|
|
The Phase 3 schema validator complements source boundaries by checking table ownership, foreign-key
|
|
targets, and physical table namespaces in compiled Drizzle metadata. Offline migration validation
|
|
checks each module's packaged Drizzle inventory and policy contract, while deliberately making no
|
|
claim that opaque SQL ownership can be proven without PostgreSQL-aware analysis.
|
|
|
|
## Generated code
|
|
|
|
Generated artifacts are reproducible outputs with a named generator and validation step. A
|
|
generated module bootstrap under the root `generated/` boundary may import discovered module entry
|
|
points, and apps may import that generated bootstrap. Handwritten app and Core code still cannot
|
|
import feature modules directly. Generated files are never an excuse for a hand-maintained central
|
|
module registry.
|