14. APIs, storage, resources & channels
The infrastructure surface that sits between the pure domain and the deployment topology: the api contract a subdomain exposes, the physical storage instances a system declares, the resource bindings that wire a context's data needs to that storage, and the channel / channelSource pair that realises event pub/sub. Reach for this chapter when you're deciding what store backs which context, how a backend connects to it, and how carried events leave the process.
Grammar:
Api,ApiStatus,Route,HttpMethod,HandlerRef,CommandHandler/QueryHandler,Storage,StorageType,ConnectionSource,Resource,DataSourceKind,IndexSpec,Channel,ChannelSource· Validators:checkDataSource/checkChannels(src/language/validators/{datasource,channel}.ts),api-checks.ts/system-checks.ts· Codes:loom.route-handler-unresolved,loom.duplicate-handler,loom.command-handler-multi-aggregate,loom.query-handler-saves,loom.handler-*,loom.resource-index-*,loom.resource-api-*,loom.file-field-needs-object-storage,loom.channelsource-*,loom.channel-key-missing-field,loom.deployable-channel-unrelated,loom.channel-consumer-unwired,loom.relay-target-not-subscribed· Docs:../resources.md,../channels.md,../architecture.md
The model is a three-link chain — storage is the physical instance, resource is the configured binding, and the context's data need is derived from its aggregates, never authored:
storage physical store / service storage primarySql { type: postgres }
↑ use:
resource the configured binding resource ordersState { for: Orders, kind: state, use: primarySql }
↑ dataSources:
deployable wires the resources it hosts deployable api { … dataSources: [ordersState] }
Everything below is verbatim from two generate system runs over scratch models: an infrastructure system Shop (an Orders context on node + a Shipping context on python, with postgres / localDisk / rabbitmq / smtp storages and a cross-service api binding), and a route/handler model emitted once per backend (node / dotnet / python / java / elixir).
api
Api: 'api' name=ID withClause=WithClause? ('from' source=[Subdomain:ID])?
('{' ('urlStyle' ':' ('literal'|'resource'))? ApiStatus* Route* '}')?
ApiStatus: 'httpStatus' error=ID '->' code=INT
Route: 'route' method=HttpMethod path=STRING '->' target=HandlerRef
HttpMethod: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
HandlerRef: context=[BoundedContext:ID] '.' handler=ID
An api is a derived contract, not a hand-written one — it names a subdomain, and the operation/query/create/destroy declarations inside that subdomain's aggregates become its HTTP surface. The block is optional; the bare api OrdersApi from Sales form derives everything. A backend deployable exposes a contract with serves: OrdersApi.
api OrdersApi from Sales {
urlStyle: resource // route segments use the plural noun (default: literal → the op name)
httpStatus NotFound -> 404 // map an `error NotFound` variant to 404 (default for unmapped: 500)
}
The contract mounts under /api/<aggregate-plural>; CRUD routes are derived from the aggregate's wire shape:
// http/index.ts — the api's aggregates each mount a sub-router under /api
app.route("/api/orders", orderRoutes(new OrderRepository(db, events)));
app.route("/api/realtime", realtimeRoutes()); // present only when a channel is declared
// http/order.routes.ts — derived CRUD, one createRoute() per operation
createRoute({ method: "post", path: "/", operationId: "createOrder", /* … */ });
createRoute({ method: "get", path: "/{id}", operationId: "getOrder", /* … */ });
createRoute({ method: "get", path: "/", operationId: "listOrders", /* … */ });
route — the explicit transport binding
Derived CRUD covers the aggregate surface; a route binds one explicit path to a context-level commandHandler / queryHandler (chapter 3, commandHandler / queryHandler). HandlerRef is <Context>.<Handler> — an unknown context or handler name is loom.route-handler-unresolved.
context Orders {
command PlaceOrderCommand { code: string }
commandHandler PlaceOrder(cmd: PlaceOrderCommand): Order id {
let order = Order.create({ code: cmd.code, status: "Draft" })
return order.id
}
queryHandler GetOrder(orderId: Order id): Order { let order = Orders.getById(orderId) return order }
extern queryHandler GetQuote(sku: string): string;
}
api OrdersApi from Sales {
route POST "/orders/place" -> Orders.PlaceOrder
route GET "/orders/{orderId}/view" -> Orders.GetOrder
route GET "/quotes/{sku}" -> Orders.GetQuote // extern → scaffolded impl file
}
Routes emit only on a deployable that serves: that api (the derived /api/<plural> CRUD mounts either way). A {brace} path segment binds a same-named handler parameter as a path param; every other parameter is bound from a JSON request body — including on GET (route GET "/orders/by-code" -> C.FindByCode with a code: string parameter emits a GET whose request schema is a JSON body on node and [FromBody] on .NET). Model a filtered read as POST, or take the value in the path, if a GET body is unacceptable to your clients.
// http/ordersApi-routes.ts — mounted at app.route("/api", ordersApiRoutes(db, events))
createRoute({
method: "post", path: "/orders/place", tags: ["OrdersApi"], operationId: "ordersPlaceOrder",
request: { body: { content: { "application/json": { schema: z.object({ code: z.string() }) } } } },
// …
});
async (httpCtx) => {
const body = httpCtx.req.valid("json");
const cmd = { code: body.code };
const orders = new OrderRepository(db, events);
const order = Order.create({ code: cmd.code, status: "Draft" });
await orders.save(order);
return httpCtx.json(order.id as unknown, 200);
}
// the extern handler dispatches to a scaffold-once, user-owned module
const result = await getQuoteImpl(sku);
// Api/OrdersApiRoutesController.cs — one action per route, each a Mediator send
public sealed record PlaceOrderBody(string Code);
[HttpPost("/orders/place")]
public async Task<IActionResult> PlaceOrder([FromBody] PlaceOrderBody body)
=> Ok(await _mediator.Send(new PlaceOrderCommand(body.Code)));
[HttpGet("/orders/{orderId}/view")]
public async Task<IActionResult> GetOrder(Guid orderId)
{
var result = await _mediator.Send(new GetOrderQuery(new OrderId(orderId)));
return Ok(new OrderResponse(result.Id.Value, result.Code, result.Status, result.Version));
}
# app/http/orders_api_routes.py
@router.post("/orders/place", operation_id="placeOrder")
async def place_order_route(body: PlaceOrderBody, session: SessionDep) -> dict[str, object]: ...
@router.get("/orders/{order_id}/view", operation_id="getOrder")
async def get_order_route(order_id: UuidStr, session: SessionDep) -> dict[str, object]: ...
// api/OrdersApiRoutesController.java
@PostMapping("/orders/place")
public ResponseEntity<?> placeOrder(@RequestBody PlaceOrderBody body) { … }
@GetMapping("/orders/{orderId}/view")
public ResponseEntity<?> getOrder(@PathVariable UUID orderId) { … }
# lib/d_elixir_web/router.ex
post "/orders/place", DElixirWeb.OrdersApiRoutesController, :place_order
get "/orders/:order_id/view", DElixirWeb.OrdersApiRoutesController, :get_order
get "/quotes/:sku", DElixirWeb.OrdersApiRoutesController, :get_quote
Handler gates (all src/ir/validate/checks/api-checks.ts unless noted): a queryHandler may not mutate (loom.query-handler-saves); a commandHandler touches one aggregate (loom.command-handler-multi-aggregate); a handler parameter may not be named id (loom.handler-param-reserved-id); an optional read bound in a handler body is T?, dereferenced only after a null test or an if let (loom.nullable-receiver); a non-extern handler needs a body and an extern one must be bodyless (loom.handler-missing-body / loom.extern-handler-has-body); and a handler name may not collide with another handler in the same context (loom.duplicate-handler, src/language/validators/duplicates.ts) — the route reference <Context>.<Name> would be ambiguous.
urlStyle only changes the route segment of custom operations — op.routeSlug is op.name under literal and plural(op.name) under resource (src/platform/hono/v4/routes-builder.ts, enriched per-subdomain in enrichments.ts). The base CRUD paths above are identical either way; the operationId, request DTO names, and extern-handler keys always stay keyed on the op name.
httpStatus <Error> -> <Code> overrides the HTTP status the RFC-7807 ProblemDetails translator emits for an exception-less operation returning that error variant. It only surfaces on an operation that actually returns the named error (operation cancel(): Order or NotFound); with no such operation it emits nothing, and the validator (structural-checks.ts) warns when a returned custom error has neither a stdlib default nor an httpStatus mapping (it would default to 500). The per-error → status map carries into every backend's error translator (errorStatuses in the IR; consumed by the .NET [ProducesResponseType], Python errors.py, Java/Hono ProblemDetails emitters).
serves: and the OpenAPI document
serves: OrdersApi on a backend deployable mounts that api's explicit routes and pins its contract identity. It does not gate the spec document: every backend publishes its own OpenAPI 3.1 spec at GET /openapi.json whether or not a serves: clause exists (Hono app.doc, Swashbuckle with the document name pinned to /openapi.json, FastAPI, springdoc's api-docs.path, and a Phoenix OpenapiController). Python and Java additionally serve a Swagger UI (/docs, springdoc.swagger-ui), both gated by LOOM_OPENAPI_UI.
An api that no backend deployable lists in serves: is dead — nothing mounts its routes or pins its contract — and raises the warning loom.api-unserved naming the api and the backends that could serve it. It stays quiet for a system with no deployables at all (a domain-only model), and for an api a resource { kind: api, use: … } binds, which already fails with loom.resource-api-unserved.
storage
Storage: 'storage' name=LooseName '{' ('type' ':' StorageType) ('instance' ':' …)? ('connection' ':' ConnectionSource)? ('config' ':' '{' … '}')? '}'
StorageType: postgres | mysql | sqlite | inMemory | redis | elastic | meilisearch | kafka | clickhouse | bigquery | s3 | localDisk | rabbitmq | nats | restApi | smtp | ses | sendgrid
A storage is a physical store or service — a typed, reusable slot (one postgres can back several deployables). type: names a built-in sourceType; the config { k: v } map carries vendor parameters validated per sourceType against the registry's config schema (src/util/source-types.ts): an unknown key is a warning, a wrong-typed value is an error, a missing required key (e.g. s3 needs bucket) is an error.
storage primarySql { type: postgres }
storage blobs { type: s3, config: { bucket: "app-files", region: "eu-central-1" } }
Each storage whose type needs a dev backing service becomes a compose sidecar (renderStorageSidecars, src/system/index.ts). The relational stores instead share the single stack db postgres service (see Systems & topology):
| storage type | sidecar | when |
|---|---|---|
s3 |
minio/minio:latest + a named data volume |
always |
smtp |
axllent/mailpit:latest (SMTP :1025, web inbox :8025) |
always — ses / sendgrid are SaaS, no sidecar |
redis |
valkey/valkey:8-alpine with --requirepass |
only when it backs a channelSource a deployable wires; a cache-only redis emits nothing |
rabbitmq |
rabbitmq:4-management-alpine + a mounted broker-init/ definitions file (vhost + per-deployable user) |
when wired as a channel transport; an unwired one falls back to plain rabbitmq:3-management |
kafka |
apache/kafka:4.1.0, single-node KRaft, SASL/PLAIN on the client listener |
only when wired as a channel transport |
localDisk |
none — the object store is a directory in the container (<RESOURCE>_URL_DIR) |
— |
| everything else | none | — |
# docker-compose.yml — an s3 storage and an smtp storage, from `generate system`
blobs:
image: minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
volumes:
- blobs-data:/data
mailer:
image: axllent/mailpit:latest
environment:
MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1
volumes:
pgdata: {}
blobs-data: {}
Generator support is narrower than the grammar. Only
postgres/inMemoryhave full backend codegen, pluss3andlocalDisk(object-store clients),rabbitmqandkafka(queue / channel transports),redis(broadcast channel transport),restApi(http api client) andsmtp/ses/sendgrid(mailer clients).mysql/sqlitebind the relational kinds in the registry, but every shipped SQL emitter targets Postgres — astatebinding on either still generates Postgres wiring;nats,elastic,meilisearch,clickhouseandbigqueryparse but bind no kind and emit nothing — declaring one is a warning,loom.storage-type-unbound, and any use of it is refused by the consuming gate. (natsis not a channel transport:CHANNEL_COMPATIBILITYinsrc/util/channels.tslists onlyinMemory/redis/rabbitmq/kafka.)
Connection sources
ConnectionSource: service(ID) | env(STRING) | secret(ID) | literal(STRING)
A storage may pin where its connection string comes from with connection: — a compose service handle, an env var, a named secret, or an inline literal:
storage primarySql { type: postgres, connection: env("DATABASE_URL") }
The four forms lower to a ConnectionSourceIR (kind: service | env | secret | literal) on the storage. Honest gap, and the compiler says so: the IR carries connection but no backend emitter consumes it — the generated compose env still uses the per-deployable derived DATABASE_URL shown in Systems & topology, and the k8s/Helm secret wiring is derived from the compose service host rather than from the source you named. Declaring the clause raises loom.reserved-not-emitted (a warning, printed by both ddd parse and ddd generate system), so this is not prose you have to find — the clause parses, validates, is recorded for the artefacts, and tells you it does not yet override the generated wiring. Draining it is M-T7.9, which deletes that warning in the same change.
resource
Resource: 'resource' name=LooseName '{' ('for' ':' [BoundedContext])? ('kind' ':' DataSourceKind)?
('use' ':' [ResourceTarget])? … ('index' ':' '[' IndexSpec+ ']')? ('config' ':' '{' … '}')? '}'
DataSourceKind: state | eventLog | snapshot | cache | replica | objectStore | queue | api | mailer
IndexSpec: entity=ID '.' ( columns+=LooseName | '(' columns+=LooseName (',' columns+=LooseName)* ')' )
use: resolves to a ResourceTarget — a storage, or an api declared in this same system (the typed in-system client, below).
A resource (formerly dataSource — the deployable's dataSources: clause keeps the old name) is the configured binding: it says context for: needs data of role kind:, served by storage use:. A backend deployable lists the resources it wires under dataSources:.
resource ordersState {
for: Orders
kind: state
use: primarySql
schema: "orders" // relational-only: postgres schema namespace
isolationLevel: serializable
}
The kind ↔ storage matrix
checkDataSource rejects a kind on a storage whose type can't serve it (an uncoded validator error — the message is built inline in src/language/validators/datasource.ts, not from the loom.* catalog). The matrix (from the sourceType registry):
| kind | role | compatible storage types |
|---|---|---|
state |
primary state store | postgres, mysql, sqlite, inMemory |
eventLog |
event stream | postgres, mysql, sqlite, inMemory, kafka |
snapshot |
event-sourced snapshot | postgres, mysql, sqlite, inMemory |
cache |
derived cache | redis, inMemory |
replica |
read replica | postgres, mysql, sqlite |
objectStore |
blob storage | s3, localDisk |
queue |
message queue | rabbitmq |
api |
external HTTP API | restApi |
mailer |
outbound email | smtp, ses, sendgrid |
resource bad { for: Orders, kind: state, use: blobs }
// error resource 'bad' kind 'state' is incompatible with storage 'blobs'
// of type 's3'. kind 'state' requires a storage of type
// inMemory, mysql, postgres, or sqlite.
The knobs and their guards
Each optional knob is gated to the kinds / storage types where it's meaningful (all checkDataSource):
| knob | meaning | guard |
|---|---|---|
schema / tablePrefix |
relational namespace / table-name prefix | relational storage only |
keyPrefix |
key-value namespace | redis / inMemory only |
ttl |
cache expiry (seconds) | kind: cache only |
every / retain |
snapshot cadence / retention | kind: eventLog or snapshot |
isolationLevel |
readUncommitted … serializable |
relational, non-cache |
readonly |
read-only binding | — |
shape |
relational | embedded | document saving shape |
— |
index |
manual performance indexes, [Entity.col, Entity.(a, b)] |
kind: state only (loom.resource-index-non-state); unknown entity / column is loom.resource-index-unknown-entity / -unknown-column |
config |
vendor parameters, validated against the sourceType's config schema | per sourceType (loom.config-key-unknown, …) |
Manual indexes
An index is infrastructure, not a domain fact, so it lives on the binding and names its entity explicitly (an aggregate or one of its contained parts — each has its own table). Always non-unique: uniqueness is the domain unique (...) invariant (Invariants).
resource ordersState {
for: Orders, kind: state, use: primary, schema: "orders"
index: [Order.code, Order.(status, code)]
}
-- orders_svc/db/migrations/20260101000000_sales_initial.sql
CREATE INDEX "orders_code_idx" ON "orders"."orders" ("code");
CREATE INDEX "orders_status_code_idx" ON "orders"."orders" ("status", "code");
use: <Api> — the typed in-system client
Bind an api instead of a storage on a kind: api resource and the caller gets a typed client for a sibling deployable — named operations, derived request/response types, and a compose address derived from the servers' service slug + port (no baseUrl is authored). Binding an api on any other kind is loom.resource-api-target-kind; the api must be served by exactly one backend deployable (loom.resource-api-unserved / loom.resource-api-ambiguous-server), and a deployable may not wire a resource pointing at an api it serves itself (loom.resource-api-self-call — call the context in-process). Every backend emits the typed client; the untyped get / post verbs over a storage restApi binding are the escape hatch for an api Loom does not own.
resource orders { for: Shipping, kind: api, use: OrdersApi }
# ship_svc/app/resources/api_clients.py — generated typed client
_orders_base_url = os.environ.get("ORDERS_URL", "http://localhost:3000")
async def orders_create_order(body: object) -> OrderCreated:
async with httpx.AsyncClient(base_url=_orders_base_url) as client:
res = await client.request("POST", "/api/orders", json=body)
# docker-compose.yml — the address is derived, and startup ordered after the callee
ship_svc:
environment:
ORDERS_URL: "http://orders_svc:3000"
depends_on:
orders_svc: { condition: service_healthy }
File fields need an object store
The File primitive is chapter 4; the binding it needs is here. A deployable hosting a File-bearing aggregate must wire a kind: objectStore resource or it is loom.file-field-needs-object-storage. With one wired, the backend emits the store client plus the root-mounted upload/download routes:
// orders_svc/http/index.ts
app.post("/files", async (c) => { /* multipart → ordersFiles$putBytes(key, bytes, contentType) */ });
app.get("/files/:key", async (c) => { /* → ordersFiles$getBytes(key) */ });
// orders_svc/resources/localDisk.ts — the `type: localDisk` client
export const ordersFilesDir = process.env.ORDERS_FILES_URL_DIR ?? path.join(process.cwd(), "data", "ordersFiles");
The state resource above drives the schema-migration owner and the connection wiring for its backend; the objectStore / queue / api / mailer kinds are consumed from workflow bodies via an ambient handle and a closed per-kind verb vocabulary (files.put(…), jobs.enqueue(…), api.get(…), mail.send(to, subject, body)) — that surface is documented in ../resources.md ("Consuming a resource from a workflow"); see also Workflows.
The .loom/datasources.md artefact
generate system emits a derived routing table — every resource's context → storage mapping, plus an unused-storage audit:
### ordersSvc — `platform: node`
| Context | Kind | Resource | Storage | Storage type | Schema | TablePrefix |
| --- | --- | --- | --- | --- | --- | --- |
| Orders | mailer | mail | mailer | smtp | n/a | — |
| Orders | objectStore | ordersFiles | uploads | localDisk | n/a | — |
| Orders | state | ordersState | primary | postgres | orders | — |
## Per storage
| Storage | Type | Used by |
| --- | --- | --- |
| primary | postgres | ordersSvc → Orders (state); shipSvc → Shipping (state) |
| bus | rabbitmq | _unused_ |
channel & channelSource
Channel: 'channel' name=ID '{' ('carries' ':' [EventDecl]+) ('delivery' ':' …)? ('retention' ':' …)? ('key' ':' …)? '}'
ChannelSource: 'channelSource' name=LooseName '{' ('for' ':' channel=ID) ('use' ':' [Storage])? '}'
ChannelDelivery: broadcast | queue
ChannelRetention: ephemeral | log | work
A channel (declared inside a context) is the publisher contract: which events it carries:, the delivery: semantics (broadcast fan-out vs queue competing-consumers), the retention: profile (ephemeral / log / work), and an optional partition key: (which must be a field of every carried event — loom.channel-key-missing-field). A channelSource (system-level) is its physical binding — the messaging twin of resource, mapping the channel for: to a storage that realises it.
context Orders {
event OrderPlaced { orderId: string, total: money }
event OrderShipped { orderId: string }
channel Lifecycle {
carries: OrderPlaced, OrderShipped
delivery: broadcast
retention: ephemeral
key: orderId
}
}
storage bus { type: redis }
channelSource lifecycleBus { for: Lifecycle, use: bus }
Transport compatibility
checkChannels rejects a binding whose storage type can't realise the channel's delivery / retention profile. Two matrices drive it — what a transport could realise, and what a shipped driver does:
| delivery / retention | compatible storage types (CHANNEL_COMPATIBILITY) |
of those, shipped drivers (SHIPPED_COMBOS) |
|---|---|---|
broadcast / ephemeral |
inMemory, redis | redis (plus in-process inMemory) |
broadcast / log |
kafka | kafka |
queue / ephemeral |
redis, rabbitmq | rabbitmq |
queue / work |
rabbitmq, kafka | rabbitmq, kafka |
Three codes gate a binding, in order: a storage that is no transport at all is loom.channelsource-transport-invalid; a transport that can't realise the profile is loom.channelsource-incompatible; a combination the matrix allows but no shipped driver realises (e.g. queue/ephemeral on redis) is loom.channelsource-not-yet-shipped — without that gate the generator would silently fall back to in-process dispatch and quietly break the delivery guarantee.
Wiring a binding onto a deployable
A channelSource is inert until a deployable lists it: channels: [lifecycleBus] provisions the broker, the per-deployable credentials, and the consumer group. The gates:
| Code | Raised when |
|---|---|
loom.channelsource-unbound |
no deployable's channels: lists the binding — declared but inert, events stay on in-process dispatch |
loom.deployable-channel-unrelated |
a deployable lists a binding whose channel it neither produces (it doesn't host the owning context) nor consumes — the wiring routes nothing |
loom.channel-consumer-unwired |
a deployable consumes a carried event but doesn't list the binding another deployable put on the broker — once traffic rides the broker it would silently receive nothing |
loom.relay-target-not-subscribed |
a ui subscribes to a channel whose relay backend neither hosts the owning context nor binds the channel |
storage bus { type: rabbitmq }
channelSource lifecycleBus { for: Lifecycle, use: bus }
deployable ordersSvc { platform: node contexts: [Orders] channels: [lifecycleBus] … }
deployable shipSvc { platform: python contexts: [Shipping] channels: [lifecycleBus] … }
# docker-compose.yml — the wired rabbitmq storage becomes a provisioned broker
ordersSvc: # (service `orders_svc`)
environment:
LOOM_CHANNEL_LIFECYCLE_BUS_URL: "amqp://orders_svc:loom-dev-bus-orders_svc@bus:5672/loom"
bus:
image: rabbitmq:4-management-alpine
volumes:
- ./broker-init/bus.conf:/etc/rabbitmq/conf.d/10-loom.conf:ro
- ./broker-init/bus-definitions.json:/etc/rabbitmq/loom-definitions.json:ro
See ../channels.md for the CloudEvents envelope, the per-broker topology, the outbox relay and broker auth.
A delivery: broadcast channel emits a realtime SSE wire on the backend — events carried by the channel stream to connected browsers at GET /api/realtime/events. This is platform-internal infra (the wire format, SSE vs WebSocket, is derived from the consumer's platform — never stated in the .ddd):
// http/realtime.ts — the carried event set becomes the UI-observable allow-list
export const REALTIME_EVENT_TYPES: ReadonlySet<string> = new Set(["OrderPlaced", "OrderShipped"]);
// realtimeTee wraps the event dispatcher so every dispatched carried event
// also reaches the SSE wire; the endpoint is one long-lived stream per browser.
export function realtimeRoutes(): OpenAPIHono { /* app.get("/events", streamSSE(…)) */ }
Rooms + policy-derived routing (v1 — all SSE backends). In a tenant-owned context the wire scopes delivery by the tenant DataKey (currentUser.tenantId, the equality part of the tenantOwned read filter this.tenantId == currentUser.tenantId) instead of broadcasting to all. The relay's connection registry becomes Map<tenant, Set<Subscriber>>: a connection joins its own tenant room from the verified principal at connect (never a client-supplied room), and a tenant-scoped event (one whose payload references a tenantOwned aggregate) is routed to the emitter's tenant room only — never cross-tenant. When the tenant can't be derived at publish (an event dispatched outside a request — outbox drain / timer), delivery degrades to a refetch ticket ({ type, <id fields> }, no payload) so nothing privileged over-delivers; the authorised read is always the gate. A context with no tenantOwned aggregate keeps the byte-identical v1 broadcast wire. Rooms ship on every SSE backend — node/Hono, .NET, Java, and Python all key subscribers by tenant from the same shared realtimeRoomPlan derivation; Phoenix/LiveView re-renders server-side through the authorised read, so raw payloads never cross the boundary there either. A UI whose target backend can't relay a subscribed channel (it neither hosts the channel's context nor binds it) is rejected with loom.relay-target-not-subscribed.
The .loom/asyncapi.yaml artefact
The channel surface is also published as an AsyncAPI 3.0 document — the messaging analogue of the OpenAPI spec the api emits:
asyncapi: 3.0.0
info:
title: "Shop channels"
version: 0.0.0
channels:
"Orders.Lifecycle":
address: "Orders.Lifecycle"
messages:
"OrderPlaced":
name: "OrderPlaced"
x-loom:
delivery: queue
retention: work
key: "order"
transport: "bus" # the channelSource's bound storage
transportStatus: "declared, not provisioned" # until a deployable lists it in `channels:`
wiredBy: ["ordersSvc", "shipSvc"]
A UI subscribes to a context's broadcast channel with a channel <Handle>: <Context>.<Channel> parameter on its ui block (UiChannelParam); the frontend then refetches through its authorised reads when an event arrives. See ../resources.md for the broader infra model.