Platforms — the backend registry
The platform: slot on a deployable picks which backend renders it.
Every platform is implemented by a PlatformSurface in
src/platform/, registered in src/platform/registry.ts. This doc
covers the registered set, how a platform: value is resolved, and
what each platform contributes.
For per-construct generator output (DTO shapes, route files, repository
shapes), see generators.md — that's the one with the
per-platform file maps. This doc covers the registry contract:
which platforms exist, what flags they carry, and how backend
versioning works.
Registered platforms
platform: keyword |
Surface file | Default port | Needs DB | Mounts UI |
|---|---|---|---|---|
node (default and only version node@v5; Hono web framework) |
src/platform/hono/v5/index.ts (shared surface factory: src/platform/hono/v4/index.ts) |
3000 | ✓ | ✗ |
dotnet (default dotnet@v10) |
src/platform/dotnet.ts |
8080 | ✓ | ✓ (when ui: is declared) |
elixir (default elixir@v1; legacy aliases phoenix / phoenixLiveView desugar to it) |
src/platform/elixir.ts |
4000 | ✓ | ✓ (fullstack) |
python (default python@v1) |
src/platform/python.ts |
8000 | ✓ | ✓ (when ui: is declared — dotnet-style dual mode) |
java (default java@v1) |
src/platform/java.ts |
8081 | ✓ | ✓ (ui: embedded-SPA mount; hosts: gated) |
react |
src/platform/react.ts |
3001 | ✗ | ✓ |
vue |
src/platform/vue.ts |
3003 | ✗ | ✓ |
svelte |
src/platform/svelte.ts |
3002 | ✗ | ✓ |
angular |
src/platform/angular.ts |
3004 | ✗ | ✓ |
flutter (mobile axis — Dart/Flutter + Riverpod, self-hosting flutter build web) |
src/platform/flutter.ts |
3006 | ✗ | ✓ (hosts only framework: flutter) |
static |
aliased to react.ts |
3001 | ✗ | ✓ |
- Needs DB — the system orchestrator (
src/system/index.ts) reads this flag to decide whether to emit a per-deployable (idempotent)CREATE DATABASEline indb-init/00-create-databases.sqland wire adepends_on: dbhealthcheck plus thedb-bootstrapone-shot (service_completed_successfully) indocker-compose.yml. - Mounts UI — whether the deployable validator allows a
ui:binding on this platform.react/vue/svelte/angular/flutter/staticalways mount;dotnet,javaandpythonare dual-mode (mount whenui:is declared, otherwise backend-only);elixiralways mounts (fullstack LiveView);nodenever does.
flutteris the mobile axis, not a sixth web SPA. Like Feliz it is a self-hosting frontend (own SDK build, not a vite static bundle), so it hosts only its ownframework: flutterUI and dispatches throughsrc/generator/flutter/. It emits a Dart/Flutter (Material 3) app on Riverpod: wire models (sealed classfor payload unions),ConsumerWidgetpages,Notifierstate + actions,FutureProviderreads forQueryView, andStatefulWidgetforms (CreateForm/OperationForm/DestroyForm). From the one Dart source the emittedMakefilebuilds three surfaces — web (make web, the compose-served surface), Android (make apk), and iOS (make ipa); the native folders are materialised on demand (make prepare). The web build is compiled per-PR bygenerated-flutter-build.yml(flutter analyze+flutter build web); the native surface has no CI gate yet.
Resolving a platform: value
The grammar accepts two forms:
deployable api1 { platform: node, ... } // bareword
deployable api2 { platform: "node@v5", ... } // pinned
Resolution happens in two parts (see parseBuiltinPlatformRef in
src/platform/registry.ts):
Bareword backend — resolves through
BUILTIN_PLATFORM_LATESTto today's default version. Currently:node → v5,dotnet → v10,elixir → v1,python → v1,java → v1. Older in-tree versions can stay resolvable through an explicit pin (BUILTIN_PLATFORM_EXTRA_VERSIONSinsrc/platform/metadata.ts); no backend registers one today. A removed version is listed inRETIRED_BACKEND_VERSIONSwith its successor:node@v4(zod 3 / TS 5) was retired, and pinning it is aloom.platform-version-unknownvalidation error namingnode@v5. Frontend platforms (react,vue,svelte,angular,static) intentionally aren't versioned at the platform layer — their version lives on the design pack / stack axis (seedesign-packs.md).What
vNmeans. A backend package's Loom version tracks the package's own dependency-major evolution, not the platform's name and not the JS runtime. The retirednode@v4was cut around Hono 4 + zod 3 + TS 5;node@v5— today's default and only version — is the cross-major successor carrying zod 4, TypeScript 6, vitest 4 (src/platform/hono/v5/pins.ts), with Hono itself still on 4.x because there is no Hono 5. Elsewhere the number does line up with the defining framework (dotnet@v10tracks .NET 10,mantine@v9tracks Mantine 9). Whatnode@v5is definitely not is "Node.js 5". (Sources never spell this out: every deployable uses the barewordplatform: node, and@v5is only the internal qualified ref the resolver fills in.)Pinned
family@version— looked up directly in the registered backend surfaces. Unknown versions are a validation error that lists the available pins (backendVersionsForFamily).
Both forms resolve to the same PlatformSurface instance the system
orchestrator dispatches against.
Realization axes — picking how a backend renders
Beyond which backend, a deployable can pick how that backend
realises the code, via an optional block on platform: — the
realization axes (full design in
proposals/platform-realization-axes.md;
in-flight tracker in
plans/realization-axes-rollout.md):
deployable api { platform: dotnet { persistence: dapper } ... }
deployable web { platform: node { persistence: mikroorm, directoryLayout: byFeature } ... }
Each axis has a per-platform menu of real adapters; an out-of-menu or
not-yet-implemented value is a validation error that lists what's
available. The matured axis today is persistence::
| Platform | persistence: |
Default |
|---|---|---|
dotnet |
efcore, dapper |
efcore |
node |
drizzle, mikroorm |
drizzle |
elixir |
ecto |
ecto |
- The default (
efcore/drizzle) is the full-surface adapter — every aggregate shape, inheritance, associations, audit/provenance, etc. - The alternates (
dapper,mikroorm) are now at FULL PARITY with their default (M-T6.9, drained across 7 waves): every relational / document / embedded / event-sourced / inheritance (TPH+TPC) shape, containment (incl. recursive part-in-part), associations, filters, audit / provenance / managed fields, retrievals, seeds, and the workflow outbox all emit.dapper≡ EF Core;mikroorm≡ Drizzle. Neither adapter rejects a SHAPE any more:loom.mikroorm-unsupportednow fires only for declared migration steps (orm.schema.update()has no rename intent to consult), and its last shape reject — an abstract inheritance base owning its owncontains— turned out to be impossible on every target and became the target-neutralloom.abstract-aggregate-contains(seeinheritance.md). There is no adapter-specific find-predicate narrowing left either: the last one was measured in wave C2 packet 2n and turned out to be the same mistake —firstNonQueryableNodewas position-blind, so a predicate with no COLUMN in it (where true, a bool parameter, acurrentUserclaim standing alone) was admitted and then crashed drizzle codegen, was refused on MikroORM, and emitted a host-language boolean into aWHEREon the other three. It is now the target-neutralfirstNonQueryablePredicate, andloom.find-predicate-unsupportedand its per-adapter descriptor table are deleted. The alternates share the generated domain layer with the default and only swap the persistence layer (Dapper SQL repositories / MikroORMEntitySchema+EntityManager), so a project can switch persistence without touching its domain code.
Event-sourcing (persistedAs: eventLog + apply(...)) emits on node,
.NET, Python, Java and Elixir (append-only per-aggregate <agg>_events
stream, fold-on-load). A dedicated Marten
document/event-store backend is 3rd priority (if ever): D-DOCUMENT-AXIS
(see decisions.md) currently pins no new Marten backend —
the event log lives on the existing relational stores.
The only other user-selectable axis is directoryLayout: byLayer | byFeature
(the fully adapter-consumed axis — it routes each artifact's path via
layout.pathFor). The former style / application, transport, and runtime
axes were removed — style is now one fixed emission per backend (cqrs on
dotnet, layered elsewhere), kept internally but no longer user-selectable, and
the name-only transport / runtime registries were deleted whole. So a
platform { … } block accepts only persistence: and directoryLayout: today
(ddd.langium; validator RealizationAxis). See the D-REALIZATION-AXES
supersession note in decisions.md.
Phoenix backend (plain Ecto/Phoenix)
platform: elixir emits plain Ecto.Schema / Ecto.Changeset / Ecto.Repo
over Phoenix.Endpoint + LiveView. The former foundation: ash is removed,
and with it the foundation: axis itself — it had collapsed to a single value
(vanilla) on every backend, so it no longer exists on the grammar. A would-be
foundation: clause no longer parses.
The document sub-case below is the one feature with a partial story:
| Feature | elixir (vanilla) |
Gate (fail-fast) |
|---|---|---|
Event-sourced storage persistedAs: eventLog |
✓ emits (incl. every apply(…) fold shape²) |
— |
Event-sourced workflow (eventSourced saga) |
✓ emits (per-correlation stream + fold) | — (every backend emits it; no per-backend gate) |
| Provenanced fields (runtime trace) | ✓ emits | — |
shape: document aggregate |
✓ CRUD + finds/ops/functions/returning-ops; small residual gated¹ | loom.vanilla-document-unsupported (sub-case) |
or-union-returning op with emit/add/remove body |
✓ full bodies | — |
| State persistence, unions, carriers, filters, stamping, inheritance | ✓ emits | — |
¹ vanilla emits the document CRUD surface (an (id, data, version) jsonb
table) plus custom finds (in-memory Enum.filter over the data map, incl.
value-object-subfield reads), named operations (body over the data map →
update/2), pure functions (over the data map), returning ops
(: A or B → tagged tuple), paged and union finds, containment mutation, and
collection reads over the aggregate's own in-memory lists (lines.sum(l => l.qty) / .count / .any(λ) over a containment embeds_many, .contains
over a scalar array) — DEBT-07 + Route A. It also applies capability filters
(filter / policy / tenancy) on every document read, evaluated in-app over
the rehydrated %<Agg>.Data{} embed. The residual that stays gated:
provenanced ops, derived and dereferenced-entity reads, collection ops over a
REFERENCE collection (X id[] — a join table a jsonb blob has no equivalent
for), and value-object METHOD calls; host those on node/dotnet/python/java.
² An apply(e: E) { … } fold rebinds in-memory state (an ES aggregate has no
state table), so EVERY fold shape emits — src/generator/elixir/vanilla/fold-stmt-emit.ts
(M-T6.2, shared by the aggregate and event-sourced-workflow folds):
- scalar compound
-=(balance -= e.amount) → arithmetic; - primitive-collection
+=(tags += e.tag) → list append; - value-object-collection
+=(charges += Money{…}) → a plain map appended inline (NO<agg>_chargeschild schema is emitted for an ES aggregate — it has no child table); - contained-entity-part
+=(boxes += Box{…}) → a plain map over the part's wire shape with a freshly mintedidand[]for any of the part's own containments (part-in-part), since the ES path emits no%Ctx.Box{}Ecto struct — mirroring the other backends' ES folds (node:Box._create({ id: Ids.newBoxId(), … })).
Before M-T6.2 the compound-mutation shapes were silently dropped (a # unsupported applier statement comment that compiled green while losing the transition).
Minting the contained-part id in the fold is non-deterministic across replays —
a shared limitation of the ES contained-part model on every backend, tracked as a
cross-backend follow-up.
Every emitter is compiled against real Elixir/Ecto by
elixir-vanilla-build.yml (one fixture per feature under
test/e2e/fixtures/elixir-vanilla-build/), so the gate is verified, not paper.
Backend versioning
Backend families are discoverable through an injectable source
(setBackendSource / resetBackendSource in registry.ts) so the
playground can back it with a VFS impl instead of fs /
node_modules. The in-tree set is exposed by
defaultBuiltInBackends().
Each registered backend ships a manifest:
interface LoomBackendManifest {
kind: "backend";
family: BackendFamily; // "node" | "dotnet" | "elixir" | "python" | "java"
loomVersion: string; // e.g. "v5"
core: string; // semver range against @loom/core
}
Today every backend family registers exactly one version. Adding a second version for a family means:
- Build the new surface (e.g.
src/platform/hono/v6/index.ts), typically by feeding a new pin set through the existing surface factory and applying upstream migration steps. - Add an entry in
inTreeBackends(src/platform/registry.ts) with the new manifest + surface. - Optionally flip
BUILTIN_PLATFORM_LATEST[family]once the new version is the recommended default. Pinning viaplatform: "<family>@<version>"is the opt-out for projects that want determinism across toolchain upgrades.
The PlatformSurface contract
Every surface implements:
| Member | Role |
|---|---|
name |
Discriminator matching the Platform IR / grammar token. |
defaultPort |
Used when the deployable doesn't specify one. |
needsDb |
Drives db-init script + depends_on: db wiring. |
mountsUi |
Drives validator acceptance of ui: on this platform. |
emitProject({ contexts, deployable, sys, migrations, emitTrace }) |
Returns Map<path, content> for the deployable's project tree. |
composeService({ deployable, sys, slug }) |
Returns the docker-compose stanza (env, healthcheck path, internal port). |
emitProject is the platform's entire output surface. It MUST be
self-contained — no cross-platform calls. The system orchestrator
in src/system/ composes the resulting per-deployable file maps into
one tree and writes the cross-cutting docker-compose.yml,
db-init/, and .loom/ artefacts on top.
platform: node does not mount a UI — a settled non-goal
node is the only backend with mountsUi: false, and that is deliberate,
not a gap: the Hono emitter serves JSON only. It emits no static-asset
middleware (serveStatic appears nowhere in src/platform/hono/), so there is
nothing for a ui: binding to be served by. A React/Vue/Svelte/Angular UI
against a node backend is its own deployable — an nginx-served bundle with
targets: pointed at the API — which is the shape every shipped example uses.
The four mountsUi: true backends (dotnet, java, python, elixir) each host a
bundle from their own web server; node's runtime has no equivalent story that
does not amount to reimplementing one.
Re-open this only with the static-serving emission in hand, not the flag:
flipping mountsUi alone would admit a ui: binding that generates a
deployable serving 404s for every page.
Known inconsistency (recorded, not fixed): node's descriptor still declares
hostableFrameworks: STATIC_BUNDLE_FRAMEWORKS. That field is read only by theloom.ui-framework-unhostablerule, which is itself guarded byplatformMountsUi(...)— so on node it is unreachable data that reads as a capability claim the emitter does not honour. Emptying it touches theadapter-metadata.tsmirror and its consistency test, so it is a separate change.
Cross-references
generators.md— per-platform feature matrix (what each backend emits, file-by-file).architecture.md— howsubdomain,deployable,api,storage,resource, anduicompose into a runnable system.design-packs.md— frontend versioning via design pack + stack.technical.md— pipeline phases, including how phase ⑨ dispatches over the platform registry.plans/backend-packages.md— the in-flight design for distributing backends as installable npm packages.