← Back to home

Loom — Generator Reference

Per-platform reference for every file the generators emit and the features they implement. This document maps each DSL construct to its output across the five backends and six frontends so you can answer questions like:

For language syntax see language.md; for architecture see technical.md; for CLI / Docker / Playwright workflow see tools.md.

To see one identical domain lowered onto every backend, pick the Storefront trio from the playground dropdown — storefront-system (Hono + React), storefront-dotnet (.NET + embedded SPA), and storefront-elixir (Elixir/Phoenix LiveView). All three share the same aggregate tree (Order → OrderLine + Money), Wallet aggregate, and transactional checkout saga, so diffing their output is the fastest way to read this matrix concretely.


Cross-platform feature matrix

The persistence stack shown per column is the default adapter (Drizzle on node, EF Core on .NET). Persistence is a selectable realization axis — platform: dotnet { persistence: dapper }, platform: node { persistence: mikroorm } — see the "Realization axes" section of platforms.md. The alternates (dapper, mikroorm) share the domain layer below and only swap the repository/schema layer; both are now at full parity with their default (M-T6.9) — see platforms.md → "Realization axes". Schema evolution differs between them. Dapper applies the same versioned MigrationsIR chain as EF Core: Migrations/*.cs, run at boot by DbSchema.EnsureAsync behind public.__loom_migrations, projected onto its jsonb value-object layout. A database provisioned by the old CREATE TABLE IF NOT EXISTS bootstrap is baselined on its first migration-aware boot. See migrations.md → "persistence: dapper". MikroORM still self-provisions with schema.update(), and declared migration steps are refused on it.

Scope note. The construct-by-construct matrix below tracks the three reference platforms (TypeScript/Hono, .NET, React) for readability — it maps each DSL construct to its emitted shape in idiom. The other backends — Python/FastAPI, Java/Spring Boot, and Elixir/Phoenix — and the other frontends — Vue, Svelte, and Angular — consume the same LoomModel / wireShape IR contract and emit the analogous constructs in their own idiom. See platforms.md for the full registered set.

Five-backend feature parity

Which of the five domain-logic backends emits each gated feature vs. fails fast at validate. A cell is ✓ (emitted, build-gate-verified), 🚫 (gated — the validator rejects the combination with the named loom.* diagnostic, a reviewed decision), or ⚠ (partial — see the note). Elixir runs on plain Ecto/Phoenix only (the Ash foundation was removed; foundation: ash is a validation error). This grid is the live view, derived from the validator gate sets in src/ir/validate/checks/ and frozen against drift by test/platform/backend-parity-gates.test.ts (a backend can't be silently ungated-and-unemitting — the F1 class of bug). The dated baseline write-up is audits/backend-feature-parity-2026-06.md; remaining gaps + sequencing are in plans/backend-parity-plan.md.

Feature node dotnet java python elixir Gate
Event-sourced storage persistedAs: eventLog ✓ ✓ ✓ ✓ ✓ (universal)
Event-sourced workflow (saga appliers) ✓ ✓ ✓ ✓ ✓ (universal)
TPH inheritance inheritanceUsing: sharedTable ✓ ✓ ✓ ✓ ✓ (universal)
TPC inheritance inheritanceUsing: ownTable ✓ ✓ ✓ ✓ ✓ (universal)
Discriminated unions / the paged carrier ✓ ✓ ✓ ✓ ✓ (universal)
The envelope carrier — a single-row find (see below) ✓ ✓ ✓ ✓ ✓ corpus envelope + test/generator/envelope-carrier.test.ts
when canCommand gate + can_<op> query ✓ ✓ ✓ ✓ ✓ (universal)
Exception-less returns (op(): X or NotFound) ✓ ✓ ✓ ✓ ✓ (universal)
Capability filter — relational (non-principal) ✓ ✓ ✓ ✓ ✓ LIMITED_FAMILIES
Capability filter — principal (currentUser/tenancy) ✓ ✓ ✓ ✓ ✓ system-checks.ts
Provenanced fields (runtime trace) ✓ ✓ ✓ ✓ ✓ (universal)
Per-operation audited ✓ ✓ ✓ ✓ ✓ (universal)
Audited lifecycle (audited create/destroy) ✓ ✓ ✓ ✓ ✓ (universal)
Audit/context stamping (with audit) ✓ ✓ ✓ ✓ ✓ (universal)

envelope is a single-row find (M-T6.57, ratified 2026-09-10). find audit(): Order envelope means "read at most one Order": the repository answers Order, the route serialises the bare body, and an empty result set is the not-found rung (404). It carries no distinct wire shape — the { id, ts, body } wrapper was the P3 design and never shipped, because nothing in the IR can source ts. T envelope and T therefore emit identically on every backend, which is what the gate on that row asserts.

Until that landed, the row above carried five ticks on output that did not build: java named Envelope<Order> in the repository port, the Spring Data interface and the impl and declared it nowhere; .NET returned a bare Order from a Task<Envelope<Order>> (CS0029); Elixir Repo.all-ed every row and answered a JSON array against its own single-object OpenAPI. Only node and python were honest. Nothing caught it because no .ddd in the repo instantiated the carrier — every compile gate was blind to it by construction, which is why test/fixtures/corpus/envelope.ddd now exists.

Re-verified 2026-08-23 against src/ir/validate/checks/system-checks.ts: every gate set in this table held all five backends — so the per-backend support sets (EVENT_SOURCING_BACKENDS, SUPPORTED_UNION_BACKENDS, AUDIT_OP_BACKENDS, …) were later deleted outright; (universal) means no per-backend gate exists, and test/platform/backend-parity-gates.test.ts pins the emission per backend. The former open gaps (principal Python filters W1b, per-op/lifecycle audited beyond node/dotnet W3, event-sourced workflow on Elixir W4) are closed. The gaps that remain are language-level rather than per-backend — see audits/language-gaps-2026-08.md.

Construct TypeScript (Hono + Drizzle) .NET (ASP.NET + EF + Mediator) React (Vite + RQ + Mantine)
enum TEXT column + exported union type (text(col, { enum: … })) C# enum + EF HasConversion<string> Zod z.enum([...])
valueobject Class with invariant ctor + accessors; flattened columns in Drizzle record with invariant ctor; OwnsOne in EF Zod object schema, nested <Fieldset> in forms
event TypeScript discriminated union; pushed via _events.push record implementing IDomainEvent; pushed via _events.Add (events are domain-internal; not surfaced to the SPA)
aggregate Class with private state, factory, ops, derived getters, pullEvents() Sealed class with private state, factory, ops, derived getters, PullEvents() List + Detail + New page; api hooks
entity part Same as aggregate but with _parentId Same as aggregate; mapped via OwnsMany Sub-table on detail page, master-detail row testids
abstract aggregate + extends / inheritanceUsing TPC (ownTable): standalone Drizzle table per concrete; a read-only <Base>Repository.findAll() union reader + <Base> discriminated-union type. TPH (sharedTable, default): one shared table + kind discriminator + nullable per-concrete columns; <Base> id refs + base reader supported. TPC: abstract class <Base> carrying shared fields, concretes : <Base>, EF Ignore<<Base>>() (each concrete maps standalone); read-only I<Base>Repository / <Base>Repository → IReadOnlyList<<Base>>. TPH (sharedTable): one shared table via EF Core native HasDiscriminator<string>("kind"); the abstract base owns the shared Id and DbSet<<Base>>, concretes inherit it (own columns only); <Base> id refs + base reader supported. Concrete subtypes carry the merged base fields in their wire shape; no base-specific page.
persistedAs: eventLog + apply(...) (event sourcing) Append-only <agg>_events stream table (stream_id, version, type, data, occurred_at); appliers render as a _apply(ev) fold + _fromEvents(id, events) rehydrator; emit records and folds; create builds an empty shell + runs its emit-only body (POST body = create params); repository folds the stream on load and appends pending events on save (fold-from-zero MVP). Both node persistences supported — Drizzle (default) and MikroORM (EntityManager event store + <agg>_events EntitySchema). EF Core and Dapper supported — <agg>_events table (on the DbContext / raw Npgsql), C# _Apply<Event> methods + _FromEvents rehydrator + _Apply dispatch switch; EF-only, not a dedicated Marten backend (D-DOCUMENT-AXIS). The persistence-agnostic fold + CQRS create chain are reused across both. (n/a — wire shape unchanged)
contains (collection) Drizzle table with parent_id FK; auto-loaded in repo EF owned-collection; auto-loaded by tracker Sub-table on detail; not editable in the create form
X id[] (reference collection) Auto-derived many-to-many join table (composite PK enforces set semantics); save diff-syncs join rows, .contains(param) lowers to an inArray subquery against the join table. The join table is just the composite key — no ordinal column; reads come back sorted by target id — see "What the generators don't do" below. EF Core join entity + DbSet<JoinEntity> (composite PK); GetByIdAsync loads via the join entity, SaveAsync diff-syncs, .contains(param) lowers to _db.<JoinDbSet>.Any(...). (Phoenix/Ecto backend: a many_to_many association through the <join> table + the id-array wire shape projected from the loaded association, set on create/update via put_assoc; .contains lowers to an EXISTS subquery against the join table.) X id[] appears in the wire shape as string[]; populated/displayed via the response, but no first-class editor yet
derived Getter that calls into the expression Computed property that calls into the expression Read-only field on detail; included in the response Zod schema
invariant Private _assertInvariants() called at the end of every mutator Private AssertInvariants() called at the end of every mutator (enforced server-side; surfaces as 422 in the UI)
provenanced property domain/provenance.ts SDK + recordTrace(...) after each write; ddd snapshot captures rule snapshots to .loom/snapshots/*.loomsnap.json Domain/Common/ProvLineage.cs SDK + inline lineage capture after each write; co-located <field>_provenance jsonb column; provenance_records flushed in the EF save transaction; current lineage exposed on <Agg>Response. Elixir emits the same shape — the <App>.Provenance SDK (process-dictionary trace buffer + flush/1), a co-located <field>_provenance jsonb column, inline capture at each named-op write site, and a provenance_records flush inside the save Repo.transaction. (n/a — wire shape unaffected)
function Private method on the aggregate / part class Private expression-bodied member (server-only)
operation Public method (or private if marked) that enforces preconditions, mutates state, queues events, and re-asserts invariants Same shape; visibility honoured Mantine button on the detail page; opens a modal whose form binds to <Op>Request; submit calls use<Op><Agg>()
precondition if (!cond) throw new DomainError(<source>) if (!cond) throw new DomainException(<source>) (server-side; HTTP 422 surfaces as a Mantine error notification)
emit _events.push({ type: "X", … }) _events.Add(new X(...)) (server-side)
repository find Method on <Agg>Repository; where clauses lower to Drizzle predicates (lowerToDrizzle) over the queryable subset, paramless finds fall back to convention-matching Method on I<Agg>Repository; LINQ .Where(x => …) for both convention and where forms use<FindName><Agg>(query) React Query hook + a list-page filter bar (one input per where param) that drives the hook, falling back to useAll<Agg>() when unfiltered
criterion (inline use-site) Predicate body re-lowered + substituted at each where / invariant / precondition (same Drizzle predicate as a hand-written filter) Same — inlined into the LINQ .Where(...) / guard (server-side; not surfaced)
criterion reified by a retrieval or find where Module-level predicate fn <name>Criterion = (args) => <Drizzle predicate>, called by run<Name> and the matching find (one fn, deduped across both) Criterion<T> (IsSatisfiedBy + ToExpression()) fed into the retrieval's Ardalis Specification<T> bundle and a find's .Where(crit.ToExpression()) (EF); a parameterised SQL fragment on Dapper. (Phoenix/Ecto backend: a shared <name>_criterion/1 query fragment (an Ecto dynamic) the read filters by — one fragment shared by retrieval + find.) (n/a — wire shape unchanged)
retrieval (named query bundle) run<Name>(args, page?) on <Agg>Repository — where + .orderBy(...) + .limit/.offset paging Run<Name>Async(args, page?, ct) — .WithSpecification(spec).ApplyPaging(page).ToListAsync(ct) (EF) / parameterised SQL (Dapper). (Phoenix/Ecto: a context query function + limit/offset page.) (n/a — backend-only)
Auto findById / getById Yes — load root + parts in a transaction; getById throws on missing Yes — GetByIdAsync returns Order?, getById is implicit via the controller raising 404 use<Agg>ById(id) hook, used by the detail page
Auto find all Yes — GET /<plural>, loads with master-detail Yes — GET /<plural> via GetAllQuery + handler useAll<Agg>() hook, used by the list page
test "name" { … } Vitest at domain/<aggregate>.test.ts xUnit at Tests/<Plural>/<Aggregate>Tests.cs (n/a — backend-only)
test e2e "name" against <backend> { … } Vitest at <system>/e2e/<System>.e2e.test.ts (typed fetch against the live HTTP) (same file; targets the named .NET deployable) (n/a — see UI variant below)
test e2e "name" against <react-deployable> { … } (n/a — UI tests live in the react deployable) (n/a) Playwright spec at <react-deployable>/e2e/<System>.ui.spec.ts, routes through the auto-generated page objects

TypeScript backend (platform: node)

generate ts for legacy single-context sources; generate system for deployables marked platform: node.

File map

For a context with aggregates Order (containing parts) and Product:

<deployable>/
├── package.json                     # deps: hono, @hono/node-server, @hono/zod-openapi, zod, drizzle-orm, pg
├── tsconfig.json
├── index.ts                         # pg Pool → drizzle → createApp(db) → @hono/node-server.serve
├── scheduler.ts                     # timerSource jobs (when the deployable owns any) — see "Timer sources"
├── drizzle.config.ts                # Drizzle Kit config (db:generate, db:push, db:studio)
├── Dockerfile                       # multi-stage node:22-alpine; runtime serves `node out/index.js`
├── .dockerignore
├── certs/.gitkeep                   # proxy-CA escape hatch (drop *.crt files here at build time)
├── domain/
│   ├── ids.ts                       # branded id types: OrderId, OrderLineId, ProductId, …
│   ├── value-objects.ts             # enums + value-object classes with invariant ctors
│   ├── events.ts                    # discriminated event union + dispatcher type + Noop dispatcher
│   ├── errors.ts                    # DomainError, AggregateNotFoundError
│   ├── order.ts                     # Order class + OrderLine part class(es)
│   ├── order.test.ts                # vitest from `test "name" { … }` blocks (when present)
│   └── product.ts
├── db/
│   ├── schema.ts                    # Drizzle pgTable / enum value tuples
│   └── repositories/
│       ├── order-repository.ts      # findById / getById / save / find* / all() / toWire
│       └── product-repository.ts
└── http/
    ├── index.ts                     # Hono app composer: CORS, /health, sub-routers, /openapi.json
    ├── order.routes.ts              # OpenAPIHono router with createRoute() + Zod request/response schemas
    └── product.routes.ts

Timer sources (timerSource)

A system-scope timerSource { for: <Event>, cron: "…" | every: <dur> } (scheduling.md) fires a plain domain event on a wall-clock cadence; workflows react through the existing on/create … by triggers. When a deployable owns a timer (its subdomain is the for-event's migrationsOwner), the Hono backend emits scheduler.ts and wires it at boot. Phase 2 splits the two cadences by where durability matters:

Delivery semantics (multi-pod). Still at-least-once: pg-boss gives single-fire per boundary and durable retry, but a clock-skewed peer or a catch-up replay can re-deliver, so tick reactors must be idempotent (the same contract event reactors already carry). Exactly-once-per-instant is not a goal — at-least-once + idempotent reactors is the contract.

A deployable owning no timer emits no scheduler.ts, no pg-boss dep, and no boot wiring — byte-identical to before.

Phase-2 durable drivers (M-T4.1). The advisory-lock path above is the in-process tier — correct for every: (sub-minute) cadences but at-least-once. cron: timers additionally get a durable, store-coordinated driver per backend, so a boundary missed while every replica was down is replayed once on recovery (missed-run catch-up) and fires exactly once across replicas (single-fire), with retries. Each backend uses its ecosystem's Postgres-native job store: node → pg-boss, .NET → Hangfire, Java → JobRunr, Python → procrastinate, Elixir → Oban. The every: tier stays the in-process interval loop + pg_try_advisory_xact_lock on every backend.

Per-aggregate detail

domain/<aggregate>.ts — class with:

Entity parts are sibling classes in the same file with _parentId set on construction; mutations on parts go through their parent's operations.

http/<aggregate>.routes.ts — built by routes-builder.ts:

Response schemas carry the full wire shape: every field, every contained part nested, every derived value (except the reserved inspect derived, which stays host-language-only — see below), value objects as nested objects. Decimals are JSON numbers; datetimes are ISO strings.

Aggregate stringification hooks: when an aggregate declares derived inspect: string = ... (auto-injected when omitted), each backend emits a host-language debug-string hook that delegates to the inspect value — public override string ToString() (.NET, auto-invoked by $"{x}" / Console.WriteLine), toString() + [Symbol.for("nodejs.util.inspect.custom")] (TS, auto-invoked by String(x) / ${x} / console.log), a public def inspect(record) module function (Phoenix, invoked explicitly as MyApp.Catalog.Customer.inspect(record) — the loom-emitted form lives at the module-function level to avoid an Inspect-protocol collision under mix compile --warnings-as-errors), a __repr__ (Python, auto-invoked by repr(x) / f-strings), and a toString() override (Java). Honours sensitive(...) field tags by substituting <redacted> for the value while keeping the field name in the structural output. VO-typed fields are inlined structurally one level deep — price: Money shows as price: Money(amount: 99, currency: 'USD') rather than the opaque price: [Money] placeholder; further nesting (VO inside VO, arrays, optionals) falls back to the placeholder so the expression stays bounded and self-recursive VO shapes can't cycle. A sensitive(...) tag on the parent's VO-typed field redacts the whole VO; per-field sensitive(...) inside the VO redacts only that slot. Never reached by string(aggregate) or implicit "x " + aggregate — the user-facing form is derived display: string = ... (opt-in), routed through the Loom expression layer and the React Select picker.

db/repositories/<aggregate>-repository.ts — built by repository-builder.ts:

db/schema.ts — Drizzle pgTable per aggregate root and per contained part (parts get a parent_id FK), a value tuple per enum (the column is TEXT, matching the emitted migration), value-object fields flattened into prefix-named columns (price_amount, price_currency).

Every X id[] reference-collection field on an aggregate also gets its own join table, snake(owner)_snake(field) (e.g. trainer_party, trainer_caught) — two FK columns (<owner>_id text not null, <target>_id text not null), an ordinal integer not null carrying the collection's position, a composite primary key (owner_fk, target_fk), and an index on the target FK so the reverse membership subquery (this.party.contains(pokemon)) stays index-backed. The field is not persisted as a column on the owner table.

http/index.ts — composer:


.NET backend (platform: dotnet)

generate dotnet for legacy single-context sources; generate system for deployables marked platform: dotnet.

File map

<deployable>/
├── <Namespace>.csproj               # net10.0, EF Core 10, Mediator source-gen, Swashbuckle, EF Tools
├── Program.cs                       # AddDbContext, AddMediator, AddCors, AddSwaggerGen, MapControllers,
│                                    # camelCase JSON, EnsureCreated, /health
├── Dockerfile                       # multi-stage dotnet/sdk → dotnet/aspnet
├── .dockerignore
├── certs/.gitkeep                   # proxy-CA escape hatch
├── Domain/
│   ├── Common/DomainException.cs
│   ├── Ids/                         # OrderId.cs, OrderLineId.cs, ProductId.cs, …
│   ├── Enums/                       # one .cs per enum + a _namespace.cs marker so empty namespaces compile
│   ├── ValueObjects/                # one .cs per value object + _namespace.cs marker
│   ├── Events/
│   │   ├── IDomainEvent.cs
│   │   └── OrderConfirmed.cs        # one .cs per event
│   └── Orders/                      # one folder per aggregate (plural)
│       ├── Order.cs                 # aggregate root class
│       ├── OrderLine.cs             # entity-part classes
│       └── IOrderRepository.cs      # interface
├── Application/
│   └── Orders/
│       ├── Requests/OrderRequests.cs    # CreateOrderRequest, AddLineRequest, …
│       ├── Responses/OrderResponses.cs  # OrderResponse, OrderLineResponse, MoneyResponse, CreateOrderResponse
│       ├── Commands/                    # one Command + Handler per public operation + Create<Agg>
│       │   ├── CreateOrderCommand.cs
│       │   ├── CreateOrderHandler.cs
│       │   ├── ConfirmCommand.cs
│       │   ├── ConfirmHandler.cs
│       │   ├── AddLineCommand.cs
│       │   └── AddLineHandler.cs
│       └── Queries/                     # one Query + Handler per find (incl. auto findById / all)
│           ├── GetOrderByIdQuery.cs
│           ├── GetOrderByIdHandler.cs
│           ├── AllQuery.cs
│           ├── AllHandler.cs
│           ├── ByCustomerQuery.cs
│           └── ByCustomerHandler.cs
├── Infrastructure/
│   ├── Persistence/
│   │   ├── AppDbContext.cs              # DbSet<Order>, DbSet<Product>, …
│   │   └── Configurations/
│   │       ├── OrderConfiguration.cs    # IEntityTypeConfiguration: OwnsMany lines, OwnsOne for VOs, HasConversion for ids/enums
│   │       └── ProductConfiguration.cs
│   ├── Repositories/
│   │   ├── OrderRepository.cs           # implements IOrderRepository: GetByIdAsync, SaveAsync, All, …
│   │   └── ProductRepository.cs
│   └── Events/NoopDomainEventDispatcher.cs
├── Api/
│   ├── DomainExceptionFilter.cs         # DomainException → 422, AggregateNotFoundException → 404
│   ├── ValidationProblem.cs             # model-binding/DataAnnotations failures → the same 422 + errors[]
│   ├── OrdersController.cs              # [HttpPost], [HttpGet], [HttpPost("{id}/<op>")], [HttpGet("<find>")]
│   └── ProductsController.cs
└── Tests/<Namespace>.Tests/             # xUnit project — emitted only when `test` blocks exist
    ├── <Namespace>.Tests.csproj
    └── Orders/OrderTests.cs

Per-aggregate detail

Domain/<Plural>/<Aggregate>.cs — sealed class with:

Infrastructure/Persistence/Configurations/<Aggregate>Configuration.cs — IEntityTypeConfiguration<<Aggregate>>:

Application/<Plural>/Commands/... — for every public DSL operation: a record Command implementing ICommand<TResponse> plus a Handler that loads the aggregate, invokes the method, saves. Preconditions are checked inside the aggregate, never duplicated in the handler.

Application/<Plural>/Queries/... — for every find (including auto findById and all): a record Query + a Handler that calls the repository and projects through <Agg>Mapper.ToDto(...) (lives in the Responses module via dto-mapping.ts).

Application/<Plural>/Requests/<Aggregate>Requests.cs — wire-shape records: Create<Agg>Request, <Op>Request per public op, <Vo>Request per used value object. Datetimes are string on the wire (Hono parity); the controllers parse with DateTimeStyles.AssumeUniversal | AdjustToUniversal → UTC.

Application/<Plural>/Responses/<Aggregate>Responses.cs — wire- shape records (<Agg>Response, <Part>Response, <Vo>Response, Create<Agg>Response). Datetimes round-trip via ToUniversalTime().ToString("o") so .NET's spec matches Hono's.

Api/<Plural>Controller.cs — [ApiController] with one route per shape; converts wire-DTOs to commands, dispatches via IMediator, returns DTOs. DomainExceptionFilter turns precondition failures into HTTP 422 (RS-15) and missing-aggregate into 404.

Api/ValidationProblem.cs — the faults that never reach a controller. Model binding and DataAnnotations run before the action, so DomainExceptionFilter never sees them and MVC answered with its own ValidationProblemDetails (rfc9110 type, errors as an object-of-arrays, traceId inline, status 400 where the other backends send 422). Wired as ApiBehaviorOptions.InvalidModelStateResponseFactory, this replaces it with the emitted envelope — 422 + errors[] for an invalid body, 400 for an unreadable one (RS-9's split). Program.cs pairs it with UseStatusCodePages (404/405, which routing refuses with no body at all) and a CustomizeProblemDetails hook that normalises the ProblemDetails the framework builds itself (415).

Middleware/EmptyJsonBodyMiddleware.cs — an empty request body reads as {} (RS-39), so a parameterless operation answers a body-less POST. It swaps the body, its framing headers and IHttpRequestBodyDetectionFeature — MVC's body binder reads the feature, not the stream. The other backends' twins: hono emptyBodyAsEmptyObject, python JsonBodyMiddleware, java EmptyJsonBodyFilter, the Phoenix BodyParser.

Program.cs — hosting entry:


React frontend (platform: react)

generate system for deployables marked platform: react. No single-deployable CLI entry — react frontends only make sense in system mode where they can resolve a targets: peer.

File map

For a frontend webApp targeting api (which hosts Order + Product):

web_app/
├── package.json                     # deps: react, react-router, @tanstack/react-query, zod,
│                                    # @mantine/* (core, hooks, form, notifications, dates, modals),
│                                    # mantine-form-zod-resolver
├── tsconfig.json + tsconfig.node.json
├── vite.config.ts                   # @vitejs/plugin-react, dev/preview bind 0.0.0.0:<port:> (PORT overrides; the image passes --port 3000)
├── index.html
├── Dockerfile                       # multi-stage node:22-alpine; runtime: vite preview
├── .dockerignore                    # excludes e2e/, dist/, playwright-report/, test-results/
├── certs/.gitkeep
├── src/
│   ├── main.tsx                     # MantineProvider + QueryClientProvider + Router + Notifications + ModalsProvider
│   ├── App.tsx                      # AppShell + <Routes>: home, /<plural>, /<plural>/new, /<plural>/:id — each page a React.lazy chunk behind a Suspense (pack loader fallback)
│   ├── api/
│   │   ├── client.ts                # fetch wrapper, ApiError
│   │   ├── config.ts                # API_BASE_URL = /api (relative, same-origin); window.__LOOM_API_BASE__ or VITE_API_BASE_URL override
│   │   ├── order.ts                 # Zod schemas + RQ hooks
│   │   └── product.ts
│   └── pages/
│       ├── home.tsx                 # link cards per aggregate
│       ├── orders/
│       │   ├── list.tsx             # /orders — Mantine <Table> from useAllOrders
│       │   ├── new.tsx              # /orders/new — Mantine form on Create<Agg>Request
│       │   └── detail.tsx           # /orders/:id — fields + parts + operation buttons (modal-form per op)
│       └── products/
│           ├── list.tsx
│           ├── new.tsx
│           └── detail.tsx
└── e2e/                             # standalone Playwright suite (separate package.json so the
    │                                # runtime image stays slim — @playwright/test isn't pulled into prod)
    ├── package.json                 # @playwright/test, @types/node
    ├── tsconfig.json                # includes ../src/api/**/*.ts so page objects can use response types
    ├── playwright.config.ts         # baseURL = http://localhost:3001 (override via E2E_BASE_URL)
    ├── smoke.spec.ts                # auto-generated: every aggregate's list page loads
    └── pages/                       # auto-generated page-object classes
        ├── order.ts                 # OrderListPage / OrderNewPage / OrderDetailPage
        └── product.ts

