2. Systems & deployment topology
The outermost shells: a system groups subdomains and contexts (the pure domain) and then describes how to ship that domain as one or more deployables — each pinned to a backend or frontend platform, bound to its contexts and data sources, and composed into a single docker compose stack. Reach for this chapter when you're deciding what runs where, on which stack, behind which UI, and how the pieces wire together.
Grammar:
System,SystemMember,Subdomain,BoundedContext,Deployable,UiSugarBinding/UiComposeBinding,Platform,Framework,DesignPack,ThemeBlock,TenancyDecl· Validators:checkDeployable/checkDeployablePlatform/checkDeployableRealizationAxes(src/language/validators/deployable.ts) —loom.ui-binding-unmountable-platform,loom.ui-framework-unhostable,loom.<framework>-deployable-missing-ui,loom.platform-knob-out-of-menu,loom.platform-knob-style-layout-mismatch;loom.top-level-domain-needs-single-system,loom.duplicate-theme-block(composition.ts);checkTheme+loom.a11y-theme-contrast(ui.ts,a11y.ts);loom.duplicate-host-port,loom.duplicate-service-slug,loom.channelsource-unbound,loom.deployable-channel-unrelated,loom.file-field-needs-object-storage(src/ir/validate/checks/system-checks.ts);loom.tenancy-*(tenancy.ts) · Docs:../architecture.md,../platforms.md,../design-packs.md,../tenancy.md,../channels.md
Everything below was generated from one scratch system (system Shop — one Orders context served by five backend deployables, one per platform, plus a React, a Feliz and a Flutter frontend) via node bin/cli.js generate system shop.ddd -o out. The compose stanzas and directory trees are excerpted verbatim from that run.
system
'system' name=ID '{' members* '}' — the top-level deployment grouping. Its member vocabulary (SystemMember) is: subdomain, context, deployable, storage, resource, channelSource, timerSource, api, ui, layout, theme, user, auth, tenancy by …, capability, a top-level function, and test e2e. Most of these are also admissible at file top level and fold into the project's single system; the exceptions that only parse inside system { … } are tenancy by … (see Tenancy) and timerSource.
system Shop {
subdomain Sales {
context Orders {
aggregate Order { reference: string, total: money }
}
}
// … apis, storage, deployables …
}
One system per project. The composition validator (src/language/validators/composition.ts) folds every top-level subdomain / context / deployable / ui / … into the project's single system; when the import graph declares zero or more than one system { … } and there is anything to fold, every foldable top-level declaration is flagged with loom.top-level-domain-needs-single-system. This is what lets you split one-file-per-subdomain with the deployment in its own file (see implicit-system-composition). The system may be just a name plus deployment declarations.
A project with no system block still parses and generates per-deployable code via the legacy generate ts / generate dotnet single-file path — generate system emits only the root .loom/ artefact bundle without one.
No generated tab: system is a container; its members emit, it does not.
subdomain & context
Subdomain: 'subdomain' name=ID '{' (contexts | permissions)* '}'
BoundedContext: 'context' name=ID withClause? '{' members* '}'
A subdomain is a logical grouping with no code of its own — it clusters contexts (and an optional permissions { … } catalogue, Auth). A context is the bounded context: it owns the domain declarations and is the unit a deployable hosts and an api derives from.
Lives in subdomain |
Lives in context (ContextMember) |
|---|---|
context children, permissions { … } |
enum, valueobject, aggregate, event, payload/command/query/response/error, repository, workflow, projection, criterion, retrieval, seed, channel, domainService, policy, commandHandler / queryHandler, filter / stamp / implements, a hoisted test … for <Aggregate> |
subdomain Sales {
context Orders {
enum Status { Pending, Shipped }
aggregate Order {
reference: string
total: money
status: Status
}
}
}
The split matters for the deployment layer: deployables reference contexts (contexts: [Orders]), and an api … from Sales derives its contract from a subdomain. A context name must be unique across the whole project; cross-context aggregate references must spell out X id (a bare type ref only resolves within the same context).
No generated tab on its own — the domain inside a context is the subject of Aggregates & domain model. What this chapter shows is how that context is shipped.
deployable
Deployable:
'deployable' name=LooseName '{'
'platform' ':' Platform ('{' realization-axes '}')?
( contexts: [...] | dataSources: [...] | channels: [...] | targets: Deployable
| serves: Api, ... | ui: Ui | ui: Ui { Param: Deployable, ... } | hosts: [Ui...]
| port: INT | auth: required|ui | design: DesignPack | favicon: STRING )*
'}'
A deployable is one shippable project. platform: is the lead clause (and the only required one); every other clause is order-independent. A backend deployable lists the contexts: it hosts, the dataSources: binding those contexts to physical storage (plus channels: for broker-transported events — Channels), and the serves: api contracts it exposes. A frontend deployable targets: a backend (inheriting its module set), mounts a ui:, and picks a design: pack. port: is the host port published in compose; the container's internal port is the platform default. A project run natively — npm run dev, dotnet run, gradle bootRun, mix phx.server, ng serve — listens on its port: too (a PORT env overrides it on the node, vite and Phoenix projects), and a frontend's dev server proxies /api to its target backend's port:. auth: required (backend) / auth: ui (frontend) opts into the OIDC layer (Auth); favicon: copies an icon into the frontend bundle.
deployable apiNode {
platform: node
contexts: [Orders]
dataSources: [ordersState]
serves: OrdersApi
port: 3001
}
deployable webReact {
platform: react
targets: apiNode
ui: Web { Sales: apiNode }
design: mantine
port: 3000
}
Deployable names are
LooseName— identifiers, not kebab-case.api-nodeparses asapithen-nodeand fails; writeapiNode.
Each deployable becomes one directory under <outdir>/ (the name slugified — apiNode → api_node) plus one docker compose service. Two deployables whose names slugify identically (apiNode + ApiNode) are rejected with loom.duplicate-service-slug; two publishing the same host port: with loom.duplicate-host-port. The orchestrator wires the database, dependency ordering, ports, CORS origins (every frontend's host URL), OTLP tracing, and (for frontends) the in-network API proxy target. Here is the compose service each platform's deployable produces in the same stack:
api_node:
build: ./api_node
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: "postgres://postgres:postgres@db:5432/api_node"
LOG_LEVEL: "info"
CORS_ORIGIN: "http://localhost:3000,http://localhost:3006,http://localhost:3007"
OTEL_EXPORTER_OTLP_ENDPOINT: "http://jaeger:4318"
OTEL_SERVICE_NAME: "api_node"
ports:
- "3001:3000" # host 3001 → container 3000 (node default)
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/ready || exit 1"]
interval: 5s
timeout: 3s
retries: 10
api_dotnet:
build: ./api_dotnet
depends_on:
db:
condition: service_healthy
environment:
ConnectionStrings__Default: "Host=db;Port=5432;Database=api_dotnet;Username=postgres;Password=postgres"
LOG_LEVEL: "info"
CORS_ORIGIN: "http://localhost:3000,http://localhost:3006,http://localhost:3007"
OTEL_EXPORTER_OTLP_ENDPOINT: "http://jaeger:4318"
OTEL_SERVICE_NAME: "api_dotnet"
ports:
- "3002:8080" # container 8080 (dotnet default)
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/ready || exit 1"]
# …
api_java:
build: ./api_java
depends_on:
db:
condition: service_healthy
environment:
SPRING_DATASOURCE_URL: "jdbc:postgresql://db:5432/api_java"
SPRING_DATASOURCE_USERNAME: "postgres"
SPRING_DATASOURCE_PASSWORD: "postgres"
CORS_ORIGIN: "http://localhost:3000,http://localhost:3006,http://localhost:3007"
OTEL_EXPORTER_OTLP_ENDPOINT: "http://jaeger:4318"
OTEL_SERVICE_NAME: "api_java"
ports:
- "3003:8080" # container 8080 (Spring default; the host-side default is 8081)
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/ready || exit 1"]
# …
api_python:
build: ./api_python
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: "postgresql+asyncpg://postgres:postgres@db:5432/api_python"
LOG_LEVEL: "info"
CORS_ORIGIN: "http://localhost:3000,http://localhost:3006,http://localhost:3007"
OTEL_EXPORTER_OTLP_ENDPOINT: "http://jaeger:4318"
OTEL_SERVICE_NAME: "api_python"
ports:
- "3004:8000" # container 8000 (python default)
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8000/ready || exit 1"]
# …
api_elixir:
build: ./api_elixir
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: "ecto://postgres:postgres@db:5432/api_elixir"
SECRET_KEY_BASE: "6f3454…"
PHX_HOST: "localhost"
PHX_SERVER: "true"
PORT: "4000"
LOG_LEVEL: "info"
CORS_ORIGIN: "http://localhost:3000,http://localhost:3006,http://localhost:3007"
OTEL_EXPORTER_OTLP_ENDPOINT: "http://jaeger:4318"
OTEL_SERVICE_NAME: "api_elixir"
ports:
- "3005:4000" # container 4000 (elixir/phoenix default)
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:4000/health || exit 1"]
# …
The divergence is the content: each backend speaks its own connection-string dialect (libpq URL / EF ConnectionStrings__Default / Spring SPRING_DATASOURCE_* / SQLAlchemy +asyncpg / Ecto URL), binds a different internal port, and Phoenix carries the extra SECRET_KEY_BASE / PHX_* runtime env. The host port is always your port:; the container port is the platform's listener (src/platform/<name>.ts). All five depends_on: db with a healthcheck wait because every backend needsDb. The stack also carries prometheus + jaeger sidecars for the observability wire.
The frontend deployables are the exception — no DB, no depends_on, and they get the API proxy wiring instead:
web_react:
build: ./web_react
environment:
VITE_API_BASE_URL: "http://localhost:3001/api"
VITE_API_PROXY_TARGET: "http://api_node:3000" # in-network → targets: apiNode
ports:
- "3000:3000"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/ || exit 1"]
# …
web_feliz:
build: ./web_feliz
environment:
VITE_API_BASE_URL: "http://localhost:3001/api"
VITE_API_PROXY_TARGET: "http://api_node:3000"
ports:
- "3006:3000" # dotnet fable + vite build, served by nginx on :3000
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/ || exit 1"]
# …
web_flutter:
build: ./web_flutter
environment:
API_BASE_URL: "http://localhost:3001/api" # Dart --dart-define, not a Vite var
VITE_API_PROXY_TARGET: "http://api_node:3000"
ports:
- "3007:3000" # flutter build web, served by nginx on :3000
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/ || exit 1"]
# …
VITE_API_PROXY_TARGET points at the compose service name of the deployable named in targets: — inside the compose network the backend is api_node, not localhost — so the bundle stays same-origin (the injectsApiProxyTarget flag on the surface drives this). Vue, Svelte and Angular deployables produce the same stanza shape as React.
The on-disk shape is idiomatic per ecosystem — each backend lays its project out the way its build tool expects:
api_node/
index.ts package.json tsconfig.json drizzle.config.ts tsup.config.ts Dockerfile
domain/ # aggregate classes, ids, value objects, events, repository ports
db/
schema.ts
migrations/ # drizzle SQL + meta
repositories/
http/ # Hono routes
lib/ obs/ certs/
api_dotnet/
ApiDotnet.csproj Program.cs Dockerfile
Api/ # controllers, filters
Application/Orders/{Commands,Queries,Requests,Responses}/
Domain/{Common,Enums,Events,Ids,Orders,ValueObjects}/
Infrastructure/{Events,Persistence,Persistence/Configurations,Repositories}/
Middleware/ Migrations/ Observability/ Serialization/ certs/
api_java/
build.gradle.kts settings.gradle.kts Dockerfile
src/main/java/com/loom/apijava/
api/ config/
domain/{common,enums,events,ids,valueobjects}/
features/orders/ # entity, repository, service, controller, DTOs
src/main/resources/db/migration/ # Flyway SQL
api_python/
pyproject.toml Dockerfile
app/
main.py settings.py
domain/
db/repositories/
http/
obs/
migrations/ certs/
api_elixir/
mix.exs config/ rel/ Dockerfile
lib/
api_elixir/orders/ # Ecto schemas / changesets / repositories
api_elixir_web/{api,api/schemas,controllers}/
components/ live/ # only when the deployable mounts a `ui:`
priv/repo/migrations/ priv/static/ certs/
The frontend trees are UI-shaped — pages, an API client, e2e page objects — and carry no domain or DB layer:
web_react/
index.html vite.config.ts package.json tsconfig.json Dockerfile
src/
api/ # generated React-Query client
pages/orders/
lib/ locales/ theme.ts i18n.ts
e2e/pages/ # Playwright page objects + smoke spec
certs/
web_feliz/
App.fsproj index.html vite.config.js tailwind.config.js package.json Dockerfile
.config/dotnet-tools.json # fable
src/App.fs # one Elmish MVU program: wire types, pages, views
e2e/pages/
web_flutter/
pubspec.yaml Makefile Dockerfile # make apk / ipa build the native targets
lib/
main.dart config.dart models.dart reads.dart i18n.dart
pages/
web/ test/
The composed stack
generate system emits one docker-compose.yml at the output root that ties the deployables together with a single shared db postgres service. Every backend that needsDb gets its own database inside that one postgres instance — created by a generated db-init/ script the db service runs on first boot:
-- db-init/00-create-databases.sql (auto-generated)
CREATE DATABASE api_node;
CREATE DATABASE api_dotnet;
CREATE DATABASE api_java;
CREATE DATABASE api_python;
CREATE DATABASE api_elixir;
# docker-compose.yml — the shared db service the backends depend_on
db:
image: postgres:18-alpine
environment:
POSTGRES_DB: postgres
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
PGDATA: /var/lib/postgresql/data
volumes:
- pgdata:/var/lib/postgresql
- ./db-init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD", "pg_isready", "-U", "postgres"]
Each backend's connection string targets its own database (…/api_node, …/api_dotnet, …) on the shared db host. The whole stack comes up with docker compose up from the output root. Schema migrations are owned per-module by exactly one backend deployable (the enrichment pass's migrationsOwner), so two backends hosting the same context don't both try to create the tables.
Backend platforms
platform: admits five backend families plus the six frontends and static. The full Platform rule:
Platform: 'dotnet' | 'node' | 'react' | 'svelte' | 'vue' | 'angular' | 'feliz' | 'flutter'
| 'static' | 'elixir' | 'python' | 'java' | STRING;
The backend families and what they generate (registered in src/platform/registry.ts, each implementing PlatformSurface; the client-safe facts live in src/platform/metadata.ts):
platform: |
Stack | Container port | DB | Mounts a ui: |
|---|---|---|---|---|
node |
Hono + Drizzle + Zod (TypeScript) | 3000 | postgres | no |
dotnet |
ASP.NET + EF Core + Mediator (CQRS) | 8080 | postgres | embeds a SPA |
java |
Spring Boot + Spring Data JPA | 8080 (host default 8081) | postgres | embeds a SPA |
python |
FastAPI + SQLAlchemy 2 (async) | 8000 | postgres | embeds a SPA |
elixir |
Phoenix + Ecto, LiveView when a ui: is mounted — fullstack |
4000 | postgres | LiveView or a SPA |
These are the only spellings — the legacy aliases (hono → node, fastapi → python, phoenix/phoenixLiveView → elixir) were retired; writing them now fails validation as an unknown platform. A backend may pin a family@version via the STRING alternative — platform: "node@v5" selects the zod-4 / TS-6 Hono package, which is also what bareword platform: node resolves to. node@v4 (zod 3 / TS 5) was retired; pinning it is a validation error naming node@v5. elixir is dual-natured: it owns a database and mounts a ui: (Phoenix LiveView), so it is the one fullstack platform; dotnet, java and python are backend-only until the deployable declares ui:, at which point they embed the referenced SPA bundle under their served static root.
Per-platform output for the same aggregate Order is the subject of Aggregates & domain model; here the platform choice is what selects the whole project shape shown above.
Frontend platforms & targets:
Framework: 'react' | 'svelte' | 'vue' | 'angular' | 'feliz' | 'flutter' | 'phoenixLiveView';
The frontend-only platforms are react, vue, svelte, angular, feliz, flutter, and static (React's UI-only alias — same Vite bundle, no separate domain). They own no server logic or database; a frontend deployable targets: a backend and renders pages against that backend's wire shape. Every frontend deployable must mount a ui: (or hosts:) — one without is rejected with the per-platform loom.react-deployable-missing-ui / loom.vue-… / loom.svelte-… / loom.angular-… / loom.feliz-… / loom.flutter-deployable-missing-ui.
deployable webReact {
platform: react
targets: apiNode // inherit apiNode's module set + proxy its /api
ui: Web { Sales: apiNode } // bind the ui's `api Sales:` param to a backend
design: mantine
port: 3000
}
deployable webFeliz {
platform: feliz // F#/Fable/Elmish — no design pack menu, daisyUI shell
targets: apiNode
ui: Web { Sales: apiNode }
port: 3006
}
deployable webFlutter {
platform: flutter // Dart/Riverpod — web bundle in compose, apk/ipa via Makefile
targets: apiNode
ui: Web { Sales: apiNode }
port: 3007
}
targets: is module inheritance — a frontend has no domain code of its own, so the enrichment pass copies the target backend's moduleNames onto it (src/ir/enrich/enrichments.ts → "React targets: module inheritance"). The ui: Web { … } compose-binding maps each api parameter declared in the ui block (api Sales: OrdersApi) to the backend deployable that supplies it. Two frontend families build outside the shared Vite static pipeline: Feliz (dotnet fable → JS → vite build) and Flutter (flutter build web, plus native make apk / make ipa from the same Dart source, which compose does not serve).
Which host can serve which framework
A ui declaration owns its framework (ui Web { framework: vue … }; omitted → derived from the mounting deployable's platform). The hosting deployable's platform must be able to serve it — PlatformSurface.hostableFrameworks:
| Host platform | Hosts |
|---|---|
react / static, vue, svelte, angular |
any static bundle: react, static, vue, svelte, angular |
dotnet, java, python |
the static bundles plus feliz (the backend Dockerfile runs the Fable stage) |
elixir |
phoenixLiveView (its own runtime) plus every static bundle and feliz, all served under /app |
feliz |
feliz only |
flutter |
flutter only |
node |
nothing — mountsUi: false; a ui:/hosts: on it is loom.ui-binding-unmountable-platform |
A mismatch (a framework: phoenixLiveView ui on a react host, say) is rejected with loom.ui-framework-unhostable. phoenixLiveView is a framework, not a platform — it is what an elixir deployable's ui: renders (HEEx, not a separate SPA deployable). A backend can mount several UIs at once with hosts: [Web, Admin].
Design packs
DesignPack: 'mantine' | 'shadcn' | 'mui' | 'chakra' | 'coreComponents' | 'daisyui'
| 'shadcnSvelte' | 'flowbite' | 'vuetify' | 'shadcnVue'
| 'angularMaterial' | 'primeng' | 'spartanNg' | STRING;
design: picks the template pack the UI generator renders pages against — only meaningful on a deployable that mounts a UI (ignored otherwise). Each pack is keyed to a frontend format; the validator cross-checks the pack against the framework the deployable renders (Rule 14 in deployable.ts), so platform: react … design: vuetify is an error ("Design pack 'vuetify' is a vue pack but framework 'react' renders tsx. Use one of: mantine, chakra, mui, shadcn."). Bareword names resolve to the current major (BUILTIN_PACK_LATEST in src/util/builtin-formats.ts); pin one explicitly with family@version (design: "mantine@v9"). The React 18 packs mantine@v7, chakra@v2, mui@v5 and shadcn@v3 were retired: pinning one is loom.design-pack-retired, which names the successor.
| Framework (format) | Packs (bareword → version) | Default when design: is omitted |
|---|---|---|
react / static (tsx) |
mantine (v9), shadcn (v4), mui (v7), chakra (v3) |
mantine |
vue |
vuetify (v3), shadcnVue (v1) |
vuetify |
svelte |
shadcnSvelte (v1), flowbite (v1) |
shadcnSvelte |
angular |
angularMaterial (v1), primeng (v1), spartanNg (v1) |
angularMaterial |
phoenixLiveView (heex) |
coreComponents (v3), daisyui (v1) |
coreComponents |
feliz |
no pack menu — design: names a daisyUI theme (design: dracula), default corporate |
— |
flutter |
no pack — Material widgets rendered procedurally | — |
A STRING value points at a custom pack — a directory with a pack.json, resolved relative to the .ddd file that declares the deployable. ddd parse / ddd generate load it and refuse a malformed one with loom.design-pack-invalid (or loom.design-pack-format-mismatch for the wrong format); the editor cannot read the directory and only warns (loom.design-pack-custom-unchecked). The body walker dispatches each page primitive through the active pack's templates under designs/; see ../design-packs.md for the authoring contract.
Realization axes
platform: can carry an optional { … } block that decomposes the platform bundle into two orthogonal realization axes — finer control over how a backend realizes its layers, without changing the platform:
deployable apiDotnet {
platform: dotnet {
persistence: dapper // data layer
directoryLayout: byFeature // on-disk shape
}
contexts: [Orders]
// …
}
The two axes: persistence, directoryLayout. The bare platform: dotnet form is unchanged — the block is additive and every axis is optional, defaulting to the platform's primary value. Axis values are validated against a per-platform menu (src/platform/adapter-metadata.ts, read by src/language/validators/data/platform-rules.ts), not the grammar:
| Backend | persistence: |
directoryLayout: (default) |
|---|---|---|
node |
drizzle (default), mikroorm |
byLayer (default), byFeature |
dotnet |
efcore (default), dapper |
byLayer (default), byFeature |
java |
jpa |
byLayer, byFeature (default) |
elixir |
ecto |
byFeature |
python |
no adapter menu — any axis is rejected | — |
An out-of-menu value is loom.platform-knob-out-of-menu ('persistence: efcore' on deployable 'apiNode' is not available on platform 'node'. Available: 'drizzle', 'mikroorm'.); a layout the backend's emission style can't produce is loom.platform-knob-style-layout-mismatch. Frontends carry no axes — platform: react { persistence: drizzle } is rejected with the same out-of-menu code (Platform 'react' exposes no 'persistence:').
Only these two axes offer real per-backend choice. The former foundation: / application: / transport: / runtime: clauses were removed as inert (one fixed emission style per backend — cqrs on dotnet, layered elsewhere — is kept internally, not user-selectable); writing any of them no longer parses (Expecting token of type '}' but found 'application'). platform: elixir emits plain Ecto/Phoenix (the Ash foundation was removed).
No generated tab here — the axes select which emitter subtree runs (e.g. byFeature vs byLayer reorganises the directory tree shown under deployable); the divergence is structural across whole projects, not a single excerptable line.
theme
ThemeBlock: 'theme' '{' (name=LooseName ':' value=STRING)* '}'
A system-level design-token block — framework-agnostic visual identity consumed by every frontend deployable in the system. At most one theme { … } per project (loom.duplicate-theme-block). Token names and value rules are pinned by checkTheme:
| Token | Validation |
|---|---|
primary secondary accent success warning error neutral |
CSS hex (#RGB / #RRGGBB / #RRGGBBAA) — named colours / rgb() / CSS vars rejected; a colour no text colour clears WCAG-AA on warns loom.a11y-theme-contrast |
radius |
none | sm | md | lg | xl |
colorScheme |
light | dark | auto |
fontFamily fontFamilyMono |
free-form string |
theme {
primary: "#2563eb"
neutral: "#64748b"
radius: "md"
fontFamily: "Inter, system-ui, sans-serif"
}
The tokens are intentionally framework-neutral: one shared preparer (src/generator/_frontend/theme-preparer.ts) fills defaults and derives 10-shade ramps, and each pack renders them its own way — Mantine's createTheme (React), src/theme.ts (Vue), a CSS-custom-property sheet (Svelte, Angular src/styles.css, Phoenix priv/static/assets/theme.css). Feliz and Flutter do not read theme (Feliz takes a daisyUI theme name from design:; Flutter seeds Material 3 from a fixed colour).
// src/theme.ts — generated, do not edit by hand
const brand: MantineColorsTuple = [
"#eef2fc", "#cfdbf7", "#b0c4f1", "#89aaf4", "#6793f1",
"#467bee", "#2563eb", "#1144b6", "#0a2b72", "#07132c", // #2563eb at shade 6
];
const neutral: MantineColorsTuple = [ "#f3f5f6", /* … */ "#16191d" ];
export const theme = createTheme({
primaryColor: "brand",
primaryShade: { light: 6, dark: 5 },
colors: { brand, gray: neutral },
defaultRadius: "md",
fontFamily: "Inter, system-ui, sans-serif",
fontFamilyMonospace: "ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, monospace",
// headings + per-component defaultProps (radius: "md") follow …
});
/* src/styles.css — base resets + the tokens projected from `theme { … }` */
body {
margin: 0;
font-family: Inter, system-ui, sans-serif;
}
:root {
--loom-primary: #2563eb;
--loom-accent: #ffb98a;
}
The Phoenix theme.css carries the same ramp as CSS custom properties (--color-brand-0 … --color-brand-9, --color-primary: var(--color-brand-6), a --color-neutral-* ramp). The shade ramp is derived from the single primary hex — your colour lands at shade 6 (the primaryShade.light index), with lighter tints above and darker shades below. Omit theme entirely and the pack's built-in defaults apply (indigo #4f46e5, md, Inter).
tenancy by user.<claim> of <Registry>
TenancyDecl: 'tenancy' 'by' 'user' '.' claim=[UserField] 'of' registry=[Aggregate];
A system-level declaration (a SystemMember only — it does not parse at file top level) naming which user { … } claim partitions the data and which aggregate is the tenant registry. Both halves are real cross-references: the claim resolves against the system's user { … } fields, the registry against the aggregates. At most one per system (loom.tenancy-duplicate); the claim must be a string (loom.tenant-owned-claim-type). Per-aggregate participation is then declared on each aggregate — with tenantOwned (stamped + filtered), with tenantRegistry (the registry tree), or the crossTenant header marker (shared reference data that opts out) — and every aggregate in a tenant system must take an explicit stance (loom.tenancy-stance-unmarked).
system Shop {
user { id: guid tenantId: string }
tenancy by user.tenantId of Org
subdomain Sales {
context Orders {
aggregate Org with tenantRegistry { name: string }
aggregate Order with tenantOwned { reference: string }
aggregate Plan crossTenant { name: string }
// repositories …
}
}
// storage / resource / an `auth: required` deployable …
}
The generated filters, stamps, hierarchical dataKey scoping, and the loom.tenancy-* / loom.policy-* gate catalogue are covered in ../tenancy.md and Capabilities, filters & stamps.
organizationContext — the operating scope
Under a hierarchy (the registry implements tenantRegistry), backend code may read organizationContext.orgPath — the materialized path of the org the request operates in. It is the principal's own currentUser.orgPath unless the request carries an x-org-context: <path> header naming an org inside the caller's subtree; every backend's auth middleware validates that header before any handler runs and answers anything else with a 403. Its first use is the tenant write stamp:
capability tenantOwned {
tenantId: string internal
dataKey: string? internal
stamp onCreate {
tenantId := currentUser.tenantId
dataKey := organizationContext.orgPath // the operating scope
}
filter this.tenantId == currentUser.tenantId // reads stay on the principal
}
orgPath is the only member, and it is backend-only (loom.org-context-surface); a read needs the hierarchy and auth: required on every backend deployable hosting it (loom.org-context-gate-unmet). The gate table, the per-backend seams and what a switched write is visible to are in ../tenancy.md.