Loom — Tools & Workflow
This document covers the CLI, the .loomignore escape hatch, watch
mode, the dev/test loop with Docker, the Playwright UI suite, the
cross-platform OpenAPI parity check, and how migrations interact with
the native database tooling (Drizzle Kit and EF Core migrations).
- For language syntax see
language.md. - For per-platform feature reference (what each backend emits, file
by file) see
generators.md. - For architecture (AST → IR → templates) see
technical.md. - For licensing see the LICENSE file — Loom is source-available under FSL-1.1 with an Apache-2.0 future license.
CLI
The ddd binary lives at bin/cli.js and exposes these sub-commands.
Spell it node bin/cli.js … (or npx ddd … inside the clone — outside it,
npx ddd fetches an unrelated npm package named ddd). A clone's npm install
does NOT put a ddd on your PATH — which ddd exits 1 — so the bare ddd
below is shorthand for one of those two. (alias ddd='node "$PWD/bin/cli.js"'
makes the shorthand real for a shell session.)
ddd new <name> [--platform …] [--template …] # scaffold a starter project
ddd parse <file.ddd> # parse + validate (AST + IR), multi-file aware
ddd generate ts <file.ddd> -o <outdir> # emit a single TypeScript project (legacy)
ddd generate dotnet <file.ddd> -o <outdir> # emit a single .NET project (legacy)
ddd generate system <file.ddd> -o <outdir> # emit every deployable + docker-compose.yml
ddd migrate add <name> <file.ddd> # record schema changes in migrations/ (see Migrations workflow)
ddd migrate status <file.ddd> [--json] # each module's recorded head and its unrecorded changes
ddd migrate remove <file.ddd> # delete the newest recorded migration
ddd migrate rebase <file.ddd> # after a merge leaves two heads, re-record one on top of the other
ddd diff <file.ddd> [--baseline <old.ddd> | --git <ref>] [--json] # classify changes as compatible / breaking-for-clients / breaking-for-data; exit 1 on breaking
ddd verify <file.ddd> --results <results.json> # join test results onto the requirements graph
ddd verify <file.ddd> --from-vitest <report.json> # …the same, straight from `vitest run --reporter=json`
ddd verify <file.ddd> --from-vitest unit.json --from-vitest e2e.json --from-playwright ui.json # every flag repeatable — one per runner report
ddd snapshot <file.ddd> -o <outdir> # capture provenance rule snapshots
ddd patch <file.ddd> --patches <patches.json> # apply node-addressed model patches
ddd trace <logfile> # translate a runtime stack-trace back to .ddd source
ddd breakpoints <file.ddd> --line <n> # resolve a .ddd source line to the generated file:line(s), or file:line:col when the construct maps to a fine expression region — the reverse of `trace`
generate system also accepts --verify-ir: it runs the IR verifier
(src/ir/verify/verify-ir.ts — the compiler checking its own lowering/enrichment
output, e.g. an enum-value ref that lost its enumName) before phase ⑦ and exits
non-zero, listing each violation, if any invariant is broken. A violation is a
compiler bug, never a model error; off by default. The same check is
GenerateSystemOptions.verifyIr for in-process callers (throws).
generate system additionally accepts --sourcemap, which also emits
.loom/sourcemap.json (construct- and statement-granular .ddd ↔ generated-line
origins). ddd trace <logfile> reads that file to rewrite a backend stack-trace so
each frame points at the .ddd line it was generated from — the debugging companion
to the source map. For Node/V8 frames (the only dialect whose frames carry a column),
the column selects the expression-level targetCol region containing it and the
annotation prints the exact .ddd path:line:col of that sub-expression; every other
format (and any column matching no region) keeps the line-granular path:line.
The Source Map v3 .ts.map sidecars it also writes name their .ddd by absolute
path and do not inline its text; pass --inline-sources alongside --sourcemap
to embed a copy in each sidecar (only worth it when the maps will be read where
the .ddd files are not — on the ERP example it takes the sidecars from 95 KB to
763 KB). See loom-artifacts.md and
debugging.md.
ddd breakpoints <file.ddd> --line <n> is the reverse lookup: given a
.ddd source line, it prints every generated file:line that line produced
(narrowest construct first — a line can host nested constructs, e.g. an
aggregate declaration and a narrower operation inside it), sourced from the
same .loom/sourcemap.json (--map/-o, --out follow the identical
discovery rule as trace). When the matched construct is a fine expression
region carrying a targetCol (the same TS/Hono marks trace's column-aware
path already consumes), it resolves to file:line:col instead — the
column-precise armable site a real DAP setBreakpoints (V8
setBreakpointByUrl takes url+line+column) needs; every coarser
statement/structural region keeps the line-granular file:line. Two
distinct fine regions on the same generated line at different columns both
print as separate targets. A line with no mapping prints an informative
message and still exits 0 — a future editor/DAP integration's primitive for
translating "set a breakpoint on this .ddd line" into the real
backend-native breakpoint(s) to arm. See
docs/old/plans/dap-node-debug.md.
patch — apply node-addressed model patches
ddd patch <file.ddd> --patches <patches.json> applies a list of
node-addressed model patches (the protocol from
proposals/ai-authoring-loop.md §4 —
the same patch shape that parse --json / generate --json
diagnostics carry in their fixHint). --patches - reads the JSON
from stdin; the file may be a bare array or { "patches": [...] }.
By default the patched source is printed to stdout so the command
composes (ddd patch m.ddd --patches p.json > m2.ddd); --json emits
the structured PatchResult instead. Exits non-zero if any patch fails
to apply.
new — scaffold a starter project
ddd new <name> is the on-ramp: it writes a small, already-valid
starter into ./<name>/ (or --out <dir>) so a newcomer goes from
nothing to an editable model without hand-assembling system /
deployable / dataSource wiring.
ddd new acme # hono backend + React (mantine), crud template
ddd new acme --platform dotnet --design shadcn # .NET backend + React (shadcn)
ddd new acme --platform elixir # Phoenix LiveView fullstack (coreComponents)
ddd new acme --platform elixir --design mui # Phoenix backend + a React (mui) frontend
ddd new acme --platform java # Spring Boot backend + React (mantine)
ddd new acme --platform python # FastAPI backend + React (mantine)
| Flag | Default | Meaning |
|---|---|---|
--platform <node|dotnet|elixir|java|python> |
node |
Backend platform (ports: node 3000, dotnet 8080, elixir 4000, java 8081, python 8000). Prints a hint listing the alternatives when defaulted. |
--template <blank|crud> |
crud |
blank = one aggregate; crud = two aggregates with a repository find. |
--design <pack> |
mantine (coreComponents for elixir) |
Frontend design pack — every built-in family (the list is derived from the pack registry, src/util/builtin-formats.ts, so ddd new --help and designs/ cannot disagree): React mantine/shadcn/mui/chakra, Vue vuetify/shadcnVue, Svelte shadcnSvelte/flowbite, Angular angularMaterial/primeng/spartanNg, Phoenix LiveView coreComponents/daisyui. The pack's FORMAT picks the frontend deployable: a Vue pack scaffolds platform: vue, an Angular pack platform: angular, and a HEEx pack makes Phoenix a single LiveView fullstack (so it requires --platform elixir). |
-o, --out <dir> |
./<name> |
Output directory. |
--force |
off | Scaffold into an existing, non-empty directory. |
It writes exactly four files — main.ddd, README.md (the run steps),
a commented .loomignore, and LICENSE (the MIT grant over generated
output; see license-faq.md) — and validates the
rendered model in-memory before writing anything, so a starter is never
emitted in a broken state. ddd generate writes none of these: it emits
build output into a tree whose identity files are yours (M-FT.13, finding
G9). It does not emit the project tree; the README walks you
through ddd generate system main.ddd -o . and docker compose up.
generate ts / generate dotnet work on legacy sources (bare
context declarations) and produce a single project for the chosen
platform. generate system works on sources that declare one or more
system { … } blocks and produces a multi-project tree:
<outdir>/
<deployable-1>/ # full per-deployable project
<deployable-2>/
docker-compose.yml # at the system root
Multi-file projects
generate system accepts an entry file (conventionally main.ddd)
that may declare per-file import "./path" statements at the top.
The CLI walks the import graph transitively, registers every
reachable document with the Langium workspace, and produces a single
project from the merged model. See
language.md
for the language surface; the original design rationale is preserved
at plans/multi-file-source.md.
generate ts / generate dotnet remain single-file: they read just
the file you point them at, no import-graph walking.
Each deployable's folder name is a lowercase slug derived from the
deployable's name (e.g. catalogWeb → catalog_web); inside the
project the .NET namespace and .csproj keep the capitalised form.
Common options
| Flag | Applies to | Effect |
|---|---|---|
-o, --out <dir> |
every generate, verify, snapshot |
Output directory. Created if missing. Required on generate system, generate ts, generate dotnet, and snapshot; optional on verify (defaults to the .ddd file's directory). |
-w, --watch |
every generate |
Re-run the generator whenever the source .ddd file changes (see Watch mode below). |
--dry-run |
every generate, snapshot |
List every path that would be written (plus any paths skipped via .loomignore). Writes nothing. |
--trace |
every generate |
Emit trace-level domain instrumentation (value_computed, precondition_evaluated, …) into the generated project. Off by default — see observability.md. |
--allow-destructive |
migrate add |
Permit destructive steps (column/table drops, NOT-NULL column adds without a default on an existing table, reshapes) in the recorded migration. Off by default a destructive delta aborts with a loom.migration-destructive error — see migrations.md § Destructive changes. |
--all-warnings |
parse, generate system |
Print every IR-level warning in full. By default a warning code that fires more than three times (the per-aggregate denyByDefault read-gate warnings, typically) prints its first occurrence plus one line naming the rest — … and 3 more loom.default-deny-by-id-ungated warning(s): Desk/Agent, Desk/Queue, Desk/Tag. The N warning(s) count and --json output always carry every diagnostic. |
ddd parse exits non-zero if the source has errors. ddd generate
runs validation first and refuses to emit if there are any errors.
parse now runs the same validation generate does. It walks the
import graph (so a multi-file entry resolves its siblings rather than
reporting them as unresolved references) and runs BOTH validation
tiers: syntax + AST-level (phases ①–④) and IR-level (phases ⑤–⑦)
— queryability of a retrieval / view where, cross-aggregate
reference rules, persistence-binding, wire-shape and migration checks.
A source that parses clean is one generate will accept; the only
thing parse skips is the emission itself.
This was not always true, and the old behaviour is worth knowing if you
have scripts built on it: parse used to run phase ④ only — it
computed the IR diagnostics and then discarded everything except the
advisory loom.index-suggestion lint before printing OK. A model
generate system rejected with six errors parsed clean, which made
parse useless as a pre-flight and actively misleading as a scan
(experience_gathered.md §62). Index suggestions remain advisory and
never fail the parse.
For the last mile a tsc / compiler run on the emitted target is still
the stronger gate — it catches emitter bugs no model-level check can.
See the pipeline phases in technical.md.
verify and snapshot
ddd verify joins one or more test-results reports onto the requirements graph
built from requirement / solution / testCase declarations, writes
.loom/verification.json + .loom/verification.md under --out, and
sets a non-zero exit code if any requirement is FAILING, if a declared
test produced no result at all (unless --allow-missing — "we have
no result" is not "it passed"), if --require-all is set and any
requirement remains unverified, or if --min <pct> is set and the
verified percentage is below it. --json also prints the verification
JSON to stdout. A report is Loom's own { version, results: [...] }
(--results), a vitest / jest --reporter=json report (--from-vitest)
or a Playwright --reporter=json report (--from-playwright) — the two
adapters apply the (suite, name) join convention for you. Every flag is
repeatable and they mix: a generated tree's unit and e2e tiers each write
their own report, and all of them are merged into one verdict. See verify.md
for the full contract and traceability.md for the
artefact format.
ddd snapshot captures one immutable <ts>-<guid>.loomsnap.json per
system under <out>/.loom/snapshots/, recording the current provenance
rules for every provenanced field. Run it as an explicit prebuild
step whenever your provenance rules change; the generated runtime
loads the latest snapshot at startup so live writes preserve full
history. See language.md for the provenanced
keyword.
.loomignore
The contract is intentionally simple: every file Loom generates is
overwritten on every run. When a user wants Loom to leave a file
alone, they pin it in a .loomignore at the output-directory root.
The file uses gitignore syntax (parsed by the ignore npm package), so
the rules will feel familiar:
# Pin our hand-tuned hosting entry
Program.cs
/index.ts # only the root index.ts (anchored)
# Ignore an entire generated folder of legacy aggregates
Domain/Legacy/
# Re-include something previously excluded
!Domain/Legacy/keep-this.cs
Patterns match against forward-slash-normalised paths relative to the
output directory. Anchor with a leading / to match only the root;
otherwise index.ts matches every index.ts at any depth.
Why no "seed" or "first-run" magic?
Two reasons:
- No surprises across regens. If
package.jsonis generated, it is always generated; if it is pinned, it is always pinned. No "the first time it was written, the second time it wasn't" mode. - Git already handles merging. When a user introduces a customisation they want to keep, they pin the file and commit. When they want Loom to take over again, they remove the pin and resolve any merge conflict normally.
What to pin and what not to
Pin when you've made a change you want to survive regeneration:
Program.cs— adding middleware, custom DI registrations, telemetry.index.ts(TS root) — adding Hono middleware, custom routes outside Loom's per-aggregate*.routes.ts.package.json/*.csproj— adding application-level dependencies beyond what Loom emits.tsconfig.json— adjusting compiler options.drizzle.config.ts— alternate connection-string source, custom migration directory.
Don't pin Loom's domain artefacts (domain/*, db/schema.ts,
Infrastructure/Persistence/Configurations/*, controllers, repository
implementations, command/query handlers). Those are auto-derived from
the .ddd source — pinning them defeats the point of the generator.
Discovering the pin set
ddd generate ... --dry-run prints, for every generated path,
whether it would be write-en or skip-ped via .loomignore:
write domain/order.ts (4.3 KB)
skip (.loomignore) Program.cs (1.0 KB)
write (local edits) docker-compose.yml (2.2 KB)
write Sales.csproj (1.2 KB)
Would write 18 file(s) in ./out, 1 of which had local modifications (pinnable via .loomignore), skipped 1 via .loomignore
Would overwrite local modifications in (add a path to out/.loomignore to keep your version):
docker-compose.yml
write (local edits) — a file you changed is about to be overwritten
The overwrite contract above is unchanged: that file is rewritten, on the
dry run's real counterpart. What the tag adds is that Loom can tell the two
kinds of rewrite apart and says which it is — it compares the bytes on disk
against the digest it recorded for that path in .loom/manifest.json on the
previous run, so "this differs because the model changed" and "this differs
because you edited it" are no longer the same line of output. The same count
closes the real run's summary:
Wrote 3 file(s) in ./out, 2 of which had local modifications (pinnable via .loomignore), unchanged: 153
Overwrote local modifications in (add a path to out/.loomignore so the next run leaves it alone):
api/domain/item.ts
api/http/item.routes.ts
The summary names the overwritten paths — up to ten, then … and N more —
so the path to pin is on screen rather than only a count.
The remedy is the one named in the line — pin the path in .loomignore (or,
for an extern implementation, let scaffold-once own it). A tree generated by
an older Loom carries no digests, so the first regen after upgrading reports
none; from the run after that they are recorded.
i18n translator workflow (ddd i18n)
User-visible strings in generated UIs route through a t(key, default) catalog.
ddd i18n is the translator side: git merge for strings, backed by the pure
three-way merge core in src/i18n/merge.ts (CLI in src/cli/i18n/).
| Command | What it does |
|---|---|
ddd i18n extract <file> [-o out] |
write the fresh source catalog to <out>/.loom/messages.en.json (front of the pipeline only, no codegen) |
ddd i18n init <file> <locale> |
scaffold locales/<locale>.json (every key as TODO:) and the source lock if absent |
ddd i18n sync <file> [--locale l] [--keep-stale] |
three-way merge every locale, then advance the lock; --keep-stale keeps source-deleted keys as _stale.<key> |
ddd i18n status <file> |
what sync would do; exits non-zero if any locale has pending changes |
ddd i18n check <file> [--strict] |
report TODO: values, unresolved conflict markers and missing keys; --strict exits non-zero on any finding (the CI gate) |
ddd i18n prune <file> |
delete keys the source no longer emits (deliberate, never automatic) |
The translator tree defaults to locales/ next to the .ddd file (--dir
overrides it; generate system --locales <dir> reads the same tree):
- BASE —
locales/.loom/source.lock.json, the source catalog as of the last successful sync; - OURS —
locales/<locale>.json, owned by the translator; - THEIRS — the fresh extraction.
Why sync is a separate, deliberate step. Codegen stays idempotent on the
source tree and never writes under locales/; reconciling translations is a
commit-worthy intent (like a migration), and conflicts need a human. The lock
advances only after every locale has reconciled: rewriting it on every codegen
would make BASE equal THEIRS, and the merge would have nothing to act on.
What ships. generate system emits each locale it finds into every frontend
with a translation runtime, but drops values the translator has not finished
(TODO: values, unresolved conflict markers, _stale.* shadows), so t(key, default) falls back to the source-language default per key. A locale created by
init is registered even while its catalog is still empty.
Watch mode
Useful during model design — every save of the .ddd file triggers a
regen:
ddd generate ts examples/sales.ddd -o ./out --watch
Implementation note: fs.watch with a 100ms debounce. No fancy
incremental regeneration — the whole project is regenerated on every
change, then .loomignore filters out pinned files. Fast enough for
sub-second feedback on real-world models.
Migrations workflow
Loom owns migration generation end-to-end. ddd migrate add diffs
the current source against the head of the migration chain — the
committed migrations/ directory beside the .ddd — and records the
platform-neutral delta (MigrationsIR, src/ir/types/migrations-ir.ts)
as a new entry. generate system renders every backend's migration
files from the whole chain; adding a property produces one
ALTER TABLE … ADD COLUMN … per backend. See
migrations.md § The migration chain, and
generators.md § Migrations for the per-platform
output table and the runtime application path.
The user-facing workflow is record, regenerate, then run:
ddd migrate add add-order-notes ./model.ddd # records the schema change in ./migrations/
ddd generate system ./model.ddd -o ./out # emits SQL/migrations alongside the rest
cd out
docker compose up # backends apply migrations at boot
There is no separate db:generate / dotnet ef migrations add step
— Loom owns the diff. Every DB backend self-applies on boot:
| Backend | Files emitted | Applied by |
|---|---|---|
| Hono (Drizzle) | <deployable>/db/migrations/<ts>_<name>.sql + db/migrations/meta/_journal.json |
migrate() from the generated db/migrate.ts (Drizzle's migrator over the same __drizzle_migrations ledger, plus the non-transactional CREATE INDEX CONCURRENTLY files, see migrations.md) called from index.ts at startup; npm run db:migrate runs the same migrate() out of band (tsx db/migrate-cli.ts against DATABASE_URL). |
| .NET (EF Core) | <deployable>/Infrastructure/Persistence/Migrations/<Ts>_<Name>.cs with b.Sql(...) raw-SQL Up / Down bodies |
db.Database.Migrate() in Program.cs at startup (idempotent — EF's __EFMigrationsHistory table tracks what's been applied). |
| Phoenix (Ecto) | <deployable>/priv/repo/migrations/<ts>_<name>.exs Ecto migrations |
mix ecto.migrate from the entrypoint script; mix ecto.setup on first boot. |
| Python (SQLAlchemy) | <deployable>/migrations/<version>_<module>_<name>.sql raw-SQL files |
run_migrations() from the FastAPI lifespan applies pending files in order, tracking applied tags in a __loom_migrations table (the Drizzle-runtime-migrator pattern). |
| Java (Spring/Flyway) | <deployable>/src/main/resources/db/migration/V<version>.<n>__<Module>_<Name>.sql Flyway scripts |
Flyway runs them on Spring Boot startup (tracked in flyway_schema_history). |
Commit migrations/ with the model: it is the whole migration history,
and the output tree carries none of it. A source with no migrations/
yet gets each module's initial migration derived from the model; once
the chain exists, generate system refuses a schema change that is not
recorded in it.
Renames
Declare a rename in a migration block (migration "rename-qty" { Order.qty -> quantity }, or Order -> PurchaseOrder for a table) so
migrate add records a RENAME COLUMN / RENAME TO instead of a
data-losing drop+add. An undeclared single drop+add of the same type
on one table is collapsed into a rename too, and announced with
loom.migration-rename-inferred. See migrations.md
§ Destructive changes.
diff — is this change compatible?
ddd diff compares the model with a baseline and classifies every change
by who it breaks:
| Class | Raised by | Examples |
|---|---|---|
breaking-for-clients |
the wire contract (.loom/wire-spec.json) moved under an existing client |
a field dropped or renamed, a type changed (an enum member removed, a carrier narrowed such as decimal → int), optional → required, a newly required field |
breaking-for-data |
the destructive migration gate — the same one migrate add applies without --allow-destructive |
a column or table drop, a NOT NULL add with no default or backfill, a narrowing column type, a reshape |
compatible |
everything else | a new optional field, required → optional, a new aggregate, a declared RENAME COLUMN |
ddd diff model.ddd --baseline old/model.ddd # against another file
ddd diff model.ddd --git origin/main # against the entry as committed at a ref
ddd diff model.ddd # against the committed migrations/ chain
--git <ref> reads every .ddd of that commit, so the old entry's imports
resolve to the old files and a multi-file project diffs as a whole. With no
flag the committed migrations/ chain is the baseline; the chain records a
schema, not a wire contract, so that run classifies only the data side.
A rename declared in a migration block is reported as one
property-renamed (or entity-renamed) change, not as a dropped field plus
an added one. Its data side is a RENAME COLUMN, which is compatible. Its
wire side is still breaking-for-clients, because no backend keeps serving
the old field name.
Diff against old/model.ddd:
breaking-for-clients property 'qty' on Order renamed to 'quantity' (declared in migration "rename-qty")
breaking-for-clients property 'status' on Order changed type: enum(New|Paid|Lost) → enum(New|Paid)
breaking-for-data module Sales: 4 schema step(s), refused by the destructive gate without --allow-destructive
ALTER TABLE "sales"."orders" DROP COLUMN "note";
…
BREAKING for clients and data.
The exit code is the gate: 0 compatible, 1 breaking, 2 when it cannot
diff (the model or the baseline does not load, or there is no baseline at
all). --json prints the toolkit's DiffReport (the shape loom_diff
returns; the CLI and the MCP tool share one core, diffModels in
src/api/evolve.ts), plus a baseline field naming where the baseline came
from. Each wire change and migration carries class, and the report carries
breakingForClients and breakingForData.
Cross-backend data migrations
Out of scope for Loom. When you need to populate a column or
transform values, write it in whichever backend's native form —
raw SQL inside the Drizzle .sql migration or an EF Migration.Up
body. Loom doesn't try to translate between them.
Docker
Every backend ships with a multi-stage Dockerfile and a
.dockerignore. Build and run with the standard commands; verified
end-to-end against docker build and docker run. The TypeScript and
.NET images are walked through below as representatives; the Python,
Java, and Phoenix images follow the same multi-stage shape.
TypeScript
cd ./out # the generator's output dir
docker build -t my-sales:latest .
docker run --rm -p 3000:3000 \
-e DATABASE_URL="postgres://user:pw@host:5432/db" \
my-sales:latest
The image uses node:22-alpine for both build and runtime stages.
Build runs npm install + npm run build; runtime starts
node out/index.js on port 3000.
.NET
cd ./out
docker build -t my-sales:latest .
docker run --rm -p 8080:8080 \
-e ConnectionStrings__Default="Host=db;Port=5432;Database=postgres;Username=postgres;Password=postgres" \
my-sales:latest
Build stage uses mcr.microsoft.com/dotnet/sdk:10.0, runtime uses
mcr.microsoft.com/dotnet/aspnet:10.0. ASP.NET Core listens on port
8080 (ASPNETCORE_URLS=http://+:8080).
Customising
Both Dockerfiles are intentionally minimal — they assume a single-
service deployment. For health checks beyond /health, multi-arch
builds, sidecar containers, BuildKit secrets, or alternate base
images, pin the Dockerfile in .loomignore and edit freely.
/health
Every generated deployable mounts a /health endpoint that returns
{"status":"ok"} (port 3000 on Hono, 8080 on .NET). It's used by
the compose healthchecks below and is the natural target for any
external smoke test.
docker-compose.yml (system mode)
ddd generate system emits a docker-compose.yml at the output
root that wires every deployable to a postgres service, with a
healthcheck on /health per deployable:
cd ./out # the generator's output dir
docker compose build # builds each deployable's Dockerfile
docker compose up -d # starts postgres + every deployable
curl http://localhost:8080/health # → {"status":"ok"} once api is healthy
docker compose down
The compose file is a generated artefact — pin it via .loomignore
if you customise it, since regenerating would otherwise overwrite
your changes.
Per-deployable databases
Each deployable owns its own postgres database, isolated from peers
sharing the same db service. This is necessary because EF Core's
EnsureCreated is all-or-nothing per database: with two .NET
deployables on a shared DB, whichever boots first creates only its
own subset of tables, and the second deployable sees existing tables
and creates nothing — silently leaving its own tables missing.
ddd generate system emits db-init/00-create-databases.sql with
one CREATE DATABASE <slug>; per deployable. Postgres mounts the
directory as /docker-entrypoint-initdb.d/, which runs once on the
first boot of an empty pgdata volume. Each deployable's
connection string is then scoped to its own database
(Database=api, Database=catalog_api, etc.).
To start fresh after schema changes that EF Core's EnsureCreated
won't pick up (it only creates; it never alters), drop the volume:
docker compose down -v && docker compose up -d
For production, use the per-backend migration tools instead of the
init script + EnsureCreated combo (see Migrations workflow
above).
Compose is the inner-loop story. For a cluster, ddd generate system --k8s additionally emits a Helm chart (helm/) plus the raw manifests
it renders to (k8s/) alongside the compose file — see
kubernetes.md. Loom still does not generate CI
pipelines (image build/push remains a project-init concern).
End-to-end test
Loom ships an opt-in vitest e2e (test/e2e/e2e.test.ts) that exercises
the whole pipeline against a real Docker daemon:
- Generates the
examples/acme.dddsystem to a temp directory. - Runs
docker compose buildon the resulting tree. - Runs
docker compose up -d(postgres + both deployables). - Polls
/healthon every deployable's port until each returns{"status":"ok"}. docker compose down -v+ cleans the temp directory.
Roughly 90 seconds end-to-end with cached base images. Stays out of
the default npm test so the unit / generator suite remains fast:
npm test # unit + parser + generator + CLI tests, ~5s
npm run test:e2e # the full Docker smoke, ~90s
The e2e is gated on LOOM_E2E=1 and docker ps succeeding, so it
silently skips on any machine without Docker access.
In sandboxed environments where the docker daemon's outbound HTTPS
goes through a TLS-rewriting proxy, set LOOM_E2E_CA_DIR to the
directory holding the proxy's *.crt files; the test will splice
them into each generated Dockerfile before building. In a normal
environment this variable is unnecessary.
Compiling generated backends in Docker
The opt-in per-backend build suites (test:dotnet, test:java,
test:phoenix, test:python, plus their obs-* / auth-e2e-*
siblings) emit a project from a fixture and compile it with the real
toolchain. Three of the four need Docker — the sandbox host's
toolchains are older than what the emitted projects target:
| Suite | Emitted project targets | Host has | Runs in |
|---|---|---|---|
test:java |
Java 25 toolchain (needs Gradle 9.1+) | JDK 21 + Gradle 8.14 | gradle:9-jdk25 container |
test:dotnet |
net10.0 |
no .NET SDK at all | mcr.microsoft.com/dotnet/sdk:10.0 container |
test:phoenix |
mix (Elixir/Ecto) |
— | hexpm/elixir container |
test:python |
uv + ruff + mypy + pytest |
uv |
host |
A managed remote/sandbox environment usually ships the Docker client
but not a running daemon — start one with dockerd (root /
passwordless-sudo) before any Docker-backed suite, e.g. `sudo dockerd
/tmp/dockerd.log 2>&1 &
. Image pulls from Docker Hub /mcr.microsoft.com` work through the standard egress.
An agent verifying a backend change can generate a project
(node bin/cli.js generate system <f.ddd> -o out) and compile it directly:
# Java — the emitted Gradle toolchain is Java 25, so the host's JDK 21 +
# Gradle 8.14 CANNOT build it ("No matching toolchains found"). Same image
# family the emitted Dockerfile uses; CI pins Gradle 9.6.1 via setup-gradle.
docker run --rm --network host -v <deployable>:/src -w /src \
-v /root/.ccr:/root/.ccr:ro -e JAVA_TOOL_OPTIONS="$JAVA_TOOL_OPTIONS" \
gradle:9-jdk25 gradle --no-daemon testClasses bootJar
# .NET — mounting /root/.ccr is NOT enough; see the CA note below.
docker run --rm --network host -v <deployable>:/src -w /src \
-v /root/.ccr:/root/.ccr:ro mcr.microsoft.com/dotnet/sdk:10.0 bash -c \
'cp /root/.ccr/ca-bundle.crt /usr/local/share/ca-certificates/proxy.crt \
&& update-ca-certificates >/dev/null && dotnet build /warnaserror'
# Phoenix
docker run --rm -v <deployable>:/app -w /app hexpm/elixir:… bash -c \
'mix deps.get && mix compile --warnings-as-errors'
The .NET CA step is required, not optional. Java reads the proxy CA
from the mounted JAVA_TOOL_OPTIONS truststore and Elixir has its own
LOOM_HEX_MIRROR path (below), but NuGet trusts only the container's
system certificate store. Mounting /root/.ccr without installing
the bundle into it fails restore before a single file compiles:
error NU1301: Unable to load the service index for source https://api.nuget.org/v3/index.json.
error NU1301: The SSL connection could not be established…
error NU1301: The remote certificate is invalid because of errors in the certificate chain: UntrustedRoot
which reads like a network outage rather than a missing trust anchor.
To run the whole test:dotnet gate (vitest drives generate + dotnet build + dotnet test) with no host SDK, mount the repo and the host Node
into the same image. Mount the checkout that owns node_modules (a worktree
under .claude/worktrees/ resolves it from the parent), and pass the proxy
through by name:
docker run --rm --network host -v /home/user/Loc:/home/user/Loc -w <worktree> \
-v /opt/node22:/opt/node22:ro -v /root/.ccr:/root/.ccr:ro \
-e HTTPS_PROXY -e HTTP_PROXY -e NO_PROXY -e NODE_EXTRA_CA_CERTS=/root/.ccr/ca-bundle.crt \
-e LOOM_DOTNET_BUILD=1 -e PATH=/opt/node22/bin:/usr/share/dotnet:/usr/bin:/bin \
mcr.microsoft.com/dotnet/sdk:10.0 bash -c \
'cp /root/.ccr/ca-bundle.crt /usr/local/share/ca-certificates/proxy.crt \
&& update-ca-certificates >/dev/null \
&& npx vitest run test/e2e/generated-dotnet-build.test.ts'
The emitted xUnit project is its own csproj (Tests/<Ns>.Tests/), which the
root dotnet build never compiles (<Compile Remove="Tests/**" />), and a
dotnet test at the project root runs zero tests and exits 0. Build and run
it by path: dotnet test Tests/<Ns>.Tests/<Ns>.Tests.csproj. The gate does
exactly that and fails on zero tests run.
Running mix on the HOST — for the suites that can't use a container
The table above is about compiling an emitted project, where a
throwaway container is fine. Some suites can't work that way: the
behavioural runner test/behavioral/run-elixir.mjs shells out to mix
from Node, boots the Phoenix app as a long-lived child process, and
HTTP-dispatches at it. Wrapping that in docker run means mounting the
repo, the host Node and a postgres route into the container just to reach
the same mix.
The simpler route is to lift the toolchain out of the image and run it
natively. The hexpm/elixir binaries are built for Debian bookworm and
run unmodified on the Ubuntu 24.04 host (newer glibc, backward
compatible):
IMG=hexpm/elixir:1.18.4-erlang-27.3.4-debian-bookworm-20260610-slim
CID=$(docker create "$IMG")
mkdir -p /opt/elixir-toolchain
docker cp "$CID":/usr/local/lib/elixir /opt/elixir-toolchain/elixir
docker cp "$CID":/usr/local/lib/erlang /opt/elixir-toolchain/erlang
docker rm "$CID"
export PATH=/opt/elixir-toolchain/erlang/bin:/opt/elixir-toolchain/elixir/bin:$PATH
export ELIXIR_ERL_OPTIONS="+fnu" # else the VM warns about latin1 name encoding
elixir --version # Elixir 1.18.4 (compiled with Erlang/OTP 27)
mix local.hex --force and mix deps.get reach hex.pm directly from the
host's Erlang, so LOOM_HEX_MIRROR is not needed on this path — the
fingerprint problem below is specific to the container. With a postgres
sidecar (docker run -d -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:16, plus the app/d/wc/pv/orx databases the fixtures
use), node run-elixir.mjs [case…] then runs locally at roughly a minute
per case.
This matters more than a convenience: the M-T9.11 wire-differential was run against four backends locally and elixir "by reasoning" for weeks, and the first real elixir boot found four divergences, two of them on rules already recorded as five-way conforming.
Java has the same shape of problem, and the container recipe does NOT
solve it. run-java.mjs shells out to host gradle/java — it has
no container path at all, so "build it in gradle:9-jdk25" applies to
hand-compiling a deployable and not to the behavioural runner. A host
with Gradle 8.x + JDK 21 fails it outright:
Cannot find a Java installation … {languageVersion=25}
Install both to the host and point the run at them:
# Temurin JDK 25 -> /opt/jdk25, Gradle 9.6.1 -> /opt/gradle-9.6.1
JAVA_HOME=/opt/jdk25 PATH=/opt/gradle-9.6.1/bin:$PATH node run-java.mjs [case…]
Port collision when running adapter legs side by side. run-java.mjs,
run-dapper.mjs and run-mikroorm.mjs all default to 8125
(LOOM_BH_DAPPER_PORT / LOOM_BH_MIKRO_PORT). Harmless in CI, where each
is its own workflow, but two of them on one host produce spurious 404/401
failures that look like wire divergences. Override the port when running
them concurrently.
The DATABASE collides too, and it fails differently. run-java.mjs and
run-dotnet.mjs both default to the app database, and each resets it
(drop every schema, recreate public) at the START of its own case — so
whichever leg boots second wipes the schema the first one is using, and
whichever boots while the other has already migrated finds a public that
is not its own. On the Java leg that surfaces as a Flyway refusal that
names neither the other backend nor the database:
FlywayException: Found non-empty schema(s) "public" but no schema history
table. Use baseline() or set baselineOnMigrate to true …
The giveaway is \dt public.* showing the OTHER backend's bookkeeping
table — __EFMigrationsHistory (EF Core) under a Flyway error, or
flyway_schema_history under an EF one. Neither is a product bug and
neither is a wire divergence: it is two legs sharing one database. Run the
legs SEQUENTIALLY, or give each its own database the way the port note
above says to give each its own port — there is no dedicated knob, so
override the whole connection setting:
SPRING_DATASOURCE_URL=jdbc:postgresql://127.0.0.1:5432/app_java \
node run-java.mjs [case…]
ConnectionStrings__Default='Host=127.0.0.1;Port=5432;Database=app_dotnet;Username=postgres;Password=postgres' \
node run-dotnet.mjs [case…]
CI never sees this: every leg is its own workflow with its own sidecar.
LOOM_HEX_MIRROR — Elixir builds behind a fingerprinting proxy
Some egress proxies allowlist by the client's TLS fingerprint: the
system OpenSSL fingerprint (curl, .NET, Gradle, Python's stdlib ssl)
is accepted, but Erlang/OTP's :ssl is rejected with a bare HTTP 503
even though the CA is trusted and SNI is correct. That makes
mix local.hex / mix deps.get fail inside the Elixir container, which
otherwise blocks every Dockerised Phoenix build — the daemon, the image
pull, and ddd generate system all succeed; only Hex's network calls
fail.
Set LOOM_HEX_MIRROR=1 to work around it. test/e2e/support/hex-mirror.ts
starts a loopback TLS-terminating mirror (scripts/hex-mirror.py, stdlib
Python only) that re-originates hex.pm traffic with the accepted
fingerprint, and runs the build container with
--network host --add-host {builds,repo,hex}.hex.pm:127.0.0.1, the mirror
CA mounted, and HEX_CACERTS_PATH pointed at it (Hex uses its own CA
bundle, not the OS store). Bytes pass through verbatim, so Hex's registry
signature and tarball checksums still verify.
# Phoenix build behind a TLS-fingerprinting proxy (needs python3 + openssl
# on the host and the privilege to bind :443):
LOOM_PHOENIX_BUILD=1 LOOM_HEX_MIRROR=1 npx vitest run \
test/e2e/generated-phoenix-build.test.ts -t "paged.ddd"
Unset (the default, and every CI runner with direct hex.pm access) the
flag is a no-op and the suite runs docker run exactly as before.
Run the Elixir suites case by case, not as a full sweep. The mirror is a
single loopback process serving one docker run at a time, and it stops
answering partway through the FIRST case of a long run: every case after that
dies in mix local.hex with ** (Mix) request timed out after 60000ms, a
uniform ~63s per case, before any generated Elixir is compiled. A whole-file
LOOM_PHOENIX_VANILLA_BUILD=1 npm run test:phoenix therefore reports something
like 67 failed | 1 passed that says nothing about the emitted code — the one
pass is case 1, and the rest never got their dependencies. Use -t "<fixture>"
to run the cases a change actually touches (each gets a fresh mirror), and
leave the full sweep to CI, which shards elixir-vanilla-build.yml and has
direct hex.pm access.
Java images behind a fingerprinting proxy — build the jar on the host
The generated Java deployable's Dockerfile is a two-stage build whose first
stage runs Gradle inside the gradle image. Behind the same
fingerprint-allowlisting proxy, that stage fails the way Erlang does: the
in-container JVM's TLS fingerprint is rejected (bare 503 / connection reset
resolving the Spring Boot plugin), while the host Gradle — same
repositories, same proxy — resolves everything (pass the proxy to the JVM via
JAVA_TOOL_OPTIONS="-Dhttps.proxyHost=… -Dhttps.proxyPort=…" if it isn't
picked up from the environment).
The recipe that works everywhere: build the bootJar outside the image, containerise only the jar.
cd out/<java-deployable>
# The emitted toolchain is Java 25, so this needs a JDK 25 + Gradle 9.1+ —
# NOT the sandbox host's JDK 21 / Gradle 8.14. Either a host with those
# versions, or the `gradle:9-jdk25` container from the table above.
gradle bootJar
cp build/libs/app.jar app.jar # .dockerignore excludes build/,
# so copy the jar INTO the context
cat > Dockerfile.local <<'EOF'
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY app.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
EOF
docker build -f Dockerfile.local -t <service> .
Point the service at the local image (edit the compose service's image: /
drop its build:) and the rest of the stack boots normally. This is a
sandbox-only workaround, not a generator concern — CI runners and normal dev
machines run the in-container Gradle stage as-is.
Generated DSL-level e2e suite
When the source declares test e2e blocks (see
language.md), ddd generate system emits a
ready-to-run vitest project at <outdir>/e2e/. The Loom e2e itself
runs that suite against the live compose stack as a follow-on step,
proving the DSL → fetch lowering reaches the live API:
# After `docker compose up -d` from the system root:
cd ./out/e2e
npm install && npm test
Endpoints default to http://localhost:<port> for each deployable.
Override per environment via E2E_<DEPLOYABLE>_BASE (e.g.
E2E_API_BASE=https://staging.example.com).
The e2e reset seam
The suite drives a REAL database through a running backend, so without
a reset it would not be idempotent: a block asserting an exact count
(expect(listed.total).toBe(2)) would be green on a fresh database and
red on the second run of the same one. Since docker compose up keeps
a named pgdata volume, that second npm test is the common case, not
the exotic one.
(Blocks within ONE run still see each other's rows by default, which is
often deliberate — see per-test below for the stronger contract.)
So the emitted suite can call a dev-only reset endpoint
(POST /__loom/test-reset) — by default once per target, before the
first test. It truncates every application table of every tenant
and re-applies any declared seed data, restoring the
just-migrated-and-seeded state, not an empty database, so fixtures that
assume seeded rows still find them. The migration ledger, the timer
watermark and the pg-boss job store are preserved. Cost is one loopback
round trip (median 6 ms against a local Postgres).
It is opt-in on both ends, with a shared secret — it is an "erase the database" button, so nothing turns it on implicitly (finding H-30: it used to be on by default in dev and forced on by the generated compose file, and running the generated suite against a dev stack holding real data erased it). Use it only against a throwaway stack:
export LOOM_TEST_RESET_TOKEN=$(openssl rand -hex 16)
LOOM_TEST_RESET=1 <start each backend> # backend: switch + token
cd e2e && npm test # suite: same token
E2E_RESET chooses when it fires:
| Value | Behaviour |
|---|---|
per-file (default) |
Once per target, before the first test that uses it. The suite starts from the same state every run, so a second npm test behaves exactly like the first. |
per-test |
Before every test, so a block sees only the rows it creates and a count assertion no longer depends on block order. One extra round trip per block. |
off |
Never. |
Why per-file is the default. A test e2e block may deliberately
build on rows an earlier block created — several of this repo's own
fixtures do, one of them named "the second … beside the first" — so
resetting between blocks changes what those models mean. Per-file
fixes the idempotence problem without changing any block's meaning.
Choose per-test when the suite is written for it: every block
creating its own fixtures and asserting only its own counts.
A per-test transaction would be cheaper still, but it is structurally unavailable: the suite talks HTTP to a separate process, so it has no transaction to share with the request handler.
It cannot fire unless both ends opted in, nor against a non-local target. Three independent gates:
| Gate | |
|---|---|
| In the suite | The reset is only sent when the suite holds LOOM_TEST_RESET_TOKEN and the resolved base URL is a loopback address (localhost, *.localhost, 127.0.0.0/8, [::1]). Without the token it warns once and runs against shared state. Pointing the suite at a deployed environment — E2E_API_BASE=https://staging.example.com — disables it by construction; there is deliberately no remote override. |
| In the backend: switch | Served only with an explicit LOOM_TEST_RESET=1 and a non-empty LOOM_TEST_RESET_TOKEN. Nothing is inferred from a profile (NODE_ENV, ASPNETCORE_ENVIRONMENT, MIX_ENV), and the generated docker-compose.yml sets neither variable. Switch on without a token → not served (node/.NET/python log why at boot). |
| In the backend: secret | Every request must carry the token in the x-loom-test-reset header, compared in constant time (timingSafeEqual / FixedTimeEquals / hmac.compare_digest / MessageDigest.isEqual / Plug.Crypto.secure_compare). Otherwise 403, having touched nothing. The path sits outside the auth middleware (so an auth-bearing suite need not mint a principal), which is why the token is mandatory. |
Node, python and .NET do not REGISTER the route unless switched on with a token, so the path does not exist at all. Phoenix and Spring build their routes at compile time / context refresh, so there the route is always defined and the handler answers 404 instead — the same nothing touched.
When the suite holds a token but a backend was not switched on, that backend answers 404 and the suite warns once and carries on with shared state:
[e2e] No state reset at http://localhost:4000 (404) — these tests SHARE a database.
They are green on a fresh one and can fail on a re-run.
Enable it by starting the backend with LOOM_TEST_RESET=1 and the same
LOOM_TEST_RESET_TOKEN. …
A 403 (token mismatch) fails the suite, as does any other failure — a 500 from the truncate, a proxy in the way — because that is a fault rather than a choice.
Set E2E_RESET=off in the suite's environment to skip the reset
entirely.
When the reset is unavailable — a target that is not loopback, or a
backend that does not register the route — the suite assumes a fresh
database per run. Start one with docker compose down -v && docker compose up -d, or write assertions that tolerate shared state
(toBeGreaterThanOrEqual, namespaced fixture data).
React frontend deployable
A platform: react deployable produces a Vite-built SPA with React,
React Query, Zod, and Mantine. It points at another deployable
(targets: <name>); the API base URL is baked from the target's
port at generation time. Modules are inherited from the target so
the frontend's pages match the backend's surface exactly.
Per aggregate the generator emits three pages:
| Route | What it does |
|---|---|
/<plural> |
Mantine <Table> of every record from useAll<Agg>(); row links to detail; create button. |
/<plural>/:id |
Card with primitive / VO fields, sub-tables for contained parts, one button per public op. |
/<plural>/new |
Mantine useForm over Create<Agg>Request, validated by Zod; submit calls useCreate<Agg>. |
Public operations on the detail page open a Mantine modal with a form for the operation's params; on submit, the matching mutation hook fires and React Query invalidates the affected queries so the page reflects the new state.
Forms use @mantine/form + mantine-form-zod-resolver; success and
error from mutations surface via @mantine/notifications.
The generated app is a regular Vite project — npm install && npm run build && npm run preview builds and serves a production bundle.
Playwright UI tests (page objects per aggregate)
Every React deployable ships a Playwright suite under <deployable>/e2e/:
<deployable>/
├── src/ # the app
└── e2e/
├── package.json # @playwright/test (kept out of the runtime image)
├── playwright.config.ts # baseURL = http://localhost:<port>, override via E2E_BASE_URL
├── smoke.spec.ts # auto-generated: every list page loads
├── <System>.ui.spec.ts # auto-generated from `test e2e "..." against <react-deployable>` blocks (when present)
└── pages/
├── order.ts # OrderListPage / OrderNewPage / OrderDetailPage
└── product.ts
The DSL test e2e "..." against <react-deployable> { … } form lowers
to a Playwright spec (<System>.ui.spec.ts) routing every
ui.<aggregate>.<verb>(...) call through the page objects above —
see language.md.
Page-object classes are derived directly from the IR. Per aggregate:
<Agg>ListPage—goto(),create(),row(id),open(id)<Agg>NewPage—goto(),fill(input: Partial<Create<Agg>Request>),submit() → <Agg>DetailPage<Agg>DetailPage—goto(),field(name),<part>Count(), plus one method per public DSL operation typed against<Op>Request
A user-written test reads top-down:
import { OrderListPage } from "./pages/order.js";
import { ProductListPage } from "./pages/product.js";
test("create order, add line, confirm", async ({ page }) => {
const prod = await new ProductListPage(page).goto()
.then((p) => p.create())
.then((p) => p.fill({ sku: "W-1", price: { amount: 5, currency: "USD" } }))
.then((p) => p.submit());
const ord = await new OrderListPage(page).goto()
.then((p) => p.create())
.then((p) => p.fill({ customerId: "cust", status: "Draft", placedAt: "2024-01-01T00:00" }))
.then((p) => p.submit());
await ord.addLine({ productId: prod.id, qty: 3 });
await ord.confirm();
await ord.goto();
expect(await ord.field("status")).toBe("Confirmed");
});
The selectors come from stable data-testid attributes the React generator
sprinkles on every interactive element:
| Element | testid pattern |
|---|---|
| List page root | <plural>-list |
| List "Create" button | <plural>-list-create |
| List row | <plural>-row-<id> |
| List row link to detail | <plural>-row-<id>-link |
| List cell | <plural>-row-<id>-<field> |
| New page root | <plural>-new |
| New form input | <plural>-new-input-<field> (nested for VOs) |
| New "Create" submit | <plural>-new-submit |
| Detail page root | <plural>-detail |
| Detail field display | <plural>-detail-<field> |
| Operation button | <plural>-op-<opName> |
| Operation modal form | <plural>-op-<opName>-form |
| Operation modal input | <plural>-op-<opName>-input-<field> |
| Operation modal submit | <plural>-op-<opName>-submit |
| Contained-part subtable | <plural>-detail-<containment> |
| Contained-part row | <plural>-detail-<containment>-row-<id> |
To run against the live compose stack:
docker compose up -d
cd <deployable>/e2e
npm install
npx playwright install --with-deps chromium
npx playwright test
Proxy CAs (sandboxed builds)
Each generated deployable contains an empty certs/ directory (with a
.gitkeep placeholder). Drop your proxy's *.crt files into
<deployable>/certs/ before docker compose build and the build
trusts them — the Dockerfile already declares the necessary COPY and
update-ca-certificates/NODE_EXTRA_CA_CERTS lines. An empty
certs/ is a no-op, so this costs nothing in environments that don't
need it.
Every build stage, not just the main one. A fullstack image has
more than one: the Phoenix LiveView assets-build stage and the
embedded-SPA spa-build stages on the .NET / Java / Python / Phoenix
images each run their own npm install, so each opens with its own
COPY certs/ before that install. The Phoenix assets-build stage
used to install first and copy the CAs afterwards, which made elixir
the one platform in the matrix that could not build behind a
TLS-terminating proxy (M-FT.13, finding G10). The ordering is pinned
by test/system/generation-defaults.test.ts, which walks each emitted
Dockerfile stage by stage.
The opt-in LOOM_E2E_CA_DIR environment variable (used by
test/e2e/e2e.test.ts) just copies the host's CAs into each deployable's
certs/ for you; no Dockerfile rewriting.
Cross-platform OpenAPI parity check
When the same subdomain is hosted on more than one deployable across
different platforms (Hono, .NET, Phoenix, Python, Java — all five are
diffed), the e2e additionally diffs their OpenAPI specs across every
backend pair to catch generator drift. Each backend self-describes via
its framework-native OpenAPI emitter (Java's springdoc spec is brought
to parity by an OpenApiContractCustomizer document filter):
Every backend serves the spec at the aligned path /openapi.json (root):
| Platform | Library | Endpoint |
|---|---|---|
| .NET | Swashbuckle.AspNetCore | /openapi.json |
| Hono | @hono/zod-openapi |
/openapi.json |
| Phoenix | OpenApiSpex | /openapi.json |
| Python | FastAPI | /openapi.json |
| Java | springdoc | /openapi.json |
Interactive UIs (FastAPI's /docs + /redoc, springdoc's /swagger-ui.html)
are gated by LOOM_OPENAPI_UI (default on; the k8s chart sets it false to
keep an unauthenticated API explorer off production). The /openapi.json spec
stays available regardless.
The check fetches each diffed backend's spec, runs
diffSpecs(ref, other) → ParityDiff (pure helper in
test/_helpers/openapi-normalize.ts) for every backend pair over the
five diffed backends — ten pairs (hono ↔ dotnet, hono ↔ phoenix,
dotnet ↔ phoenix, hono ↔ python, dotnet ↔ python,
phoenix ↔ python, hono ↔ java, dotnet ↔ java, python ↔ java,
phoenix ↔ java) — and reports any divergence across the dimensions:
ops sets, response cardinality, schemas sets, per-schema
field/required-set drift, per-property type/format, path-param types,
query params, request- and response-body schema refs, operationIds,
enum value-sets, and error responses.
The CI workflow (.github/workflows/conformance-parity.yml) runs in
strict mode (LOOM_E2E_STRICT_PARITY=1): each divergence is a hard
expect(...).toBe(...) assertion. Local npm run test:e2e defaults to
report-only — the divergence list logs as console.warn but the test
passes either way.
Why framework-native rather than emitting OpenAPI from the IR:
- Drift you want to catch (typo in a controller, wrong status code, schema mismatch) only shows up if the spec is derived from what the framework actually serves. An IR-based spec would agree with itself even when the running code disagrees.
- Both libraries are well-supported in their ecosystems, so the generator's output looks like normal hand-written code rather than carrying a bespoke OpenAPI emitter.
For the full dimension reference, how to read a divergence report,
and the checklist for adding a tenth dimension, see
conformance.md.
Compiling generated FRONTENDS locally
The generated-frontend build gates (generated-{react,vue,svelte,angular}-build,
generated-feliz-build, generated-flutter-build) run in CI, but all six can
be reproduced by hand — generate a system, then run that frontend's real build
in the emitted project:
node bin/cli.js generate system <f.ddd> -o out
cd out/web && npm install && npx tsc --noEmit # React (+ any static-bundle host)
cd out/web && npm install && npx vue-tsc --noEmit # Vue
cd out/web && npm install && npx svelte-check # Svelte
cd out/web && npm install && npx ng build # Angular ← see the Node floor below
The Angular CLI has a Node floor the sandbox image sits just under. It
requires Node ≥ 22.22.3 (or 24.15 / 26.0); the container ships 22.22.2, so
ng build refuses to start with a version message and it looks as though
Angular simply can't be verified here. It can — fetch a newer Node into the
scratchpad and put it first on PATH for that build only:
curl -fsSL -o node.tar.xz https://nodejs.org/dist/v24.15.0/node-v24.15.0-linux-x64.tar.xz
tar xf node.tar.xz && export PATH="$PWD/node-v24.15.0-linux-x64/bin:$PATH"
npx ng build # in the emitted out/web
Feliz builds here too — it does not "stay CI's to answer" (this paragraph
said so until 2026-09-13; re-measured that day and corrected). Fable is a dotnet
tool and the image ships no .NET SDK, but Microsoft's own install script puts one
in the scratchpad in under a minute, and dotnet fable + vite build then run
green on a generated Feliz app:
curl -fsSL https://dot.net/v1/dotnet-install.sh -o dotnet-install.sh
bash dotnet-install.sh --channel 8.0 --install-dir "$PWD/dotnet" --no-path # 8.0 = the TFM the emitted App.fsproj targets
export PATH="$PWD/dotnet:$PATH" DOTNET_ROOT="$PWD/dotnet"
cd out/web && npm install && npm run build # = dotnet tool restore && dotnet fable + vite build
Measured 2026-09-13 on sales-system-feliz: Fable parsed 47 source files and
compiled in ~15 s, vite bundled in ~3 s, and the full behavioural UI leg
(cd test/behavioral && node run-ui.mjs sales-system-feliz — real Hono backend on
PGlite, real headless Chromium) came back 11 passed, 0 failed. So the Feliz
cell of frontend-fullstack-e2e is reproducible locally like every other
frontend; do that before pushing rather than reading CI.
Everything else, including all three Angular design packs, builds locally in a couple of minutes and is far cheaper than a CI round-trip.
Flutter: analyzing a generated app locally
Try the plain path first — it now works (re-verified 2026-08-10 against a
freshly generated app): in ghcr.io/cirruslabs/flutter:stable, an ordinary
flutter pub get resolves and flutter analyze reports No issues found! on a
FULL generated app, deps and all.
docker run --rm --network host -v <deployable>:/src -w /src \
-v /root/.ccr:/root/.ccr:ro -e CURL_CA_BUNDLE=/root/.ccr/ca-bundle.crt \
-e HTTPS_PROXY="$HTTPS_PROXY" -e HTTP_PROXY="$HTTP_PROXY" \
ghcr.io/cirruslabs/flutter:stable bash -c 'flutter pub get && flutter analyze'
So generated-flutter-build is no longer the only thing that can answer for
Flutter — analyze locally before pushing, as with every other backend.
The full runtime leg runs here too, with a one-file flutter shim.
test/behavioral/run-ui-flutter.mjs shells out to whatever FLUTTER names, with
the generated deployable as its cwd — so a wrapper that forwards to the container
gives the leg an SDK without touching the harness:
cat > /tmp/flutter-docker <<'SH'
#!/bin/bash
set -euo pipefail
exec docker run --rm --network host -v "$PWD":/src -w /src \
-v /root/.ccr:/root/.ccr:ro -v "$HOME/.pub-cache":/root/.pub-cache \
-e CURL_CA_BUNDLE=/root/.ccr/ca-bundle.crt \
-e HTTPS_PROXY="${HTTPS_PROXY:-}" -e HTTP_PROXY="${HTTP_PROXY:-}" \
ghcr.io/cirruslabs/flutter:stable flutter "$@"
SH
chmod +x /tmp/flutter-docker
cd test/behavioral && FLUTTER=/tmp/flutter-docker node run-ui-flutter.mjs
Measured 2026-09-13 (Flutter 3.44.0 in that image): 4 passed, 0 failed,
including the numeric round-trip probe, and red on a re-seeded F1 money decode.
The -v "$HOME/.pub-cache" mount is what keeps a re-run from re-resolving pub
deps. One wrinkle worth knowing: on this sandbox the browser's cross-origin
fetches are reset by the proxy, which used to surface as an opaque
Failed to load resource: net::ERR_CONNECTION_RESET console line and failed all
four probes for an environment reason; the leg now attributes a failed request to
an origin and only fails on its OWN.
Fallback: the pub.dev TLS wrinkle
Some proxies DO refuse Dart's own TLS stack — flutter pub get then fails with
Got TLS error trying to find package … at https://pub.dev. That is the SAME
fingerprint wrinkle documented above for Erlang/hex: system OpenSSL passes and
the language's own TLS stack does not. The proof is one command — inside the
container, curl https://pub.dev/api/packages/vector_math returns 200 while
Dart's fetch of the same package is refused.
If you hit it, seed the pub cache with curl and resolve offline. Flutter
pins its runtime deps to exact versions in the SDK's own pubspec, so fetch those:
# inside the container, with -v /root/.ccr:/root/.ccr:ro -e CURL_CA_BUNDLE=/root/.ccr/ca-bundle.crt
CACHE=~/.pub-cache/hosted/pub.dev
grep -oP '^ \K[a-z_0-9]+(?=:\s*[0-9])' /sdks/flutter/packages/flutter/pubspec.yaml | while read -r n; do
v=$(grep -oP "^ $n:\s*\K[0-9][0-9.]*" /sdks/flutter/packages/flutter/pubspec.yaml)
mkdir -p "$CACHE/$n-$v"
curl -sL "https://pub.dev/api/archives/$n-$v.tar.gz" | tar xz -C "$CACHE/$n-$v"
done
flutter pub get --offline && flutter analyze
That is enough to flutter analyze any generated file whose imports are
SDK-only — which covers the hand-written runtime widgets (lib/modal.dart).
It does NOT cover a full generated app, whose pub deps (flutter_riverpod,
http, intl + transitives) need a constraint-satisfying version solve the
seeding trick doesn't do. On a proxy that blocks Dart's TLS, a pub.dev mirror
(the scripts/hex-mirror.py equivalent) is the durable fix and
generated-flutter-build stays the authority — but check the plain path above
first, since it resolves the full dependency set when the proxy allows it.
vitest --reporter=basicno longer exists in vitest 4 — it fails at startup with "Failed to load custom Reporter from basic". Use the default reporter.
Local enforcement hooks (.claude/hooks/)
Three checked-in Claude Code hooks, wired by .claude/settings.json, so the
gates CI runs anyway cannot be forgotten locally. .claude/ is gitignored
except settings.json and hooks/, so the hooks ship with the repo while
worktrees and settings.local.json stay local.
| hook | script | what it does |
|---|---|---|
SessionStart |
session-start.sh |
npm install (the prepare lifecycle) on a fresh remote container. Idempotent, remote-only ($CLAUDE_CODE_REMOTE). |
Stop |
biome-gate.sh |
npm run lint (biome ci ., the exact test.yml step) when a turn ends with work in the tree. Blocks and feeds the output back, then releases with a loud warning after one fix cycle so it cannot loop. Never blocks when Biome isn't installed. |
PreToolUse(Bash) |
pre-push-merge-check.sh |
Gates git push — see below. Non-push Bash calls early-exit, so it costs nothing per call. |
The push preflight
pre-push-merge-check.sh runs three gates on a git push, in order:
Sync-only — a push whose only new commits are merges of
origin/main(every non-merge commit it adds is already onmain) is denied, unless the old remote head conflicted withmainor the push is marked deliberate withLOOM_ALLOW_SYNC=1(in the environment or on the command line). Such a push changes nothing a reviewer reads but re-runs the whole per-PR check set; dozens at once starve the shared runner pool. The merge queue re-tests every entry on currentmainanyway. Merging another PR's branch (a stack), a rebase, or any push with a commit of its own passes. Pinned bytest/system/pre-push-sync-gate.test.ts. The rules behind it are in.claude/skills/steward/SKILL.md.Textual —
git merge-tree --write-tree origin/main HEAD. A definite conflict denies the push, listing the conflicting paths.Semantic — on a clean merge, the hook materialises
origin/main + HEADin a throwaway worktree (git commit-tree+git worktree add --detach, borrowing the checkout'snode_modulesby symlink so nothing installs) and runs there:npx tsc -b node scripts/test-typecheck.mjsEither failing denies the push with the failing output attached.
Then, in the same worktree, the fast whole-tree censuses and ratchets (
test/system/*census*.test.ts,*ratchet*.test.ts,typing-differential.test.ts) — every one except the three corpus-wide censuses that take 1.5–5 min each (diagnostic-firing,openapi-component-uniqueness,ddd-source), which stay CI-only. A census pins a whole-tree count, so two branches that each pass it can fail it together; that is the commonest reason a green PR is ejected from the merge queue, and an ejection discards the build of every entry queued behind it. It denies only when vitest ran and reported failed tests.
Why the second gate exists. A clean merge is necessary, not sufficient.
Two branches merge textually and still fail to compile together — one renames a
symbol the other starts using, one adds a field the other's new test constructs
without. git merge-tree cannot see that, and neither can a green local tsc
on the un-merged branch. It is the same combined tree the merge queue
re-runs, which means today the feedback arrives hours later and after a runner
slot; the hook moves it to ~1 minute before the push.
It fails open on everything else. No npx, no borrowable node_modules,
an unparsable merged-tree OID, a worktree that will not create, a 900 s
timeout per gate, not a git repo, no origin/main, an offline fetch, git older
than 2.38, or pushing trunk itself — all ALLOW. It denies on exactly three
things: a sync-only push, a definite conflict, and a gate that ran to
completion on the merged tree and failed.
Opting out deliberately:
LOOM_SKIP_PUSH_TYPECHECK=1 git push … # skips gate 2 (and the censuses); gate 1 still runs
LOOM_SKIP_PUSH_CENSUS=1 git push … # skips only the censuses
Cost, measured: ~51 s for the typecheck gates on a warm node_modules,
plus ~85–100 s for the censuses on 4 cores; ~1 s with LOOM_SKIP_PUSH_TYPECHECK. The throwaway worktree is removed by a top-level
EXIT trap — top-level rather than function-scoped RETURN, because the deny
path exits the script and an exit from inside a function never runs that
function's RETURN trap.