Stores (store Cart { state … action … })

A top-level ui member store Cart { state { … } action … } (a shared client-side container, sibling to page/component — named-actions-and-stores.md §3) emits one Zustand module per store at src/stores/<snake>.ts: export const useCart = create<CartState>((set) => ({ …fields, …actions })). The action bodies reuse the same :=/+= statement lowering a page action does, targeting Zustand's set((s) => ({ … })) instead of a useState setter (zustand is added to the generated package.json). At a use site a page/component references the store by dotted name — Cart.lines (a store-field read) and Cart.clear() (a store-action call). Each consuming shell derives its store dependency from those resolved refs (derive-don't- stamp), imports useCart, and hoists one selector binding per used member (const lines = useCart((s) => s.lines)); the body / action handlers reference the bare local. The persist: lifetime ladder ships on the SPA frontends — store <Name> persist: memory|local|session|url { … } is real grammar, and React / Vue / Svelte / Angular honour it, so the blanket loom.store-lifetime-unsupported is retired (a bad value is rejected earlier as loom.store-lifetime-invalid). Feliz honours it too, differently: a Feliz store has no module of its own — it folds into the single Elmish Model, so the ladder rides that fold (generator/feliz/store-persist.ts). init seeds each persisted field through a StorePersist.load<Store><Field> () loader, an updateWithPersist wrapper mirrors the Model back after every message, and the url tier adds a popstate Elmish subscription so back/forward moves the state. Keys and shapes match the JS builders byte-for-byte (loom.store.<Name> + a JSON object keyed by the bare field name; one query param per field, empties dropped), so the same blob and the same URL round-trip across frontends. Every frontend now implements the ladder; the one target that does not is Phoenix LiveView, which refuses a non-memory lifetime rather than degrading silently (loom.store-lifetime-liveview-invalid — a server-side per-process struct has no browser storage, and URL state belongs to the page's handle_params). What survives of loom.store-lifetime-target-unsupported is FIELD-scoped, on the two frontends whose persistence crosses an untyped boundary per field: on Feliz a type with no total F# conversion (datetime / duration / guid / enum / entity / value object, and arrays of them), and on Flutter one with no total Dart conversion (json / File / entity / value object / optional). Both are tracked as one gap row in src/diagnostics/unsupported-register.ts. A persist: url store reflects its fields into query params, which carry only scalars, so an array / entity / value-object field there is rejected (loom.store-url-field-invalid). sync: remains reserved. Validator gates: a store action can't call a view-scoped effect (navigate/toast — loom.store-action-view-effect), a page can't write store state inline (loom.store-state-inline-write), and a store→store call cycle is rejected (loom.store-action-cycle).

Phoenix LiveView also ships stores (the loom.store-on-liveview-unsupported gate was lifted): a store Cart { … } emits a dedicated lib/<app>_web/stores/cart.ex module — a defstruct carrying the state fields (with their defaults) plus one public pure function per action (def clear(%__MODULE__{} = state), do: %{state | …}). Each LiveView page that touches the store seeds one per-process assign in mount/3 (|> assign(:cart, %Cart{}) + an alias <App>Web.Stores.Cart); a Cart.count read renders @cart.count (template) / socket.assigns.cart.count (handler), and a Cart.clear() call renders |> update(:cart, &Cart.clear/1) (0 args) or |> update(:cart, fn c -> Cart.add(c, sku) end) (with args). Because each store is its own per-page assign, a store action calling a different store's action is gated (loom.store-cross-store-on-liveview-invalid) — same-store action→action composition is fine (a pure in-module call).

Vue/Svelte/Angular store-module emit is a fan-out follow-up and throws loudly until ported.

Per-aggregate detail

src/api/<aggregate>.ts — built by api-builder.ts:

All hooks parse the response with the matching Zod schema before returning, so callers get type-checked, validated data.

src/pages/<plural>/list.tsx — built by body-walker.ts + pages-emitter.ts (the legacy pages-builder.ts archetype renderer has been removed; bodies — scaffolded or hand-written — all route through the walker now):

src/pages/<plural>/new.tsx:

src/pages/<plural>/detail.tsx:

Mantine input mapping

Form-input emission walks the field type:

Type Mantine component Notes
int / long <NumberInput allowDecimal={false}>
decimal <NumberInput decimalScale={2} fixedDecimalScale>
money <TextInput inputMode="decimal"> Precise-decimal field — value is a Decimal from decimal.js; number-mode inputs lose precision via 1e10 notation.
string / guid <TextInput>
bool <Switch> Manual checked / onChange since Mantine <Switch> accepts event-based onChange.
datetime <TextInput type="datetime-local"> Native datetime input — Mantine's <DateTimePicker> is harder to drive from Playwright; users can swap via .loomignore for richer UX.
enum <Select allowDeselect={false}> Explicit value / onChange / error (Mantine Select calls onChange with (value, option), not an event, so getInputProps can't be spread directly). allowDeselect={false} keeps required fields from being cleared by a click on the already-selected option.
valueobject <Fieldset legend> with one input per VO field Sub-inputs use the name.subField form for nested binding.
array<part> (omitted from the create form) Parts are added via operations, not the create form.

data-testid map

The body-walker sprinkles a stable data-testid on every interactive element so the page-objects-builder can write selector-free test code. Walker-stdlib primitives accept an explicit testid: named arg; the walker threads it through the rendered component.

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: …-<field>-<voField>)
New "Create" submit <plural>-new-submit
Detail page root <plural>-detail
Detail field display (primitive) <plural>-detail-<field>
Detail enum badge <plural>-detail-<field> (on <Badge tt="unset">)
Detail VO sub-field display <plural>-detail-<field>-<voField>
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>
Contained-part row cell <plural>-detail-<containment>-row-<id>-<field>
Route-level loading fallback (shell) route-loading (role="status")

Route-level code splitting

Every routed page is its own chunk; only the shell (layouts, sidebar, NotFound, error boundary) is in the entry bundle. App.tsx binds each page as const OrderList = React.lazy(() => import("./pages/orders/list")) and wraps each layout's <Outlet /> — plus the whole <Routes>, for layout: none pages — in <React.Suspense fallback={<RouteLoading />}>, where RouteLoading renders the pack's own loader (Loader / CircularProgress / Spinner / Loader2) with data-testid="route-loading". The generated smoke spec waits for that fallback to clear before asserting the page did not crash; the page objects already wait on each page's root testid. Vue (component: () => import(…)) and Angular (loadComponent) split the same way — on vuetify each .vue file also imports the components its template renders (import { VBtn, VCard } from "vuetify/components", derived from the emitted markup) rather than createVuetify registering the whole library, so those components ride the page's chunk; SvelteKit splits per +page.svelte on its own. Pinned by test/generator/_frontend/lazy-route-pages.test.ts.

Page-object classes

page-objects-builder.ts emits one TS module per aggregate at <deployable>/e2e/pages/<aggregate>.ts. Each module exports three classes that mirror the page set:

export class <Agg>ListPage {
  static readonly url = "/<plural>";
  constructor(public readonly page: Page);
  goto(): Promise<this>;
  create(): Promise<<Agg>NewPage>;
  row(id: string): Locator;
  open(id: string): Promise<<Agg>DetailPage>;
  expectRow(id: string): Promise<void>;
}

export class <Agg>NewPage {
  static readonly url = "/<plural>/new";
  constructor(public readonly page: Page);
  goto(): Promise<this>;
  fill(input: Partial<Create<Agg>Request>): Promise<this>;
  submit(): Promise<<Agg>DetailPage>;
}

export class <Agg>DetailPage {
  constructor(public readonly page: Page, public readonly id: string);
  goto(): Promise<this>;
  field<K extends keyof <Agg>Response>(name: K): Promise<string>;
  // For each `contains <name>: Part[]`:
  <name>Row(id: string): Locator;
  <name>Count(): Promise<number>;
  // For each public operation `op(p1: T1, …)`:
  op(input: <Op>Request): Promise<this>;
}

The fill() body branches per type — selectOption for enums (via the dropdown), .fill() for text inputs, .click() toggles for bools, slice-to-YYYY-MM-DDTHH:mm for datetime — and walks nested VO fields.

Design pack selection

The file map above is for the default design: "mantine". Picking a different pack swaps the rendered component library, the package.json dep set, and the theme bootstrapping — the rest of the project (routing, RQ hooks, page objects, smoke tests) is identical.

Family Path Versions shipped Component library Latest (bareword resolves to)
mantine designs/mantine/ v9 @mantine/* mantine@v9
shadcn designs/shadcn/ v4 Tailwind + Radix primitives shadcn@v4
mui designs/mui/ v7 @mui/material mui@v7
chakra designs/chakra/ v3 @chakra-ui/react chakra@v3
vuetify designs/vuetify/ v1 Vuetify 3 — Vue only vuetify@v1
shadcnVue designs/shadcnVue/ v1 Tailwind + Radix Vue primitives — Vue only shadcnVue@v1
flowbite designs/flowbite/ v1 Flowbite Svelte — Svelte only flowbite@v1
shadcnSvelte designs/shadcnSvelte/ v1 Tailwind + bits-ui primitives — Svelte only shadcnSvelte@v1
angularMaterial designs/angularMaterial/ v1 Angular Material — Angular only angularMaterial@v1
primeng designs/primeng/ v1 PrimeNG — Angular only primeng@v1
spartanNg designs/spartanNg/ v1 Spartan/ui + Tailwind — Angular only spartanNg@v1
coreComponents designs/coreComponents/ v3 HEEx core components (baseline) — Phoenix LiveView only coreComponents@v3
daisyui designs/daisyui/ v1 HEEx + daisyUI (Tailwind) — Phoenix LiveView only daisyui@v1

Pack selection per platform when design: is omitted:

Platform Default family
react, static, fullstack dotnet (with ui:) mantine
vue vuetify
svelte shadcnSvelte
angular angularMaterial
phoenixLiveView coreComponents (default; daisyui also available)
node, backend-only dotnet none (no UI mount)

Picking a pack also locks in a stack (a coherent React + router + Zod + Vite + TypeScript dep bundle). Each pack version declares its stack in its pack.json; the bundler reads that declaration and emits the matching package.json. See design-packs.md for the full pack authoring surface, including the stack catalogue and the recipe for adding a new pack version.

A bareword design: "mantine" resolves to the family's current default version; pinning is via family@version (e.g. design: "mui@v7"). The per-version folder under designs/<family>/<vN>/ is what the bundler actually loads.


Svelte frontend (platform: svelte)

The second frontend generator (src/generator/svelte/). Emits a Svelte 5 / SvelteKit static SPA per svelte deployable — @sveltejs/adapter-static with ssr = false and an index.html fallback, served by vite preview in docker exactly like the React SPA. Pages flow through the same shared markup walker the React generator uses (src/generator/_walker/walker-core.ts) with svelteTarget + a svelte-format design pack (shadcnSvelte default, flowbite); see D-SVELTE-FRONTEND in decisions.md.

Concern Emission
Routing SvelteKit file routing — /orders/:id → src/routes/(app)/orders/[id]/+page.svelte (one chunk per route, by SvelteKit's own splitting); route groups (app) (chrome) / (bare) (layout: none) carry the layout selectors. A named layout <Name> lowers to its own route group (<name>)/+layout.svelte, rendering the header/sidebar/footer slots around {@render children()}; pages selecting it via layout: <Name> route into that group (src/generator/svelte/layouts-emitter.ts).
Data @tanstack/svelte-query v6 factories with the React hook names (useAllCustomers, …) — createQuery(() => opts) returns a runes-reactive object with the .data/.isPending/.mutate surface. Zod schemas are byte-identical with the react modules (shared src/generator/_frontend/zod-schemas.ts).
State let x = $state<T>(init); walker := writes lower to plain assignments; page title: becomes a $effect. Money page-state imports decimal.js so Decimal initial values compile (matches the react fix).
Forms Hand-rolled runes + zod helper (src/lib/forms.svelte.ts): createForm(schema, defaults) → values (deep-reactive bind targets), errors (dotted-path map), submit, applyServerErrors (RFC 7807 422 decode). Field templates bind form.values.<path>.
Operation modals Page-scope {#snippet <op>OpModal(form)} blocks after the markup — one component per .svelte file means module scope lands in the template; primitive-modal renders {@render <op>OpModal(<op>Form)}.
Realtime realtime channels emit a RealtimeHandlers.svelte that subscribes via a $effect to src/lib/api/realtime.ts (shared transport contract src/generator/_frontend/realtime.ts), surfacing a realtime-toast on events — parity with the react realtime client.
Extern hatch extern function → a typed signature stub src/lib/extern/<name>.signature.ts re-exported through $lib/<name>. extern component → a forwarding <Name>.svelte wrapper rendering <Impl {...props} /> against a typed <Name>.props.ts (slots typed as Snippet).
e2e Same testid-keyed Playwright surface as react: page objects from the shared _frontend builders (api imports root at src/lib/api), smoke spec, fixtures, config. test e2e … against <svelte deployable> lowers to a Playwright ui spec, gated at runtime by generated-svelte-e2e.yml (vite preview + the spec).
Embedding Every backend host can embed the SvelteKit SPA (framework: svelte on the ui binding). dotnet/java fullstack hosts mount it under ClientApp/ (Dockerfile copies build/ → wwwroot); phoenix mounts it under assets/ served at /app, with kit.paths.base = "/app" so asset URLs resolve (the fleet-wide base-path fix also covers react/vue).
CI generated-svelte-build.yml — per {example × pack}: npm install + svelte-check --fail-on-warnings + vite build (npm run test:svelte-build locally); generated-svelte-e2e.yml adds the runtime preview + Playwright gate.

Vue frontend (platform: vue)

The structural mirror of the React frontend, driven by the SAME shared markup walker (src/generator/_walker/) through vueTarget and the SAME _frontend/ api/workflows module builders (only the TanStack Query import specifier differs). Design packs: vuetify (default) and shadcnVue (reka-ui + Tailwind 4, source-copy). See D-VUE-FRONTEND and [docs/old/plans/vue-frontend-plan.md](https://github.com/Loom-Harness/Loc/blob/cadbe3ba12fb833565704cb9d628c8f41b2090a8/docs/old/plans/vue-frontend-plan.md).

File map (deltas vs the React project)

web_app/
├── package.json            # vue, vue-router, @tanstack/vue-query, zod (+ vuetify/@mdi or
│                           # reka-ui/tailwindcss/cva/clsx/tailwind-merge/lucide-vue-next)
├── vite.config.ts          # @vitejs/plugin-vue (+ @tailwindcss/vite and the @ alias on shadcnVue)
├── src/
│   ├── main.ts             # createApp + router + VueQueryPlugin (+ vuetify instance / globals.css)
│   ├── App.vue             # app chrome + <router-view/> + onErrorCaptured boundary
│   ├── router.ts           # createRouter(createWebHistory) route table (each page `component: () => import(…)`, its own chunk) + NotFound catch-all
│   ├── theme.ts            # createVuetify tokens, no global component registration (vuetify) / `export {}` stub (shadcnVue — CSS vars)
│   ├── lib/form.ts         # useLoomForm — vee-validate useForm over the shared zod schema (local toTypedSchema) + per-field error map
│   ├── lib/format.ts       # formatting FUNCTIONS (the React packs' format components, fn-style)
│   ├── lib/toast.ts        # channels only: reactive pushToast() queue the app-shell host renders
│   ├── api/realtime.ts     # channels only: EventSource client (broadcast wire → subscribeRealtime)
│   ├── components/RealtimeHandlers.vue  # channels only: renderless on-mount switch → pushToast
│   ├── components/<Name>.vue # user components — typed defineProps SFC, walked <template>
│   ├── lib/<extern>.ts + lib/extern/<extern>.signature.ts  # extern frontend fns (shim + signature)
│   ├── components/ui/      # shadcnVue only: source-copied SFCs + index.ts barrel
│   ├── layouts/<Name>.vue + layouts/DefaultLayout.vue  # named layouts only: chrome moves out of App.vue, router nests
│   └── pages/**/*.vue      # SFC pages — <script setup lang="ts"> + walked <template>
└── e2e/                    # identical harness/page objects to React (testid/DOM only)

Key behaviours

Angular frontend (platform: angular)

The fourth frontend generator (src/generator/angular/). Emits a standalone Angular 22 SPA per angular deployable — bootstrapApplication + appConfig (no NgModules), built with the @angular/build:application builder and served the same vite preview-style way as the other SPAs in docker. Pages flow through the same shared markup walker the React / Vue / Svelte generators use (src/generator/_walker/walker-core.ts) with angularTarget + the angularMaterial design pack. See docs/old/plans/angular-frontend-plan.md.

Angular renders idiomatic Angular, not a transliterated React app: standalone @Components, signals/computed for view state, typed Reactive Forms, and — for server state — TanStack Angular Query (injectQuery / injectMutation), the senior-Angular-idiomatic caching layer, so reads share a query cache (dedup + caching) and mutations invalidate exactly the keys the generator knows they touch. Several walker seams fork the shared (React-shaped) emission rather than reuse it — each is opt-in (only angularTarget implements it), so the other JSX/markup frontends (React/Vue/Svelte) stay byte-identical (pipeline-layering + the full suite gate this).

Concern Emission
Pages Each page becomes a standalone @Component under src/app/pages/<slug>.component.ts with an inline template. The route table (src/app/app.routes.ts) maps /orders/:id → the component via provideRouter, lazily (loadComponent: () => import(…), one chunk per page); a wildcard ** mounts NotFound. The app shell (app.component.ts) derives the sidebar from the page set.
State / derived state { x: T = init } → readonly x = signal(init) (read x(), write x.set(…)); the := walker write lowers to .set(…). derived n = expr → readonly n = computed(() => …) (signal reads this.-prefixed in the class-field initialiser).
Events Angular (click) binds a statement, not a function value (and forbids arrow functions) — the renderEventHandler seam inlines the lambda body ((click)='count.set(count() + 1)'). Button(to:) routes through renderNavigateExpr → router.navigateByUrl(<to>).
Data TanStack Angular Query: per aggregate src/api/<agg>.ts ships an @Injectable HttpClient service (the raw requests) + factories the page-shell hoists as component fields — useAll<Agg>s / use<Agg>ById return injectQuery results (cached, keyed ["<tag>"] / ["<one>", id]; byId is enabled: !!id so it stays idle until the route param resolves), and useCreate<Agg> / use<Op><Agg> return injectMutation results whose onSuccess calls queryClient.invalidateQueries on exactly the affected keys. Signals are called (handle.data() / handle.isPending()); the renderQueryDataAccess seam defaults a collection read to [] ((handle.data() ?? [])). The QueryClient is provided once in app.config.ts (provideTanStackQuery). byId reads bind the route param from the ActivatedRoute snapshot.
Forms Idiomatic typed Reactive Forms (the renderCreateForm / renderOperationForm / renderWorkflowForm seams), not react-hook-form: CreateForm(of:), standalone OperationForm(of:,op:) / OperationForm(<inst>.<op>), and WorkflowForm(runs:) each emit a [formGroup] / (ngSubmit) shell over a FormGroup of nonNullable FormControls (per-field Material inputs from src/generator/angular/form-fields.ts), submit → mutateAsync(getRawValue()) (op/workflow wrap as { id, input }) → navigate (the create mutation invalidates the list, so it refetches). DestroyForm(of:) → a confirm-delete mat-raised-button (renderDestroyForm seam) wired to useDelete<Agg>. So the pack ships no field-input-* / form-of templates — the Angular required-primitive surface is display/layout/input only.
Operations Action(inst.op) → a "dumb template" button (renderAction seam): (click)="on<Op><Agg>()" + [disabled]="<localVar>.isPending()", with an async on<Op><Agg>() method that reads the record id inside (a ?.id guard + early return), awaits <localVar>.mutateAsync({ id, input }), then runs the optional then: effect (this.-prefixed). Modal { OperationForm(…) } → a signal-toggled inline Reactive Form (renderModal seam): the trigger captures the record id into an <op>Id signal and flips <op>Open; an @if (<op>Open()) block holds the op FormGroup; submit reads the id signal, mutateAsync({ id, input })s, then closes.
User components A walked component <Name>(p: T) { body: … } emits a standalone @Component class at src/app/components/<Name>.ts (components-emit.ts) — assembled by the SAME shell that assembles a page (renderAngularPage in componentMode), so state signals, derived computeds, action methods, api-read hoists, Reactive Forms and the t / FORMAT_HELPERS / String / Decimal member lifts all apply. Params bind as plain @Input() fields (bare template reads, like a bound route param; NOT required: true — a call site may omit an arg, and the other frontends render that as undefined). A call site uses the component's OWN kebab selector — <app-tier-badge [label]='tier()'> — with the class in the page's standalone imports: [], since Loom stamped that selector itself; an extern component keeps the selector-free NgComponentOutlet form (its @Component({ selector }) is the author's, so Loom has no tag to spell) and its shim sits at src/components/ rather than src/app/components/. Only the tag form has a content-projection channel, so children passed at a call site — Panel("a", Text { "child" }) — reach a walked component's Slot { } (emitted as <ng-content>) and are still dropped on an extern one (loom.component-children-unsupported, D-ANGULAR-EXTERN-CHILDREN). Deferred (call site keeps the walker's give-up comment, never a dangling reference): a slot / action(T) param, and an api read whose ARG reads an @Input() (a read hoists as a class-FIELD initializer, which runs before Angular sets inputs; an arg-less read and a reactive find are supported).
Page stub Only a page with no body (route/title-only) renders a title stub; every form / action / read primitive now renders a real body. The pageNeedsDeferredFeatures predicate is defence-in-depth (the shared react-hook-form formOfs / actionMutations sinks are never populated on Angular — each primitive forks via its render<X>Form seam), and validateRequired keeps any unsupported construct a compile error rather than a codegen crash.
CI generated-angular-build.yml — per {case × pack} (minimal / scaffold / showcase × angularMaterial): npm install + ng build (the Angular CLI typechecks + bundles in one step; npm run test:angular-build locally).

Feliz frontend (platform: feliz)

The fifth frontend generator (src/generator/feliz/). Emits a Feliz (F#/Fable/Elmish) SPA per feliz deployable, built with dotnet fable + vite (not the vite-only static pipeline of the JS frontends). Pages flow through the same shared markup walker the other frontends use (src/generator/_walker/walker-core.ts) via feliz-target.ts — but Feliz is the one frontend whose embedded language is F#, not JS, so instead of consuming the wire shape only, it supplies its own F# expression leaves (src/generator/feliz/fs-expr.ts, the FS_LEAVES table) through the shared emitExpr dispatcher. See docs/old/plans/feliz-frontend-build.md.

Concern Emission
Architecture Elmish MVU — a Model record, a Msg union, and an update fn per page (update-emit.ts); page-level state {} fields become Model fields and := writes lower to Msg dispatch + an update arm.
Expressions Rendered to F# via FS_LEAVES (fs-expr.ts) — ==→=, null→None, list [ a; b ], anonymous records `{
Data / wire Decodes the JSON wire shape into F# records (wire.ts); a paged .all decodes the envelope's items to a 'T list (so the Model holds a list, page 1).
Design The daisyUI pack — real Tailwind + daisyUI build (styles.css + tailwind.config.js), a design: "<theme>" theme picker over the built-in daisyUI theme set (default corporate), and a persistent app-shell navbar for multi-page routed UIs.
Forms Controlled string form state on the Model + per-field blur validation with inline errors (#1944); required/whole-form submit gate.
User components A walked component <Name>(p: T) { body: … } emits an F# function of its props record — `let (props: {
CI generated-feliz-build.yml — real dotnet fable + vite build + a Playwright smoke against the built bundle.

Known frontier (M-T1.16): modal open-state, typed in-flight form state, enum DU wire decoder, per-page sub-models, multi-param routes; and the interactive-Table sort/pagination/filter seams degrade (Elmish needs Set-Msg/update plumbing) rather than emit.

Flutter mobile (platform: flutter, framework: flutter)

The mobile axis — a new development branch outside the web-target matrix, not a sixth web SPA. src/generator/flutter/ emits a Dart/Flutter (Material 3) app on Riverpod per flutter deployable. Like Feliz it is self-hosting (own SDK build, not the vite static pipeline) so it hosts only its own framework: flutter UI.

One Dart source, three build surfaces. The emitted project's Makefile builds web (make web → flutter build web, the surface compose serves via the emitted nginx Dockerfile), Android (make apk), and iOS (make ipa) from the same UI — "web-vs-native is a build target, not a modelling mode." The native android//ios/ folders are SDK-owned boilerplate and are not vendored; make prepare (flutter create --platforms=…) materialises them on demand. Compose serves only the web surface (mobile artifacts aren't a compose concern), and CI gates only the web build — see the CI row. Structurally Flutter is a Feliz clone: a non-JSX, function-call-tree target (Column(children: [ … ])) that rides the same shared markup walker (walker-core.ts) through flutter-target.ts + a Dart expression-leaf table, and — like Feliz — supplies its own interChildSeparator seam because Dart list literals are comma-separated.

Concern Emission
Architecture Riverpod — each page with state {}/actions projects an immutable <Page>State + a <Page>Notifier extends Notifier<State> (riverpod-emit.ts); named actions become notifier methods (state = state.copyWith(…)), and the page is a ConsumerWidget binding ref.watch/ref.read. App root wraps ProviderScope.
Wire models lib/models.dart — a Dart class + fromJson/toJson per aggregate/VO/event; discriminated payload unions emit a Dart-3 sealed class + tag-switching factory (exhaustive switch, the analogue of Loom's match).
Data / reads QueryView resolves to a Riverpod FutureProvider (reads-emit.ts) that GETs the collection (unwrapping the paged {items} envelope) or a FutureProvider.family for byId; the widget renders AsyncValue.when(loading/error/data) with empty: folded in. For lowers to .map(...).toList().
Forms CreateForm/OperationForm/DestroyForm render self-contained StatefulWidgets in lib/forms.dart (forms-emit.ts) — typed field widgets by wire type (TextFormField/SwitchListTile/DropdownButtonFormField/showDatePicker, value objects flattened), a foreign-key X id becomes a runtime-loaded dropdown (initState GETs /<target-collection>, options labelled by the target's derived display), GlobalKey<FormState> validation, http POST/PUT/DELETE via apiUri, pop-on-success.
Design The procedural flutterMaterial pack (src/generator/flutter/pack.ts, Feliz-pack.ts model — emits Material widget trees, no .hbs).
CI generated-flutter-build.yml — real flutter analyze + flutter build web on an interactive showcase (state + reads + forms + routing). Web only — no gate compiles the native apk/ipa surface, so native-only regressions aren't caught per-PR (tracked in GitHub issues, area:frontend).

Modal { trigger: Button(…), OperationForm(of:, op:) } renders as a trigger ElevatedButton whose onPressed opens an AlertDialog wrapping the op-form widget (showDialog); the op-form pops its own route on success, dismissing the dialog. WorkflowForm(runs: <wf>) renders as a StatefulWidget (like CreateForm) that POSTs the workflow params to the command route /workflows/<wf>.

match await <api>.<Agg>.<op>() (async effect) projects an async Riverpod Notifier method (Future<void> <action>(String id) async): it POSTs the instance op to /<coll>/$id/<op>, reifies a non-2xx ProblemDetails back into the error variant (clobbering the wire type tag), then a Dart-3 switch over result['type'] reifies each arm via fromJson and runs the arm body as a state.copyWith write. The page-shell binds the action as an id-capturing closure (final <a> = () => notifier.<a>(id);) so the button's bare <a>() call is unchanged (riverpod-emit.ts).

View effects from an action body (navigate(<Page>), toast(<expr>)) ship through two generated OUT-OF-TREE bridges, because a ui action projects to a Riverpod Notifier method and a Notifier holds no BuildContext: lib/nav.dart installs a GlobalKey<NavigatorState> on MaterialApp and the statement renders navigateTo('/route'), and lib/toast.dart installs a GlobalKey<ScaffoldMessengerState> and the statement renders showToast(<expr>) (Object?, so a non-string argument coerces). Both files and both MaterialApp arguments are emitted only when some emitted Dart actually calls into them, so an app that uses neither is byte-identical to one built before the bridges existed. A REALTIME handler's toast is in the widget tree (LoomRealtime on MaterialApp.builder) and keeps ScaffoldMessenger.maybeOf(context) instead.

A scalar array form field (tags: string[] / scores: int[]) renders as a repeatable add/remove row list (one TextEditingController per row, managed in state; numeric arrays parse each row on submit).

Stores (store X { … }) project onto the same Riverpod triad a stateful page does (store-builder.ts), and the persist: local|session|url lifetime ladder ships (store-persist.ts): build() seeds each cell from its backing store, a ref.listenSelf mirror writes the whole state back after every transition, local/session ride shared_preferences with setPrefix('') (so the web key is the bare loom.store.<Name> the JS builders write) and url rides Uri.base.queryParameters + SystemNavigator.routeInformationUpdated with a LoomUrlStoreSync back/forward observer. session is the same backing CLEARED at boot — Dart has no per-tab store on every surface. A field type with no total Dart codec (json / File / entity / value-object / optional) is refused rather than silently dropped (loom.store-lifetime-target-unsupported, #flutter-field).

auth: ui ships (auth-gate.ts): a sessionProvider (FutureProvider<CurrentUser?>) over GET /auth/me, sign-in / sign-out redirects to the backend's own handshake via url_launcher, an AuthGate on MaterialApp.builder (spinner → sign-in prompt → routes; on the builder and not around MaterialApp, so its Material widgets have Theme/Directionality ancestors and the app never unmounts mid-probe), a page requires guard rendering ForbiddenView, and Action-button gating on a currentUser-only op requires.

Realtime (on <channel>.<Event>) ships (realtime.ts): one subscription against /realtime/events mounted on MaterialApp.builder, a SnackBar per toast(…), and ref.invalidate(<var>Provider) per refetch(<Agg>) — invalidating a .family refetches every live instance, so a server-paged table reloads the page the user is on. The transport is a conditional import: the browser's own EventSource on the web (package:web; package:http's browser client buffers the whole body, so it cannot stream) and a line parser over a streamed package:http response natively.

ProvenanceInfo renders a native ExpansionTile disclosure over the co-located <field>_provenance (a ProvLineage? on the decoded model — dart-model-emit.ts).

A user component Foo(params) { body } emits a Dart widget into lib/components.dart (component-emit.ts); an invocation Foo(a: x) renders as a widget constructor call and the page imports ../components.dart. A stateless component (value params, no own state) becomes a StatelessWidget (one final field per param, the walked body as build). A stateful component (state {} + named actions) becomes a StatefulWidget whose State holds an immutable <Comp>Model (the same data-class shape a Riverpod page projects), built in initState, exposes each param as a widget.<param> getter, and wraps each action body in setState — reusing the page path's renderNotifierStmt (a write is state = state.copyWith(field: value)). State is per-instance (each Foo(...) its own State), which a shared Riverpod provider would get wrong. A read-bearing component (a QueryView { of: … } body, no own state {}) becomes a Riverpod ConsumerWidget — exactly the shape a read-bearing PAGE takes: build(BuildContext context, WidgetRef ref) hoists final <var> = ref.watch(<var>Provider…) through the same renderApiHoisting seam, and collectFlutterReads scans component bodies so the provider it watches is emitted into lib/reads.dart. Only USED components are emitted; an extern component, a derived binding, an async-effect (match await) action, a store read, a byId read (the route id is a local only a page shell binds), or a stateful component that ALSO reads (that would need ConsumerStatefulWidget) falls back to the diagnostic comment.

An array-of-value-object form field (lines: LineItem[]) renders each row as a group of TextFormFields over the VO's scalar sub-fields (a List<List<TextEditingController>> in state), submitting a {sub: value, …} map per row — when every sub-field is text/numeric (a bool/enum/datetime/nested sub-field defers the whole array).

Known frontier: VO-array fields with non-scalar sub-fields, and the remaining user-component variants (extern / derived / stateful-plus-read / byId-read / store-read / async-effect actions), are deferred (fall back to a diagnostic comment — never broken Dart). Inline := state writes in render-tree lambdas are rejected upstream (loom.effect-in-lambda); named-action writes emit through the Riverpod Notifier — a nested target (draft.address.zip := v) folds into an immutable copyWith chain (state.draft.copyWith(address: state.draft.address.copyWith(zip: v))), so every wire model carries a copyWith. (End-to-end record-typed page state additionally needs the VO.create({…}) state initializer, a separate universal gap — see docs/old/proposals/nested-state-writes-copywith-frontends.md.) See docs/old/plans/flutter-mobile-implementation.md.

Phoenix LiveView fullstack (platform: elixir)

generate system for deployables marked platform: elixir. Single project that both serves a context-derived API (when serves: is populated) AND mounts a ui: rendered as Phoenix LiveView modules. Owns its own Postgres database (needsDb: true). Plain Ecto/Phoenix — the Ash foundation was removed (and with it the foundation: axis, which had collapsed to a single value everywhere; a would-be foundation: clause no longer parses).

File map

For a fullstack phoenixApp with contexts: [Sales] + matching dataSources: [salesState], serves: SalesApi, ui: SalesAdmin:

phoenix_app/
├── mix.exs                                       # phoenix, phoenix_live_view, ecto, ecto_sql, postgrex
├── .formatter.exs
├── Dockerfile                                    # multi-stage hexpm/elixir → debian, mix release
├── .dockerignore
├── config/{config,dev,prod,runtime}.exs          # Phoenix + Ecto config
├── priv/repo/
│   └── migrations/<ts>_create_<table>.exs        # one per aggregate, stable ordering, FK indexes
                                                  # NOTE: no seeds.exs — `seed` datasets emit NOTHING on
                                                  # this backend today (B19 / M-T6.37); the layout adapter
                                                  # reserves the slot but no emitter writes it.
├── rel/{env.sh.eex,overlays/bin/server}          # release scaffolding
├── lib/phoenix_app/
│   ├── application.ex                            # supervision tree: Repo, Endpoint, PubSub
│   ├── repo.ex                                   # Ecto.Repo
│   ├── request_context.ex                        # ambient exec-context carrier (Plug → Logger.metadata: correlation_id/scope_id/actor_id) — see architecture/request-context.md
│   └── sales/                                    # one folder per BoundedContext
│       ├── customer.ex                           # Ecto.Schema per aggregate
│       ├── order.ex
│       ├── order_line.ex                         # entity-part as embedded_schema
│       ├── order_status.ex                       # enums as Ecto.Enum
│       ├── email.ex                              # a CONSTRAINED value object → a schemaless-changeset
│                                                #   validator module.  An UNCONSTRAINED VO (Money) emits
│                                                #   no module at all — it is a :map column on the owner.
│       ├── events/order_confirmed.ex             # plain defstruct modules
│       ├── workflows/place_order.ex              # context fns wrapping Repo.transaction
│       ├── dispatcher.ex                         # in-process event router (when a channel carries a subscribed event)
│       ├── workflows/order_fulfillment_state.ex  # saga-state Ecto.Schema (correlation-keyed)
│       ├── workflows/order_fulfillment/start_order_placed.ex   # event-create starter handle/1
│       └── workflows/order_fulfillment/on_shipment_requested.ex # on(...) reactor handle/1
│   └── sales.ex                                  # context module — schema list + public functions
├── lib/phoenix_app_web.ex                        # __using__ macro
└── lib/phoenix_app_web/
    ├── endpoint.ex                               # Phoenix.Endpoint
    ├── router.ex                                 # `live "<route>", <Page>Live` per PageIR
    ├── controllers/realtime_controller.ex        # broadcast channels only: chunked SSE at GET /api/realtime/events
    ├── components/
    │   ├── core_components.ex                    # <.input>, <.button>, <.modal>, <.simple_form>, <.table>
    │   ├── layouts.ex                            # use Phoenix.Component
    │   └── layouts/{root,app}.html.heex
    └── live/
        ├── customer_list_live.ex                 # one per scaffolded PageIR
        ├── customer_new_live.ex
        └── customer_detail_live.ex

Per-aggregate detail

Aggregate IR maps onto Ecto/Phoenix:

IR Ecto/Phoenix construct
aggregate X { … } Ecto.Schema (schema "<plural>" do … end) + a base_changeset/2; persisted via <App>.Repo
field: T field :<snake>, <ecto-type> + validate_required for non-optional
contains lines: OrderLine[] has_many :lines, <App>.<Ctx>.OrderLine (relational) or embeds_many :lines, … (embedded)
derived total: Money = expr a def total(record) function over the struct (<lowered>)
invariant <pred> when <guard> Two carriers. A message-less, unguarded SINGLE-FIELD rule takes an idiomatic native line (validate_number / validate_length / validate_format). Everything else routes to a custom validate_invariants/1 piped onto base_changeset, update_changeset AND the operation-persist path — cross-field comparisons, guarded rules, messaged rules (whose wire code rides the add_error metadata), and, since #3023, member reads, method calls, collection ops and derived reads (sku.trim().length > 0, lines.count > 0, lines.any(l => …), isBig == false). A derived read INLINES its defining expression, because an Ecto struct carries no computed field. A contained collection is normalised (%NotLoaded{} → []) before it is read. The one shape deliberately NOT enforced here is a reference collection (X id[]): its join rows are written after the changeset, so the changeset would read [] and the rule would pass for every input — enforcement in name only. That case, and anything else this carrier cannot evaluate (a domainService / resource call), now raises loom.elixir-invariant-unenforced (a warning) instead of being silently dropped, so an invariant on this backend is either enforced or reported. The shared judgement is src/ir/util/changeset-invariant-carrier.ts, read by both the emitter and the phase-⑦ gate.
operation op(args) { body } a context function def <snake_op>(record, params) (precondition + put_change + Repo.update)
valueobject Money { … } A :map (JSONB) column on the owner's schema — field :total, :map, add :total, :map in the migration — never an embedded_schema, never a custom Ecto.Type (mapTypeToEcto in vanilla/schema-emit.ts returns ":map" for a valueobject unconditionally). A VO carrying a check additionally gets a schemaless-changeset validator module (@types map + cast/3 + the invariant validators + new/1); an unconstrained VO gets no module at all. The wire shape stays the nested { amount, currency } object every backend agrees on — the column layout is a deliberate divergence, recorded in language-reference/03-domain-modeling.md § valueobject.
event LineAdded { … } plain defstruct module under <Ctx>.Events.<Event>
repository finds: find byCustomer(...) where ... a context query function def by_customer(customer_id) = Repo.all(from … where: …)
workflow placeOrder(...) { ... } a context function wrapping Repo.transaction(fn -> with … end)
emit OrderConfirmed { … } Phoenix.PubSub.broadcast(<App>.PubSub, "events", %Events.OrderConfirmed{…}); inside an in-process dispatch handler, emit re-enters <Ctx>.Dispatcher.dispatch(%Events.OrderConfirmed{…}) so choreography chains run.
on(e: Event) reactor / event-triggered create(e: Event) by … (channel-carried) one <Ctx>.Workflows.<Wf>.On<Event> / .Start<Event> module with handle(event), routed by a per-context <Ctx>.Dispatcher that pattern-matches each event struct. Correlation persists through a <Wf>State Ecto.Schema keyed by the correlation field (create loads-or-allocates, on routes-or-drops + logs event_unrouted). An event-triggered-only workflow emits no run/2 / HTTP route / UI form page. See workflow.md §Triggers and channels.md.
channel X { delivery: broadcast } (realtime SSE, for an SPA frontend) <App>Web.RealtimeController — a chunked text/event-stream response at GET /api/realtime/events, subscribed to the SAME "events" PubSub topic every emit above already broadcasts on (so no dispatcher decorator is needed, unlike Hono's realtimeTee). One frame/1 clause per carried event → event: <Type> + camelCase JSON data: carrying type; a 15s event: ping keep-alive. Routed through its own :sse router pipeline — the :api pipeline's plug :accepts, ["json"] would 406 an EventSource. A tenant-scoped event degrades to a refetch ticket (type + <Agg> id refs only). queue-only ⇒ nothing emitted (vanilla/realtime-emit.ts).
abstract aggregate Party + extends (TPC) base emits no schema; each concrete is a standalone Ecto.Schema on its own table; the context module gains list_parties/0 (the union of the concrete list_<concrete>/0 reads).
abstract aggregate Party + inheritanceUsing: sharedTable (TPH) the concretes share one table: each concrete Ecto.Schema declares schema "<base_plural>", a :kind string field defaulted to its own name, and every read self-filters on where: c.kind == "<Concrete>" so it reads/writes only its rows. The base owns no schema; the context module gains the same polymorphic list_parties/0 union reader. See phoenix-tph-emission.md.
persistedAs: eventLog + apply(...) (event sourcing) Supported (src/generator/elixir/vanilla/eventsourced-emit.ts): an append-only <agg>_events stream + apply fold + rehydrator, the elixir sibling of the node/.NET/python/java event stores.
shape: document persistence Supported (CRUD + finds/ops — DEBT-07; Route A) — src/generator/elixir/vanilla/document-emit.ts: the whole aggregate persists as one jsonb blob in an (id, data, version) table, where data is a typed embeds_one :data, <Agg>.Data embed cast via cast_embed (the same validate_required / invariant validators the relational base_changeset runs); reads merge data back over the id. Custom finds filter in memory (`Repo.all
test "…" { … } ExUnit → test/<ctx>/<agg>_test.exs (use ExUnit.Case, async: true) + a once-per-project test/test_helper.exs. (src/generator/elixir/vanilla/tests-emit.ts) ports the full Loom idiom onto a pure domain core emitted on the aggregate module (domain-core-emit.ts): def create(attrs) = base_changeset |> Ecto.Changeset.apply_action(:insert) and def <op>(record, params) = precondition + in-memory mutation — both Repo-free. So Agg.create({…}) → {:ok, p} = Agg.create(%{…}), expect(create({bad})).toThrow() → assert {:error, _} = …, o.op(x) → o = Agg.op(o, %{…}), precondition toThrow → assert_raise, field reads → assert == (money/decimal via Decimal). Verified green under mix test with no DB. A value-object construction invariant (expect(Money{…}).toThrow()) lowers to the VO's validating constructor — assert {:error, _} = Money.new(%{…}) (F5; valueobject-emit.ts emits <VO>.new/1, and the aggregate base_changeset runs it via validate_vo so the invariant is enforced at the real create/update path, not just in tests). A config/test.exs is emitted so mix test can load (never copied into the prod image). See docs/audits/test-parity-generated-backends.md.
seed [name] [raw] { … } Supported (src/generator/elixir/vanilla/seed-emit.ts, M-T6.37) — one <Ctx>.Seeds module per context that declares a seed block, run at boot after migrating and also reachable manually via the emitted priv/repo/seeds.exs (mix run priv/repo/seeds.exs). Domain-path rows go through <Agg>Repository.insert(%{…}) so the aggregate's base_changeset invariants run (a bad seed fails at boot rather than writing a corrupt row); raw rows bypass the domain and emit the shared cross-backend INSERT via renderSeedRowInsert, schema-qualified because Phoenix aggregates live in per-context Postgres schemas. Idempotent per D-SEED-IDEMPOTENCY: a __loom_seed marker table records each applied dataset (ship-once), default always runs and other datasets opt in via the LOOM_SEED env var. Closes B19 — the last silent gap in this table (verified 2026-08-23).

Per-page detail

PageIR maps onto LiveView:

IR LiveView construct
page X { route, body } defmodule <App>Web.<X>Live do use <App>Web, :live_view end at lib/<app>_web/live/<x>_live.ex
state { step: int = 0 } socket.assigns.step; initialised in mount/3 via assign(socket, :step, 0)
state.step := 1 (in lambda) assign(socket, :step, 1) inside generated handle_event/3
match { p => v; else => fallback } cond do p -> v; true -> fallback end (or <%= cond do … end %> in HEEx)
requires <pred> (page) full handle_params/3 guard — when the predicate fails it put_flash(:error, "forbidden") + push_navigates to / (the read-side UI analogue of an operation's requires; liveview-emit.ts)
navigate(<P>, {…}) push_navigate(socket, to: ~p"/route?…")
on <channel>.<Event>(e) { toast(…) refetch(Agg) } (ui-level realtime, channels.md Part I) native — no SSE client. mount/3 if connected?(socket), do: Phoenix.PubSub.subscribe(<App>.PubSub, "events") (the same topic every domain emit broadcasts on); one handle_info(%<App>.<Ctx>.Events.<Event>{} = e, socket) clause per subscribed event type → toast(<expr>) becomes put_flash(:info, …), refetch(Agg) re-runs the page's list_<agg>s / get_<agg> load (no-op when the page doesn't display it), plus a handle_info(_msg, socket) catch-all. The reactor/saga path uses direct Dispatcher.dispatch/1 calls (never this PubSub topic), so it is untouched. A ui with no on handlers emits byte-identical output (liveview-emit.ts + realtime-liveview.ts).
Scaffolded body pack.render("page-list" | "page-new" | "page-detail", vm) → HEEx inline in render/1
Pack-emitted Playwright page object e2e/pages/<x>.ts — same testid-keyed shape as React; HEEx HTML is selector-compatible

The framework-specific seams (state read/write, hook hoisting, match, navigate, helper imports) live behind the WalkerTarget interface in src/generator/_walker/target.ts — see page-metamodel.md §16 for the full mapping table.


Java backend (platform: java)

Spring Boot 4.1 / Spring Data JPA (Hibernate) / Postgres, built with Gradle (Kotlin DSL, Boot plugin + BOM import, Java 25 toolchain; no wrapper jar is committed — the generator emits text only, and the Dockerfile/CI/dev environments provide Gradle ≥ 9.1 — required for the Java 25 toolchain — or run gradle wrapper once). Emission lives in src/generator/java/; the surface is src/platform/java.ts (java@v1). Per-aggregate placement routes through the layout adapter (byFeature default — package-by-feature; byLayer — package-by-layer), which owns BOTH the package and the file path.

Per deployable it emits:

Piece Files
Project shell build.gradle.kts + settings.gradle.kts (Boot plugin; jMolecules, springdoc, Flyway when migrations exist), Application.java, application.yml (datasource via SPRING_DATASOURCE_* env), multi-stage Gradle Dockerfile
Domain typed-id records (@Embeddable, newId()), enums (DSL-cased constants — the wire), VO records running invariants in the compact constructor, event records implementing a DomainEvent marker (jMolecules-annotated), aggregate/part classes with package-private fields + record-style accessors, create(...) factory, pullEvents(), positional part _create factories
Persistence JPA annotations mirroring the shared MigrationsIR schema (@EmbeddedId typed ids, flattened-VO @AttributeOverrides, unidirectional @OneToMany containments with nullable = false join columns, @ElementCollection join tables for X id[] + value collections, @MappedSuperclass TPC bases); repository triple — domain port (save/findById/getById/findAll/delete + declared finds), Spring Data interface with @Query JPQL finds, @Repository impl mapping misses to 404
Migrations MigrationsIR → Flyway db/migration/V<ts>.<n>__*.sql via the shared Postgres-SQL renderer
API @RestController per aggregate (POST / 201 {id}+Location, GET /{id}, GET /, POST /{id}/<op_snake> 204, GET /<find_snake>, DELETE /{id}), DTO records in wireShape order (money/datetime as strings), wire validators from the shared invariant classifier → 422 RFC 7807 with errors[], @RestControllerAdvice (400/403/404/422/500 problem+json — 400 only for a malformed body, the domain floor is 422), springdoc /openapi.json brought to cross-backend parity by an OpenApiContractCustomizer (named <Agg>ListResponse array wrappers, RFC 7807 error responses, named enum components, empty request bodies for param-less ops, per-component required sets, Workflow operationId suffixes)
Auth auth: required + user {} → typed User record, UserVerifier boundary + accept-all dev stub, 401 filter, ThreadLocal accessor; currentUser threads into ops as a trailing parameter
Workflows POST /workflows/<snake> via a per-context @Service (loops over Repo.run(...) retrievals incl. the call-site page: tuple, workflow-level emit logging the domain_event envelope)
Retrievals / criteria reified criteria → <Agg>Criteria Specification<T> factories (java consumes CriterionIR directly — the first backend to); run<Name> port methods: an exact-criterion-ref retrieval rides JpaSpecificationExecutor.findAll(spec, Sort), composed wheres fall back to @Query JPQL with order by; paged runs via the OffsetLimitPageRequest Pageable
Paged finds find all(): T paged → Paged<T> envelope over Spring Data Pageable (1-based, page=1&pageSize=20 defaults)
Exception-less returns operation f(): X or NotFound → sealed domain union + variant records, Jackson-polymorphic <U>Response wire DTO (type tag), controller switch: error variants → RFC-7807 ProblemDetail at their mapped status, success → 200
Inheritance TPC via @MappedSuperclass; TPH (sharedTable) via JPA SINGLE_TABLE — the base owns the shared table + @DiscriminatorColumn("kind"), concretes carry @DiscriminatorValue and share the base <Base>Id
Single containments hidden owning _parent @OneToOne on the part (JPA has no unidirectional one-to-one with the FK on the part table) + inverse mappedBy with cascade/orphanRemoval on the root
Seeding seed blocks → <Ctx>SeedRunner ApplicationRunner: domain rows through create(...) + the port save, raw rows as schema-qualified INSERTs, ship-once __loom_seed marker (default always, others via LOOM_SEED)
Capability filters non-principal predicates → Hibernate @SQLRestriction (static SQL on every SELECT) on relational + shape: embedded roots, in-app findAll().stream() for shape: document; principal (tenancy) predicates → SpEL-principal JPQL clause AND-ed into the scoped findAll/findById overrides + finds/retrievals
Fullstack (ui:) controllers move under /api, SpaWebConfig serves the SPA bundle (UI_DIR, index.html fallback), React project under ClientApp/, node stage in the Dockerfile; the auth filter guards /api/* only
Extern ops per-op handler interface + throwing dev-stub @Component; service runs check<Op> → handler → invariants → save
Tests test "…" → JUnit 5 classes under src/test/java
Observability always-on catalog envelope as flat JSON on stdout (server_starting … server_drained, request bracket)

Expression rendering is the JAVA_TARGET leaf table over the shared ExprTarget dispatcher: BigDecimal method arithmetic with compareTo comparisons, Objects.equals reference equality, Instant ordering via isBefore/isAfter, find-anywhere regex via Pattern…find(), Streams collection ops with type-directed sum reduction.

Both the ui: embedded-SPA mount and the D-PHOENIX-SURFACE hosts: UI hosting are implemented (M-T6.5): a hosts: binding onto a framework:-owning ui { } block embeds that framework's SPA (react / vue / svelte) into the Spring project — controllers under /api/*, SpaWebConfig serving UI_DIR with an index.html fallback, the SPA under ClientApp/, and a node spa-build Dockerfile stage — the dotnet hosts: twin, at full hosting parity.

Not yet implemented — every gap fails fast at validate time (never a silent downgrade): the reserved axon event-store adapter and jooq persistence adapter (both stubAdapter — the default JPA persistence does emit persistedAs: eventLog), resource-op clients, a principal-referencing capability filter on a non-relational aggregate (loom.context-filter-no-principal — each half ships alone; only the actor + jsonb intersection is deferred), and provenance/audited (gated — no runtime emitted; the node and .NET backends do implement these). See docs/old/plans/java-backend-implementation.md for the execution record.

Value-object read-model fields ARE implemented. A VO-typed workflow-instance field or projection row field emits its <Vo>Response record co-located in the consuming package (application.workflows for instance/projection reads), the read-model analogue of an aggregate response's nested VO records. Only an entity (containment part) read-model field stays gated (loom.java-workflow-instance-field-unsupported / loom.java-projection-field-unsupported) — a defensive backstop, since a part type never resolves in workflow / projection scope.

Discriminated unions (operation returns; union finds take the untagged optional-style path — see payloads.md), shape: document / shape: embedded persistence, event-sourced (persistedAs: eventLog) JPA streams, and non-principal capability filters on those non-relational shapes are all implemented (no per-backend gate refuses them on java; PLATFORM_SAVING_SHAPES.java carries all three shapes).

Names that are Java reserved words

A .ddd member, parameter, operation or enum value may be called case, do, final, native, … — anything the .ddd grammar admits. Java has no verbatim identifier (JLS §3.9), so the emitted host identifier is mangled with a trailing underscore (case → case_, the spelling the emitters already used for let bindings), and the wire spelling is pinned back on wherever that identifier would otherwise have become the wire name:

position what is emitted
request / response / event / VO / projection-row / workflow-instance record component @JsonProperty("case") String case_ — so the JSON body and the springdoc schema key both read case
a find parameter @RequestParam("case") String case_ — the query key stays ?case=
a validation error the advice's pointer mapper inverts the mangle, so the 422 names /case (Spring builds a binding path, which never goes through Jackson)
the ?sort= whitelist the wire key, with one generated line translating it to the JPA property
the column unchanged — @Column(name = "case"), quoted since M-T6.42/M-T6.43
an enum value @JsonProperty("case") case_ plus a generated <Enum>.Codec AttributeConverter, because @Enumerated(STRING) would otherwise persist Enum.name() and write case_ into the column

Nothing about the wire, the OpenAPI document or the stored data differs from the other four backends; the mangle is visible only in the generated Java. Emission for a keyword-free model is byte-identical to before.


Python backend (platform: python)

FastAPI / SQLAlchemy 2 (typed declarative, async) / asyncpg / Postgres, managed by uv (Python 3.12) and held to ruff check + mypy --strict + pytest by the LOOM_PYTHON_BUILD gate. Emission lives in src/generator/python/; the surface is src/platform/python.ts (python@v1; python is the only spelling — the fastapi platform alias was retired the way phoenix/hono were). Async end-to-end: async def handlers over a per-request AsyncSession — repositories flush(), the session dependency commits once after the handler returns, so multi-save workflows are atomic by construction.

Per deployable it emits:

Piece Files
Project shell pyproject.toml (uv, pinned deps, ruff/mypy/pytest config), app/main.py (lifespan: verifier/extern asserts → migrations → seeds; CORS; /health + /ready), app/settings.py, app/db/engine.py, python:3.12-slim + uv Dockerfile
Domain NewType-branded str ids, StrEnums, VO classes with invariant ctors, frozen-dataclass events + dispatcher protocol, aggregate/part classes (private state, @property accessors, create factory, pull_events())
Persistence app/db/schema.py SQLAlchemy models (Mapped[...], dataSource-routed __table_args__ schema, Uuid(as_uuid=False) ids, flattened VO columns, join tables), per-aggregate repositories (find_by_id/get_by_id/all/declared finds/save diff-sync/to_wire from wireShape), where clauses lowered to SQLAlchemy predicates incl. correlated-EXISTS contains
Migrations MigrationsIR → migrations/*.sql via the shared Postgres-SQL renderer + app/db/migrate.py, a __loom_migrations-tracked boot-time runner (also python -m app.db.migrate)
API APIRouter per aggregate (POST / 201 {id}, GET /, GET /{id}, POST /{id}/<op_snake> 204, GET /<find_snake>, DELETE /{id} with 409), Pydantic wire DTOs in wireShape order, named <Agg>ListResponse array components, paged carriers, discriminated-union operation returns (union finds are untagged, optional-style), RFC 7807 handlers (+ §3.2 errors[] on 422), the shared per-route error matrix re-keyed to application/problem+json by an install_openapi post-processor
Inheritance / ES TPC + TPH (kind-scoped shared table, base readers), persistedAs: eventLog append-only stream + applier folds
Sagas app/dispatch.py in-process dispatcher when a channel routes a subscribed event — create(e) by … load-or-allocates the persisted correlation row, on(e) by … routes or drops + logs event_unrouted; handler emits re-enter
Auth auth: required + user {} → app/auth/ (frozen User dataclass, verifier registry, middleware with the cross-backend bypass list) + a dev-stub verifier registered in main.py; current_user threads into ops/finds/workflows as a trailing parameter
Seeds / extern app/db/seed.py (__loom_seed ship-once marker, LOOM_SEED gating, schema-qualified raw INSERTs); app/domain/<agg>_handlers.py extern registries (TypedDict requests, dev-stubs, boot verify) with routes running load → check_<op> → handler → assert_invariants → save
Fullstack ui: embeds the React SPA (dotnet parity): routers under /api/*, ClientApp/ generation, wwwroot FileResponse fallback, multi-stage Dockerfile
Tests test "…" → pytest under tests/ (synthetic admin actor threads into gated ops)
Observability app/obs/ — flat-JSON catalog envelope (ts/level/event/request_id) on stdout, lifecycle bracket, request bracket middleware with x-request-id correlation, fault warns in the problem handlers

Expression rendering is the PY_TARGET leaf table over the shared ExprTarget dispatcher: native Decimal money arithmetic, comprehension-shaped collection ops, re.search regex, snake_case member folding.

Conformance: a pythonApi deployable ships in examples/showcase.ddd and the e2e OpenAPI cross-check compares it pairwise against Hono / .NET / Phoenix / Java (all five backends are diffed — strict in conformance-parity.yml).

Resource verb clients (objectStore → boto3, queue → aio-pika, api → httpx, mailer → aiosmtplib) are emitted under app/resources/<sourceType>.py; workflow / saga <resource>.<verb>(...) calls import + await them. (Cross-backend the mailer client is nodemailer / MailKit / Swoosh / Jakarta Mail — see resources.md for the full kind × backend matrix.)

Not yet implemented (fails fast / follow-ups): durable-channel outbox tier, --trace domain instrumentation, and provenance/audited (gated — no runtime emitted; the node and .NET backends do implement these). shape: document / shape: embedded persistence (all three shapes are in PLATFORM_SAVING_SHAPES.python) and when can-queries are implemented.

Names that are Python keywords

The python twin of Java's reserved words (eval item 6, ruling D-TARGET-RESERVED-NAMES). Python has no verbatim identifier, and every .ddd member lands in an identifier position — a keyword argument, an attribute, a def name — so one funnel in src/util/naming.ts spells them all: pythonIdent(name) (the snake_cased domain/ORM spelling, def → def_) and pythonWireIdent(name) (the camelCase pydantic spelling, def → def_). The two names the outside world sees are pinned back on:

aggregate Thing with crudish {
  def: string
  operation class(except: int) { … }
}
repository Things for Thing { find byDef(def: string): Thing? where this.def == def }
position what is emitted
domain class def __init__(self, *, def_: str, …), @property def def_(self), def class_(self, except_: int)
row model def_: Mapped[str] = mapped_column("def", Text) — the column stays def
pydantic request / response / VO model def_: WireStr = Field(alias="def") — the JSON key, the OpenAPI key and a 422's loc stay def
a find query parameter def_: Annotated[WireStr, Query(alias="def")] — ?def=
an enum value lambda_ = "lambda" — the stored and wire value stay lambda
the ?sort= whitelist {"def": "def_"} — wire key → ORM attribute

Emission for a keyword-free model is byte-identical: pythonIdent equals snake for every other name. Not yet funnelled: folded / query-time projection read models and the auth.user claim record still spell a member name bare (a keyword there remains a syntax error, not a silent defect).

Generated-helper collisions. A member may also take the name of a private helper the entity emitter generates. The invariant check was the one that collided: .NET emitted private void AssertInvariants() beside the AssertInvariants property of a member assertInvariants (CS0102), node's backing field private _assertInvariants: string duplicated the method, and python's backing field self._assert_invariants overwrote the method (a TypeError on the first invariant check). On all three the helper now steps aside — AssertInvariants_ / _assertInvariants_ / _assert_invariants_ — only when a member takes its name. Java (_assertInvariants beside the field assertInvariants) and Elixir (functions and struct keys are separate namespaces) never collided. Pinned by the python-reserved-words corpus row (every compile tier + the wire-golden differential) and test/generator/target-reserved-member-names.test.ts.

The per-target reserved-identifier table (every backend)

The Java and Python sections above are two instances of one contract. Every target's reserved words now live in ONE table, src/util/target-identifiers.ts — ts, csharp, java, python, elixir, fsharp, dart — with ONE escaping path, escapeTargetIdent(target, name) (C# @base, F# ``member``, a trailing _ everywhere else) and a pure isReserved(target, name). The per-language helpers (escapeCsharpIdent / escapeTsIdent / escapeJavaIdent / escapePythonIdent / escapeElixirIdent in naming.ts, java's jid and package segments, F#'s fsIdent, Dart's dartMember) are views of it, so no backend carries a keyword list of its own. One table closes the drift the per-backend copies had: Java's two copies disagreed on record / yield / sealed / permits; TS lacked the strict-mode arguments / eval; C# lacked await (illegal inside the async handlers every command runs in); python lacked the method receivers self / cls and the builtin / BaseModel names a generated class body shadows (str, dict, …).

A second table, escapeTargetMember, holds names a target reserves as a class MEMBER although they are legal bindings — TS constructor and then (an operation named then makes every aggregate a thenable, so await repo.getById(…) breaks), and Dart's inherited Object members (hashCode, toString, noSuchMethod, runtimeType).

Scope collisions are a separate concern and stay with each backend's namer: a .ddd name landing on a local the generated method declares for itself (javaLocals, csLocalNamer, csParamIdent, dartComponentMember) is not a keyword, and those namers build on the table rather than replace it.

The gate is test/fixtures/corpus/target-reserved-words.ddd (row ALL): every table word the .ddd grammar admits, as a field, an enum value, an operation, and that operation's parameter — ~180 names at once — on every backend's compile leg (tsc, dotnet build /warnaserror incl. Dapper, gradle, uv + ruff + mypy + pytest, mix compile --warnings-as-errors). test/util/target-identifiers.test.ts keeps the table and the fixture from drifting (a table word the grammar admits but the fixture omits fails), and checks the tables with an authoritative source against it (python's keyword module, the TypeScript scanner's reserved-word range).

backend positions newly escaped by this gate
node every parameter declaration + param reference (break_: number); operation / helper method names then / constructor
.NET enum members (@break, @base — the runtime name and the wire value stay break); domain-service, workflow-function and extern-hook parameters (@event, @base); CA1720 stays in the csproj NoWarn (member names are domain vocabulary)
python receivers + builtin names via the existing funnel; the repository's SQLAlchemy / app.db.wire imports are detected by CALL, not by word (an escaped field and_ is not sqlalchemy.and_)
elixir elixirLocal(name) for every parameter / bound op input (do_); elixirOpAction(name) for a controller action (def do_, and show / index step aside to show_op from the CRUD read actions); an OpenApiSpex schema with a reserved property key opts out of the derived struct (struct?: false)
java already clean through jid (M-T6.36)

Not covered yet — tracked by #4213: a FIELD named constructor on node (its getter cannot carry that name), TYPE names that shadow a target's globals / BCL / framework types (valueobject Promise / String / Symbol on node; .NET handles Task-like names via dotnet-bcl-type-collision), and the frontends (the fixture declares no ui).


System orchestration

generate system (in src/system/index.ts) runs each deployable through its respective backend, scoped to its hosted contexts, and writes everything to a flat tree:

<outdir>/
├── api/                  # one folder per deployable
├── catalog_web/
├── web_app/
├── docker-compose.yml    # postgres + every deployable + healthchecks
├── docker-compose.obs.yml       # opt-in: prometheus + jaeger + the OTLP export env
├── monitoring/prometheus.yml    # the overlay's scrape config, one job per backend
├── db-init/
│   └── 00-create-databases.sql  # CREATE DATABASE per backend deployable
└── e2e/                  # generated DSL-level e2e suite (vitest+fetch)
    ├── package.json
    ├── tsconfig.json
    └── <System>.e2e.test.ts

Per-platform compose service shape

Platform Internal port Env Depends on db Healthcheck path
dotnet 8080 ConnectionStrings__Default=Host=db;Port=5432;Database=<slug>;… yes /ready
node 3000 DATABASE_URL=postgres://…/<slug> yes /ready
react / static (also vue, svelte) 3000 VITE_API_BASE_URL=…/api (build-time base override; the bundle defaults to a relative /api) + VITE_API_PROXY_TARGET=http://<backend-svc>:<port> no /
phoenixLiveView 4000 DATABASE_URL=ecto://…/<slug>, SECRET_KEY_BASE, PHX_HOST, PHX_SERVER=true, PORT=4000 yes /health

Frontend same-origin in compose. A vite-served frontend's built bundle fetches /api relative, and its image runs vite preview. So compose injects VITE_API_PROXY_TARGET (→ the target backend's compose service, e.g. http://api:3000, not localhost) and the generated vite.config.ts proxies /api to it on both server (dev) and preview (the compose runtime). Result: one origin, no CORS, no separate API host — the compose twin of the k8s same-origin Ingress. (Local vite dev falls back to the baked http://localhost:<port> target.)

The platform contract decides UI mount admissibility and DB ownership via two PlatformSurface flags (src/platform/surface.ts): mountsUi (true on react, static, phoenixLiveView) and needsDb (true on dotnet, node, phoenixLiveView). The system orchestrator consults these instead of hardcoding platform names, so adding a new platform extends the registry + the two flags only.

Per-deployable databases

The init script db-init/00-create-databases.sql creates one database per backend deployable. Each backend's connection string points to its own DB. This sidesteps a real bug observed on .NET: EF Core's EnsureCreated is all-or-nothing per database, so two backends sharing one DB silently leave the loser-of-the-race's tables uncreated. React deployables don't connect to a DB and don't appear in the init script.

Every statement is idempotent (SELECT 'CREATE DATABASE x' WHERE NOT EXISTS (…) \gexec): Postgres runs docker-entrypoint-initdb.d only on an EMPTY volume, so the compose stack also carries a one-shot db-bootstrap service that re-applies the same file over TCP on every up, and each database-backed service waits for it with condition: service_completed_successfully. A deployable added in a later generation therefore gets its database on an existing volume (B-26 of the banking evaluation — it used to crash-loop on database "x" does not exist).

Migrations

Schema changes flow through a platform-neutral MigrationsIR (src/ir/types/migrations-ir.ts), one per entry of the committed migration chain (migrations/ beside the .ddd, recorded by ddd migrate add). The builder (src/system/migrations-builder.ts) derives each module's delta against the chain's head for every (subdomain, storage) pair owned by a deployable (migrationsOwner enrichment in src/ir/enrich/enrichments.ts). Backends only translate steps to their syntax via the per-platform emitters listed below — they never recompute the schema themselves. Shared SQL rendering for the Postgres-backed emitters lives in src/generator/sql-pg.ts.

Backend Emits Applied by
Phoenix priv/repo/migrations/<ts>_<name>.exs (Ecto DSL) mix ecto.migrate at boot via the existing release config
Hono db/migrations/<version>_<name>.sql + db/migrations/meta/_journal.json the generated db/migrate.ts: await migrate(db, { migrationsFolder }) in index.ts reads the journal + .sql files and tracks state in __drizzle_migrations as Drizzle's migrator does, and applies a -- loom:no-transaction file (a CREATE INDEX CONCURRENTLY unit) outside a transaction. npm run db:migrate runs the same migrate out of band (tsx db/migrate-cli.ts, a pool from DATABASE_URL); drizzle-kit migrate is not wired, since it runs every file inside one transaction
.NET Migrations/<Version>_<Name>.cs (migrationBuilder.Sql(@"...")) db.Database.Migrate() in Program.cs after builder.Build(); no ModelSnapshot is emitted — Loom owns SQL generation, so dotnet ef migrations add is never run and the runtime migrator is happy without one

Phoenix stays in Ecto DSL because its output is Elixir. Hono and .NET share src/generator/sql-pg.ts for bit-identical Postgres DDL.

Every generate system renders the whole chain: one file per backend per recorded generation, the first of each subdomain its "Initial". Recording a new property with migrate add adds a single ALTER TABLE … ADD COLUMN … per backend. A source with no chain yet renders each subdomain's "Initial" derived from the model.

Regenerating is deterministic: the same source and chain render the same files into any output tree. Renames are declared in a migration block (see migrations.md).

test e2e lowering

A test e2e "name" against <deployable> { … } block lowers via src/system/e2e-render.ts into typed fetch calls in <outdir>/e2e/<System>.e2e.test.ts:

DSL form Lowered to
api.<aggregate>.create({ … }) __post(\${base}/`, {…})`
api.<aggregate>.getById(idExpr) __get(\${base}//${idExpr}.id`) (.idauto-appended for knownlet`-bindings)
api.<aggregate>.<op>(idExpr, body?) __post(\${base}//${idExpr}.id/`, body ?? {})`
api.<aggregate>.<find>(args) __getQuery(\${base}//`, args)`

expect(<x>).<matcher>(…) lowers to the native matcher; expect(<call>).toThrow() becomes expect(() => <call>).toThrow().


What the generators don't do

Out of scope for v1 (intentional):

These are all addressable as either generator extensions or .loomignore-pinned customizations.