← Back to home

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).


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:

  1. No surprises across regens. If package.json is 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.
  2. 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:

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):

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:

  1. Generates the examples/acme.ddd system to a temp directory.
  2. Runs docker compose build on the resulting tree.
  3. Runs docker compose up -d (postgres + both deployables).
  4. Polls /health on every deployable's port until each returns {"status":"ok"}.
  5. 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:

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:

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=basic no 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:

  1. Sync-only — a push whose only new commits are merges of origin/main (every non-merge commit it adds is already on main) is denied, unless the old remote head conflicted with main or the push is marked deliberate with LOOM_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 current main anyway. Merging another PR's branch (a stack), a rebase, or any push with a commit of its own passes. Pinned by test/system/pre-push-sync-gate.test.ts. The rules behind it are in .claude/skills/steward/SKILL.md.

  2. Textual — git merge-tree --write-tree origin/main HEAD. A definite conflict denies the push, listing the conflicting paths.

  3. Semantic — on a clean merge, the hook materialises origin/main + HEAD in a throwaway worktree (git commit-tree + git worktree add --detach, borrowing the checkout's node_modules by symlink so nothing installs) and runs there:

    npx tsc -b
    node scripts/test-typecheck.mjs
    

    Either 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.