feat: define the module UI authoring model and host contract
ADR 0021 established that a module may own UI and added a statically
validated, never-executed `entrypoints.ui` path, but deliberately left
five questions open: what the artifact exports, what a host provides,
how one artifact runs under both Ekklesia and a standalone host, what
the browser-side trust model is, and how module UI avoids being welded
to a Dashboard that does not exist. This answers them.
React and TypeScript are the supported module UI authoring model. The
repository already leaned this way: `packages/typescript-config` ships a
`browser.json` preset and a `react-native.json` preset with
`"jsx": "react-jsx"`, and ADR 0002 names browser and React Native
configurations. One framework is supported; every other stays rejected.
React is deliberately not part of the contract. A module's compiled
artifact default-exports `defineModuleUi({ id, version, mount })`, and
`mount(container, context)` takes a DOM element and returns an unmount
function. `id` and `version` are build-artifact identity markers a host
verifies against the manifest, mirroring `defineModuleRuntime` exactly.
A host needs an element and a context, never a matching rendering
library, so a module's framework, a host's framework, and the platform
boundary between them stay independent.
The contract lives in a new `@ekklesia/module-ui-sdk` rather than in
`@ekklesia/module-sdk`, for a concrete reason: that package re-exports
all of `@ekklesia/contracts`, so routing module UI through it would drag
Zod, Drizzle, and semver into every browser bundle. The new package has
no runtime dependencies, and its one dependency on contracts is
type-only — the emitted `host-context.js` is `export {};`, and a test
pins that so it cannot regress.
The host context is four members: `moduleId`, `api`, `principal`, and
`permissions`. Navigation, theme, module configuration, organization
context, and diagnostics are deliberately absent; each needs a design
that does not exist. Module UI reaches its backend through
`api.request(path)` with a path relative to its own API root and never
constructs `/api/v1/modules/<id>/...`, which is Ekklesia's private
deployment detail — a module that builds that URL is silently unhostable
anywhere else. One fixture artifact is mounted under two hosts with
different origins and URL shapes to prove it.
Client-side permission hints are presentation only, and both the docs
and a test say so plainly: a denied hint does not stop the module from
calling its API, because server-side `requirePermission` is the only
authority. Module UI is trusted code in the host's own browser realm —
no iframe, no sandbox — which matches the backend model rather than
overstating it, since a module's server half already holds the API's
database authority.
The boundary guard is narrowed precisely, not removed: `.tsx`, `react`,
and `react-dom` are un-denied and modules may depend on the new
`module-ui-sdk` role. `.jsx`, `.css`, every other framework,
`react-router-dom`, `react-native`, and `@vitejs/plugin-react` stay
denied, layer rules are untouched, and an unsupported framework aliased
behind an allowed name (`"react": "npm:vue@3"`) is resolved and rejected
by its real target. Fixtures prove each case, including that module UI
source still cannot import Core.
Core, apps/api, and the manifest contract are untouched. Discovery still
validates the `entrypoints.ui` path and never evaluates it; hosts
perform their own dynamic import and validate the result with
`parseModuleUiExport`. No Dashboard shell, UI runtime, navigation,
grouping, standalone host, build or styling pipeline, installer,
hot-loading, or feature module is added.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b5aa4371b6
commit
8d651c427d
@@ -205,6 +205,15 @@ one that does not, and an operator installing either is deploying trusted code.
|
||||
|
||||
## Consequences
|
||||
|
||||
> **Cross-reference — see [ADR 0022](0022-module-ui-authoring-and-host-contract.md).**
|
||||
> The open question this section records — the module UI authoring model, and which parts of the
|
||||
> `modules-do-not-own-ui` guard change — is now answered. ADR 0022 selects React as the supported
|
||||
> authoring model, narrows the guard to allow `.tsx` plus `react`/`react-dom` while keeping every
|
||||
> other framework rejected, and defines what `entrypoints.ui` exports and what a host provides.
|
||||
> Everything else in this ADR remains accepted and unchanged: `entrypoints.ui` is still statically
|
||||
> validated and never executed by discovery, and Core still never loads browser code.
|
||||
|
||||
|
||||
A module author can now express the complete product they own, and the platform has one contract for
|
||||
discovering that a module offers presentation at all. Backend-only modules are unaffected beyond a
|
||||
one-character manifest version bump. The dependency graph stays flat and meaningful, and Dashboard
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
# 0022: Module UI authoring model and host contract
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-21
|
||||
- Extends: [0021](0021-modules-as-hostable-products.md)
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0021 established that a module is a product which may own its UI, and added `entrypoints.ui` —
|
||||
a statically validated, never-executed path to a compiled artifact. It deliberately answered none of
|
||||
the questions that make that field usable, and recorded the gap explicitly: the authoring model was
|
||||
"the first question the next pass must answer," and `pnpm boundaries` still rejected UI source and UI
|
||||
framework dependencies inside a module.
|
||||
|
||||
Five questions block every later Phase 6 pass:
|
||||
|
||||
1. What does a UI artifact export?
|
||||
2. What does a host give it?
|
||||
3. How does one artifact run under Ekklesia *and* a standalone host?
|
||||
4. What is the browser-side trust model?
|
||||
5. How does module UI avoid being welded to a Dashboard that does not exist?
|
||||
|
||||
The repository already leans one way on the first. `packages/typescript-config` ships a `browser.json`
|
||||
preset (DOM libs, JSX) and a `react-native.json` preset (`"jsx": "react-jsx"`), and ADR 0002 names
|
||||
"separate Node, browser, and React Native base configurations." No frontend framework is installed
|
||||
anywhere — the only React or Preact in the tree is inside boundary fixtures that exist to prove the
|
||||
guard rejects them.
|
||||
|
||||
## Decision
|
||||
|
||||
### React is the supported module UI authoring model
|
||||
|
||||
A module author writes module UI in React and TypeScript (`.tsx`). This is a real choice, not a
|
||||
neutrality dodge: the repository's own TypeScript presets already anticipate React, Ekklesia's future
|
||||
first-party UI will be React, and pretending otherwise would push every module author into an
|
||||
abstraction layer that serves nobody. One framework is supported. Vue, Svelte, Angular, Preact,
|
||||
Solid, Qwik, Lit, Astro, Marko, and React Native remain unsupported and remain rejected by
|
||||
`pnpm boundaries`.
|
||||
|
||||
**React is the supported authoring model. It is not part of the host contract.** That distinction is
|
||||
the whole design, and the next section is where it is enforced.
|
||||
|
||||
### The host contract is a mount function, not a component
|
||||
|
||||
A module's compiled `entrypoints.ui` artifact default-exports a `ModuleUiDefinition`
|
||||
(`packages/module-ui-sdk`):
|
||||
|
||||
```ts
|
||||
export default defineModuleUi({
|
||||
id: "announcements",
|
||||
version: "0.1.0",
|
||||
mount(container, context) {
|
||||
const root = createRoot(container);
|
||||
root.render(<Editor context={context} />);
|
||||
return () => root.unmount();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`id` and `version` are build-artifact identity markers, mirroring `ModuleRuntimeDefinition` exactly
|
||||
(ADR 0006): a host proves the bundle it imported was built from the module it thinks it is, and a
|
||||
stale artifact is caught rather than mounted. They duplicate nothing else from the manifest.
|
||||
|
||||
`mount` receives a container `Element` and the host context, and returns an unmount function or
|
||||
nothing. A module that acquires anything — a React root, a subscription, a timer, a listener —
|
||||
returns an unmount and releases it there, exactly as a backend module releases resources in `stop`.
|
||||
|
||||
React appears nowhere in that contract. A host needs an element and a context; it does not need to
|
||||
know, share, or match the module's rendering library. That keeps three things independent that would
|
||||
otherwise be welded together: the module's framework, the host's framework, and the platform
|
||||
boundary between them. React can be the supported way to *write* module UI while the *contract*
|
||||
outlives any particular version of it.
|
||||
|
||||
### `@ekklesia/module-ui-sdk` is a separate, browser-safe package
|
||||
|
||||
The contract lives in a new shared package with role `module-ui-sdk`, not in `@ekklesia/contracts` or
|
||||
`@ekklesia/module-sdk`. The reason is concrete rather than organizational: `@ekklesia/module-sdk`
|
||||
re-exports all of `@ekklesia/contracts`, which pulls Zod, Drizzle, and semver behind one barrel
|
||||
export. Routing module UI through it would drag a backend dependency graph into a browser bundle.
|
||||
|
||||
The package therefore has no runtime dependencies. Its one dependency on `@ekklesia/contracts` is
|
||||
type-only (`import type { EkklesiaPrincipal }`), erased at build time, so a host and a module UI
|
||||
provably share Core's principal type without shipping any of Core's runtime.
|
||||
|
||||
Both hosts consume this same package. It is not a Dashboard framework and must not become one: no
|
||||
components, no layout, no design system, no router.
|
||||
|
||||
### The host context is deliberately four members
|
||||
|
||||
```ts
|
||||
interface ModuleUiHostContext {
|
||||
readonly moduleId: string;
|
||||
readonly api: ModuleUiApiClient;
|
||||
readonly principal: EkklesiaPrincipal;
|
||||
readonly permissions: ModuleUiPermissions;
|
||||
}
|
||||
```
|
||||
|
||||
This is the smallest set that proves the architecture, not the set a finished screen will want.
|
||||
Navigation, host theme and design tokens, module configuration, organization/platform context, and
|
||||
diagnostics reporting are **not** included. Each needs a design that does not exist, and a
|
||||
speculative field frozen into a public contract costs more to remove than to add later. A host passes
|
||||
this object and nothing else; there is no platform singleton, no global, and no escape hatch to Core.
|
||||
|
||||
### Module UI addresses its own API relatively, never by URL
|
||||
|
||||
`ModuleUiApiClient.request(path, init)` takes a path **relative to the module's own API root** and
|
||||
returns a `Response`. The module asks for `posts`. The host decides what that means.
|
||||
|
||||
This is the single most important portability rule here. Ekklesia mounts module routes under
|
||||
`/api/v1/modules/<module-id>` (`apps/api/src/module-http-routes.ts`), but that prefix is Ekklesia's
|
||||
private deployment detail. A standalone host serving one module may expose the same routes at `/api`,
|
||||
at another origin, or behind a proxy. A module UI that builds
|
||||
`/api/v1/modules/announcements/posts` itself is silently welded to Ekklesia and can never be hosted
|
||||
anywhere else — and the failure is invisible until someone tries.
|
||||
|
||||
`Request`/`Response` are the same Fetch vocabulary a module's backend routes already speak (ADR
|
||||
0015), so one module speaks one HTTP shape on both sides of the wire. Credentials, base URL, and any
|
||||
platform headers are the host's responsibility; a module UI neither supplies nor sees a session
|
||||
token. No backend routing changed in this pass.
|
||||
|
||||
### Module UI is trusted first-party code in the host's realm
|
||||
|
||||
Module UI runs in the same browser JavaScript realm as the host shell. There is no iframe, no
|
||||
sandbox, no cross-origin isolation, and no capability confinement. A module's UI can reach the host
|
||||
page, the DOM, `window`, and any global the browser exposes, exactly like any other script the
|
||||
application loads.
|
||||
|
||||
**This is a trusted-code model, stated plainly rather than dressed up.** It matches, and does not
|
||||
extend, the backend model ADR 0021 preserved: installing a module already means running its code in
|
||||
the API process with the API's PostgreSQL authority. A module whose server code is trusted with the
|
||||
database is not meaningfully constrained by sandboxing its browser code.
|
||||
|
||||
Introducing an iframe or a separate origin now would add real cost — postMessage plumbing, styling
|
||||
seams, focus and accessibility breakage — to buy theoretical separation from code that already has
|
||||
server-side authority. That trade only becomes worthwhile for genuinely untrusted third-party
|
||||
modules, which this platform does not accept (see
|
||||
[Security model and deployment trust boundaries](../../development/security.md)). Nothing in this
|
||||
contract prevents a future sandboxed tier: a host that wanted one could implement `mount` against an
|
||||
isolated realm without changing this interface, because the contract is a mount function and a
|
||||
plain-data context rather than shared object graphs.
|
||||
|
||||
### Client-side permission checks are presentation, never enforcement
|
||||
|
||||
`ModuleUiPermissions.has(permissionId)` tells a module UI what the host believes, so it can hide an
|
||||
action, disable a control, or pick an empty state. It is not a security boundary and must never be
|
||||
described as one.
|
||||
|
||||
Server-side authorization remains the only authority. Every module route is enforced by Core's
|
||||
`requirePermission` middleware against the composed platform permission registry (ADR 0013/0014),
|
||||
and that enforcement neither knows nor cares what a browser decided. A module UI that consults
|
||||
`has()` and then calls its API is still fully authorized server-side; one that skips the check and
|
||||
calls anyway is still denied. The value is that a user does not see a button that would fail.
|
||||
|
||||
`permissionId` is a plain `string`, not Core's branded `PermissionId`, deliberately: branding would
|
||||
force a module UI to run Zod in the browser to produce a value for a call that decides nothing
|
||||
security-relevant. The branded type remains the server-side type, where it matters.
|
||||
|
||||
### Discovery still never executes UI, and Core still never loads it
|
||||
|
||||
`parseModuleUiExport(namespace, expected)` validates an already-imported module namespace. It does
|
||||
not import anything. Each host performs its own dynamic import — a browser host from a served URL, a
|
||||
test host from a file URL — and passes the resulting namespace in.
|
||||
|
||||
That keeps this package free of any loader, bundler, or URL assumption, and it keeps Core out of the
|
||||
business of evaluating browser code entirely. Module discovery continues to resolve and validate the
|
||||
`entrypoints.ui` path and never execute it (ADR 0021), and this pass adds no UI loading to
|
||||
`core/`, to `apps/api`, or to any startup path. Nothing about restart-based discovery changed, and
|
||||
nothing about explicit operator-invoked migrations changed.
|
||||
|
||||
### Dashboard organization stays outside this contract
|
||||
|
||||
The contract carries no navigation entry, route, title, icon, folder, group, or ordering field, and
|
||||
none may be added as a hierarchy mechanism. ADR 0021 established that Dashboard grouping is
|
||||
presentation configuration an administrator reorganizes, never module architecture; adding any of it
|
||||
here would quietly reverse that, because a field in the UI contract is a field a module owns.
|
||||
|
||||
### The boundary guard is narrowed precisely, not removed
|
||||
|
||||
`pnpm boundaries` keeps enforcing `modules-do-not-own-ui`. Exactly three things changed:
|
||||
|
||||
- `.tsx` is no longer a rejected source extension. `.jsx` stays rejected (this repository is
|
||||
strict-TypeScript throughout) and `.css` stays rejected (module styling and the module UI build
|
||||
pipeline are not designed yet, and a stylesheet would imply a bundling contract that does not
|
||||
exist).
|
||||
- `react` and `react-dom` are no longer rejected dependencies. Every other framework stays rejected,
|
||||
including `react-router-dom` (routing belongs to the host), `react-native` (this pass supports
|
||||
React on the DOM only), and `@vitejs/plugin-react` (the build pipeline is undesigned).
|
||||
- Modules may depend on the new `module-ui-sdk` shared role, which itself may depend only on
|
||||
`@ekklesia/contracts`.
|
||||
|
||||
Layer rules are untouched. Module UI source is subject to the identical import boundaries as module
|
||||
backend source: it cannot import Core, an app, a surface, a builder, another module, or a shared
|
||||
package outside the roles a module is allowed. An unsupported framework aliased behind an allowed
|
||||
name (`"react": "npm:vue@3"`) is resolved and rejected by target, so permitting React did not create
|
||||
a way to smuggle anything else in.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **A React component as the export** (`export default function Ui(props)`). Rejected: it would put
|
||||
React into the platform contract, force every host — including standalone — onto the same React
|
||||
major, and make a framework upgrade a breaking change to the module boundary. A mount function
|
||||
costs a module author three lines and buys independence for all three parties.
|
||||
- **Web Components as the authoring model.** Rejected as the *authoring* model: it would mean every
|
||||
module author writes to a lower-level API than the one Ekklesia's own UI will use, and styling,
|
||||
form handling, and state would all need re-solving per module. Notably, the mount contract does not
|
||||
forbid a module from using them internally — it only declines to require them.
|
||||
- **A serialized UI description** (`{"type": "form", "fields": [...]}`) rendered by the host.
|
||||
Rejected outright. It is a frontend framework in disguise, it can only ever express what its schema
|
||||
anticipated, and the Announcements editor that motivated ADR 0021 is exactly the kind of screen
|
||||
such a system cannot express. Ekklesia is not building its own frontend framework.
|
||||
- **Putting the contract in `@ekklesia/module-sdk`.** Rejected: that package re-exports all of
|
||||
`@ekklesia/contracts`, so a module UI bundle would pull Zod, Drizzle, and semver into the browser.
|
||||
- **Passing a single platform object** (`context.platform`, `context.core`, a store). Rejected: it
|
||||
makes every internal a de facto public API, and nothing that reaches a module can be changed
|
||||
afterwards without breaking one. Four narrow members can grow; a platform object can only leak.
|
||||
- **An absolute API base URL** (`context.apiBaseUrl`) instead of a request function. Rejected: a
|
||||
module that concatenates a base URL still assumes a path shape underneath it, and it puts URL
|
||||
construction — and therefore credential and header policy — back inside the module.
|
||||
- **Iframe or cross-origin sandboxing now.** Rejected as premature and misleading: real cost today,
|
||||
no real containment for code whose backend half already holds the API's database authority, and a
|
||||
sandbox claim this repository would not be able to substantiate.
|
||||
- **Waiting for the Dashboard before defining the contract.** Rejected: the Dashboard would then
|
||||
define the contract by accident, and every Ekklesia-specific assumption it happened to make would
|
||||
become the standalone host's problem later.
|
||||
- **Allowing `.css` in modules now.** Rejected: it implies a bundling and style-isolation contract
|
||||
that this pass does not design. Deferred deliberately rather than half-answered.
|
||||
|
||||
## Consequences
|
||||
|
||||
A module author can now write a real screen in React, against a documented four-member host context,
|
||||
and know exactly what the artifact must export. Two hosts can consume that artifact with no shared
|
||||
code beyond one small package, and the test suite proves it by mounting one fixture artifact under
|
||||
two hosts whose URL shapes and origins differ.
|
||||
|
||||
The cost is that this is still a contract without a production consumer. No Dashboard mounts a module
|
||||
UI, no module ships one, and the build pipeline that turns a module's `.tsx` into the compiled
|
||||
`entrypoints.ui` artifact is not designed — `browser.json` uses `"jsx": "preserve"`, which needs a
|
||||
bundler that no module has, and `@vitejs/plugin-react` remains denied. Choosing between a `tsc`-only
|
||||
JSX preset and a real bundler, together with the styling question, is the next pass's work and is the
|
||||
reason `.css` stays rejected today.
|
||||
|
||||
A second cost is deliberate duplication of effort in the browser: if both the host and a module
|
||||
bundle React, a page can load React twice. Sharing one copy through peer dependencies, bundler
|
||||
externals, or an import map is a build-pipeline optimization, not a contract change, and the mount
|
||||
contract is unaffected by whichever is chosen.
|
||||
|
||||
## Deferred
|
||||
|
||||
Not implemented, designed, or promised here: the Dashboard application shell, homepage, or
|
||||
navigation UI; module navigation registration; Dashboard folders, groups, or drag-and-drop
|
||||
organization; a design system, component library, or theme/design-token contract; the module UI build
|
||||
and styling pipeline; a standalone application host; a module installer, marketplace, or manager;
|
||||
hot-loading or runtime unloading; automatic migrations; sandboxed or untrusted module tiers; and
|
||||
every real feature module. `entrypoints.jobs` still accepts source TypeScript where other executable
|
||||
entrypoints require compiled output — that inconsistency is unchanged and remains open, because this
|
||||
pass did not centralize executable-entrypoint validation and fixing it here would have been unrelated
|
||||
scope.
|
||||
@@ -30,3 +30,4 @@ ADRs capture durable choices that affect more than one package or phase.
|
||||
- [0019: Durable authorization history and grant management](0019-durable-authorization-history-and-grant-management.md) — Accepted
|
||||
- [0020: Module runtime and migration execution hardening](0020-module-runtime-and-migration-execution-hardening.md) — Accepted
|
||||
- [0021: Modules as independently hostable products](0021-modules-as-hostable-products.md) — Accepted
|
||||
- [0022: Module UI authoring model and host contract](0022-module-ui-authoring-and-host-contract.md) — Accepted
|
||||
|
||||
@@ -30,7 +30,9 @@ The four ownership layers are:
|
||||
1. **Core** orchestrates generic platform infrastructure: startup, identity, permissions,
|
||||
configuration, database connectivity, storage, events, jobs, module discovery, and lifecycle.
|
||||
2. **Modules** own domain behavior, data schemas, resources, actions, routes, events, permissions,
|
||||
jobs — and, per [ADR 0021](decisions/0021-modules-as-hostable-products.md), their own UI. A module
|
||||
jobs — and, per [ADR 0021](decisions/0021-modules-as-hostable-products.md), their own UI, authored
|
||||
in React against `@ekklesia/module-ui-sdk`
|
||||
([ADR 0022](decisions/0022-module-ui-authoring-and-host-contract.md)). A module
|
||||
is an independently versioned product, not only a backend capability. It does not own the
|
||||
containing application shell or any platform policy.
|
||||
3. **Surfaces** own the presentation concerns a module does not: the containing application shell,
|
||||
@@ -163,20 +165,40 @@ blocks Phase 6. Read
|
||||
does and does not protect against, and the repository root
|
||||
[README](../../README.md#current-status) for the current status of every phase.
|
||||
|
||||
**Phase 6A establishes the module product contract.**
|
||||
**Phase 6A established the module product contract.**
|
||||
[ADR 0021](decisions/0021-modules-as-hostable-products.md) replaces the backend-only module model:
|
||||
a module is an independently versioned product that may own its own UI, hostable inside Ekklesia and
|
||||
— later, from the same implementation — as a standalone application. Core keeps the host shell and
|
||||
every platform policy, the module graph stays flat, and Dashboard grouping stays presentation
|
||||
configuration.
|
||||
|
||||
What this pass actually implements is the **static contract only**: `manifestVersion: 4` adds one
|
||||
optional `entrypoints.ui` field, validated during discovery exactly like the server and schema
|
||||
entrypoints — package-relative, inside the package, compiled ESM. Discovery remains inert; nothing
|
||||
imports, mounts, or renders that entrypoint. The Dashboard shell, the UI runtime, the standalone
|
||||
host, module UI authoring (including the still-enforced `modules-do-not-own-ui` boundary rule), and
|
||||
every feature module remain future work. Restart-based discovery, explicit operator-invoked
|
||||
migrations, and the trusted compiled-module model are all unchanged.
|
||||
What that pass implemented is the **static contract only**: `manifestVersion: 4` adds one optional
|
||||
`entrypoints.ui` field, validated during discovery exactly like the server and schema entrypoints —
|
||||
package-relative, inside the package, compiled ESM. Discovery remains inert; nothing imports, mounts,
|
||||
or renders that entrypoint.
|
||||
|
||||
**Phase 6B pass 1 defines the module UI authoring model and host contract.**
|
||||
[ADR 0022](decisions/0022-module-ui-authoring-and-host-contract.md) selects React and TypeScript as
|
||||
the supported way to author module UI, and defines what a compiled `entrypoints.ui` artifact exports
|
||||
and what a host provides it. React is the authoring model but deliberately not the contract: an
|
||||
artifact default-exports `defineModuleUi({ id, version, mount })`, and `mount(container, context)`
|
||||
takes a DOM element and returns an unmount function, so a module's framework and a host's stay
|
||||
independent.
|
||||
|
||||
The contract lives in `@ekklesia/module-ui-sdk`, a browser-safe shared package with no runtime
|
||||
dependencies, consumed by module authors and hosts alike. Its host context is four members —
|
||||
`moduleId`, `api`, `principal`, `permissions`. Module UI reaches its own backend through
|
||||
`api.request(path)` with a path relative to the module's own API root, never by constructing
|
||||
Ekklesia's `/api/v1/modules/<module-id>` prefix, which is what keeps a standalone host possible.
|
||||
Client-side permission hints are presentation only; server-side enforcement remains the sole
|
||||
authority. Module UI is trusted code in the host's browser realm — there is no sandbox, matching the
|
||||
backend trust model rather than overstating it.
|
||||
|
||||
Core is untouched by this: discovery still validates the `entrypoints.ui` path and never evaluates
|
||||
it, and hosts perform their own dynamic import. The Dashboard shell, a UI runtime, the module UI
|
||||
build and styling pipeline, the standalone host, and every feature module remain future work.
|
||||
Restart-based discovery, explicit operator-invoked migrations, and the trusted compiled-module model
|
||||
are all unchanged.
|
||||
|
||||
Target architecture not yet implemented includes production approval-provider
|
||||
composition, physical storage-bucket provisioning, jobs/workflow automation, richer public resource
|
||||
|
||||
@@ -7,7 +7,7 @@ These rules map platform ownership to filesystem and package boundaries. The exe
|
||||
| --- | --- | --- |
|
||||
| `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 (per ADR 0021) the module's own UI | Module SDK, public contracts, and external backend libraries |
|
||||
| `modules/*` | Domain capability, data, and the module's own UI | Module SDK, module UI SDK, public contracts, React, and external backend libraries |
|
||||
| `surfaces/*` | UI, renderers, navigation, and interaction | Surface SDK, API/auth clients, public contracts, and design packages |
|
||||
| `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 |
|
||||
@@ -17,14 +17,18 @@ These rules map platform ownership to filesystem and package boundaries. The exe
|
||||
|
||||
- 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 cannot contain presentation source, including JSX/TSX, single-file components,
|
||||
Astro/Marko/MDX/HTML views, stylesheets, or SVG presentation assets, and cannot depend on UI
|
||||
frameworks or shared UI-role packages. **This rule is still enforced, but it is no longer the
|
||||
architectural direction.** [ADR 0021](decisions/0021-modules-as-hostable-products.md) establishes
|
||||
that a module may own its own UI and declare a compiled `entrypoints.ui` artifact. The deny list
|
||||
above stays in force until a later Phase 6 pass chooses the module UI authoring model and decides
|
||||
what replaces it; until then a module UI must be a framework-neutral compiled artifact. Core still
|
||||
owns the containing application shell and all platform policy.
|
||||
- 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`. It still cannot contain `.jsx`, stylesheets, 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 shared UI-role
|
||||
packages. 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. Core still owns the containing
|
||||
application shell and all platform policy, and module UI reaches the host only through
|
||||
`@ekklesia/module-ui-sdk`.
|
||||
- A surface never imports Core or a feature module. It uses public API/client contracts.
|
||||
- Separate surfaces do not import each other's internals.
|
||||
- Builder code composes contracts and surface renderer identifiers; it does not import module or
|
||||
@@ -44,8 +48,9 @@ The boundary test suite includes intentionally invalid fixture graphs. This prov
|
||||
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 — and revisiting the guard itself is
|
||||
an explicit prerequisite of the module UI authoring pass (ADR 0021).
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user