The complete reference for writing DevMatrix specifications. From data types and annotations to flows, state machines, and infrastructure.
Overview
DMX (DevMatrix Specification Language) is a declarative language for defining full-stack microservices. A single .dmx file describes entities, APIs, business flows, state machines, security, infrastructure, and more — then compiles deterministically to production-ready code.
DMX is not a template engine. It is a compiler IR: every construct maps to concrete backend code through deterministic passes. There is no interpolation, no scripting, and no runtime interpretation.
Lexical Structure
DMX source files use UTF-8 encoding. The language is whitespace-insensitive (indentation is conventional, not syntactic). Blocks are delimited by curly braces {}.
Comments
Line comments start with //. Block comments use /* ... */.
Comments
// This is a line comment
/* This is a
block comment */
service "UserService" {
version = "1.0.0" // trailing comment
port = 8000
database = "user_service"
}
Strings
Strings are double-quoted only. Backslash escapes are supported: \", \\, \n.
Numbers
Integer literals: 42, 0. Float literals: 3.14. No hex/octal support.
Identifiers
PascalCase for entities and types (UserProfile). snake_case for fields and keywords (created_at). UPPER_SNAKE_CASE for enum values (ACTIVE, PENDING_REVIEW).
Operators
= — assignment. -> — state transition or mapping. * — wildcard/all.
Annotations
Annotations start with @ followed by an identifier. Some accept parenthesized arguments: @default("active"), @fk(User.id).
DMX supports 18 data types that map to database column types and API schema types.
Name
Description
UUID
Universally unique identifier (v4)
String
Variable-length text, up to 255 characters
Text
Unlimited-length text (CLOB/TEXT)
Integer
Whole number (32-bit signed)
Float
IEEE 754 floating-point
Decimal
Arbitrary-precision decimal
Boolean
true or false
Date
Calendar date without time (YYYY-MM-DD)
DateTime
Date and time with timezone (ISO 8601)
Time
Time of day without date
JSON
Arbitrary JSON object or array
Bytes
Binary data (BLOB)
Array
Ordered collection of a single type
Enum
Enumerated set of named values
Char
Fixed-length character string
Point
Geographic point (latitude, longitude)
Polygon
Geographic polygon boundary
Geometry
Arbitrary geometric shape
Every entity field has a name, a type, and optional annotations.
Field declarations with types
entity User {
id UUID @pk @default($computed.uuid)
email String(255) @not_null @unique
name String(200) @not_null @freetext
bio Text
age Integer
balance Decimal(12, 2) @default(0)
is_active Boolean @not_null @default(true)
joined_at DateTime @not_null @default($computed.now)
metadata JSON @default({})
role Enum(ADMIN, MEMBER, GUEST) @not_null @default("MEMBER")
}
Annotations
Annotations modify the behavior of fields, entities, APIs, state machines, and ClickHouse columns.
Field Annotations
Name
Description
Syntax
@pk
Primary key
@pk
@not_null
NOT NULL constraint
@not_null
@unique
Unique constraint
@unique
@nullable
Allows NULL values
@nullable
@default
Default value
@default("value") or @default($computed.now)
@fk
Foreign key reference
@fk(Entity.field)
@check
Check constraint expression
@check("age >= 18")
@min_length
Minimum string / collection length (non-negative integer). Pairs with the max_length that String(N) / Char(N) already imply (#569)
@min_length(8)
@min
Minimum numeric value. SIGNED — negative bounds are legitimate (#569)
@min(0) or @min(-40)
@max
Maximum numeric value. SIGNED — negative bounds are legitimate (#569)
@max(100) or @max(-1)
@required
Required in API input
@required
@sensitive
PII / secrets — excluded from logs
@sensitive
@computed
Server-computed, not in API input
@computed
@json_list
Store as JSON array column
@json_list
@immutable
Cannot be updated after creation
@immutable
@indexed
Create a database index
@indexed
@freetext
Free human text — fixes the injection security class
@freetext
@scoped
ABAC row-filter: binds this column to a principal claim (scoped / scope_match strategy)
@scoped(claim="sub")
@scope_anchor
ABAC scope anchor: when a child has N>1 @fk to the same @scoped parent, marks WHICH FK anchors the inherited transitive row-scope (else first-declared anchors)
@fk(User.id) @scope_anchor
@scope_reverse
ABAC reverse scope marker: on a junction (M2M bridge) entity's parent-@fk column, grants the parent reverse row-visibility to rows reachable through the junction (reverse-EXISTS RLS). Junction (@role(membership)) @fk columns only.
@fk(Org.id) @scope_reverse
@format
OpenAPI format hint (closed vocab: date-time, credit-card, datetime, hostname, duration, binary, email, guid, ipv4, ipv6, phone, regex, uuid, iban, byte, date, time, url, uri, tel). email, uuid and guid are runtime-enforced at the emitted boundary — 422 on malformed input
@format(email)
@pattern
Explicit validation regex
@pattern("^[A-Z]{3}$")
@validation_pattern
Validation pattern type (snapshot, calculation, immutability, constraint, relationship)
@validation_pattern(calculation)
@seed_template
Seed-data value shape (email, personal_name, url, phone, description, currency_code, timestamp)
@seed_template(email)
@field_purpose
Semantic field purpose (closed allowlist: auth_failed_attempts, auth_locked_until, auth_last_login, auth_email_verified, password) — declares a capability-owned auth field (or the credential password) so emitters route on the structural role instead of name-matching
@field_purpose(auth_locked_until)
@otp_code
Marks the field carrying a fixed-format OTP/TOTP code. The validator structurally joins its String(N) length against security { mfa { code_digits = N } } — never a name match on a field called "code" (TWIN-B / INV-208-031)
code String(6) @otp_code
@deprecated
Marks field deprecated (since / until / replacement)
DMX specs are composed of top-level blocks. Each block defines a different concern of the service.
service
The root block that defines a microservice. Contains all other blocks.
Service definition
service "UserService" {
version = "1.0.0"
port = 8000
api_prefix = "/api/v1"
database = "postgresql"
// ... entities, api, flows, etc.
}
identity
Optional nested sub-block inside service { ... } that lets the spec author override canonical-derived service-identity fields. Mirrors the MicroservicesIR sub-IR hierarchy (ServiceIdentityIR / GatewayIR / ArchitectureIR). Each attribute is closed-taxonomy parse-time enforced. When the block is absent the producer derives every field canonically (service_id from the service block name, service_slug + internal_hostname from the canonical lowercase slug, internal_port from the infrastructure block, etc.). When present, every set attribute lands on the corresponding sub-IR overridden_fields frozenset sentinel for cross-build provenance.
All 12 spec-declarable attrs in a single block. Each sub-block keeps the canonical-strict closed taxonomy enforcement (GATEWAY_MODE = passthrough | auth_precheck; ARCHITECTURE_MODE = standalone | microservices | mixed; ARCHITECTURE_MODE_SOURCE = explicit | inferred | default).
Spec authors only pin the attributes they want to override; the rest canonical-derive. Here the platform pins a service version explicitly, leaving every other identity field on its canonical default.
Identity overrides — single attribute
service "AuthService" {
version = "1.0.0"
port = 8000
api_prefix = "/api"
database = "auth_service"
identity {
service_version = "2.4.1"
}
}
entity
Defines a data model. Fields have a name, type, and optional annotations. Entities compile to database tables, ORM models, and Pydantic schemas.
Entity with annotations
entity Order {
@aggregate_root
@description("Customer order")
@audit(level: standard)
@soft_delete
@role(domain)
id UUID @pk @default($computed.uuid)
customer_id UUID @fk(Customer.id) @not_null
total Decimal(12, 2) @not_null
status Enum(DRAFT, PENDING, PAID, SHIPPED, CANCELLED) @not_null @default("DRAFT")
notes Text
created_at DateTime @not_null @default($computed.now)
@unique_together(customer_id, created_at)
}
api
Defines REST, GraphQL, or WebSocket endpoints for an entity. Route-level RBAC via permissions = ["resource:action"] is enforced end-to-end — the compiler emits the permission check in the generated service (ADR-354), not advisory metadata. Each key is resource:action (2+ non-empty colon-separated segments).
Per-endpoint attributes. Beside permissions, an endpoint body takes:
- operation_id = "..." — the stable name this operation carries into the generated OpenAPI document and the clients built from it. Set it when the generated name would otherwise change with the route.
- tags = [...] — OpenAPI tags, which is how the generated docs group the operation.
- auth_required = — whether the route demands an authenticated caller at all. It is the coarser question than permissions: this decides *whether anyone is asked*, permissions decides *who passes*.
Path addressing. A route with placeholders says *where* to look but never *by what*. The addressing { } block answers that, binding each HTTP path placeholder to the one entity column it addresses — addressing { = . }. Three properties are the point of declaring it rather than inferring it:
- Spelling never binds./widgets/{key} with no addressing block produces no binding at all, and neither does a placeholder that happens to be named after a column. A route addressed by a column the compiler guessed is a route that silently changes meaning the day the column is renamed.
- Declaration order is the order. Bindings carry the order they are written in, which is independent of the order the placeholders appear in the path — so a composite key is read as declared and not as the URL happens to spell it.
- The target must be a real key. The complete binding has to be exactly a declared primary or business key of that entity; anything else is rejected rather than lowered into a lookup that could match more than one row.
WebSocket endpoints are declared by is_websocket = true, not by a protocol string — the flag is what the IR carries and what the emitters and gates read. Three attributes only mean anything alongside it:
- subscription_params = [a, b] — the identifiers a client may subscribe by.
- heartbeat_interval = — seconds between keepalives.
- max_connections = — the ceiling on concurrent sockets for this endpoint.
REST API
api {
POST "/orders/{id}/cancel" {
operation_id = "cancel_order"
permissions = ["orders:manage"]
tags = ["orders"]
response @entity(Order)
}
GET "/orders/summary" {
operation_id = "order_summary"
permissions = ["orders:read"]
query {
from Date
to Date
}
response @paginated(Order)
}
}
Each placeholder bound to the column it addresses. The second route is keyed by a composite: the bindings are read in the order they are declared, not the order the path spells them.
Path addressing
api {
GET "/widgets/{opaque}" {
operation_id = "fetch_widget"
addressing { opaque = Widget.arbitrary_label }
response @entity(Widget)
}
GET "/widgets/{first}/{second}" {
operation_id = "fetch_widget_scoped"
addressing {
second = Widget.local_ref
first = Widget.tenant_ref
}
response @entity(Widget)
}
}
flow
Multi-step business process. Each step has an action, target entity, and field mappings. Flows compile to service-layer functions with transaction management. A flow must have either at least one step OR an intentional_stub = "reason" declaration (not both): a stepless intentional_stub declares an endpoint that is deliberately unimplemented (compiles to a declared NotImplementedError instead of a silent 501).
Step attributes beyond action / target / mapping. A step body is one production, so the grammar accepts all of them on any step; which ones MEAN anything is decided by action, and the validator is what enforces that pairing:
- query_field = "..." and query_value = — the single-field lookup a query step runs. query_value takes a literal or a $-token, so a step can look up by something the request carried.
- result_field = "..." — which field of the step's result is kept. Distinct from result_capture, which names the whole result for later steps to refer to.
- tx_policy — how the step joins the surrounding transaction: join (the default — run inside it), new (its own transaction), none (outside one).
- target_service = "..." — for an external_call step, which service is called. The cross-service call is emitted with auth, timeout and retry; a step that reaches another service's data any other way is a defect, not a shortcut.
- output_format — required by an export step: csv, json or pdf. The grammar accepts it anywhere; the export pass is what requires it where it belongs.
- source_collection = $token — the array a foreach step iterates, when the source is a $-token rather than a query. Its alternative is a source_query { } block — target = , an optional order_by = , and an optional filter { } — and the two are discriminated structurally, not by a mode flag. A source_query without order_by still iterates in a fixed order: the compiler falls back to the target's primary key and always emits a stable ordering, so the same input produces the same output. Inside the do { } body each element binds to $item, which is scoped to that body — a $item outside one is a validate error, not a runtime surprise.
Registration flow
flow "Register User" {
type = workflow
trigger = http(method="POST", path="/users")
entity = User
step 1 "Validate input" {
action = validate
condition = "$input.email is unique within tenant"
error = "email already registered"
}
step 2 "Create user" {
action = create
target = User
mapping {
email = $input.email
name = $input.name
created_at = $computed.now
}
}
}
state_machine
Defines valid state transitions and role restrictions. Compiles to a state machine implementation with validation. initial names the state a new row starts in. terminal = [...] lists the states nothing may transition out of. sensitive = [...] lists the states whose INBOUND transitions require an elevated role — declare them and the compiler injects the guard, instead of inferring sensitivity from the state's name.
⚠️ @guard() { condition = "..." } — NOT LOWERED TODAY. The grammar accepts it (sm_guard, grammar.lark:1021) and the parser reads both condition and error, but the transformer collects them into a local that it never returns, and StateMachineIR has no field to receive them. Measured 2026-07-28 end-to-end: a spec whose state machine declares @guard(payment_confirmed) { condition = "$instance.paid_at != null" } produces an ApplicationIR containing the state machine and not one of the guard's strings. What DOES reach the IR from this block is @roles(...) on a transition, sensitive = [...] and terminal = [...] — so role-gated and sensitive-state transitions are enforced; a CONDITION on a transition is not. Declare it if you want the intent recorded in the spec, but do not rely on it to hold: today nothing downstream reads it.
Validation rules applied to entity fields. Compile to both database constraints and API-level validation.
A rule is keyed either by entity (User { }) or by field (User.email { }), and type names WHICH KIND of rule it is — format, range, presence, uniqueness, relationship, stock_constraint, status_transition, workflow_constraint or custom. It is a bare token, not a quoted string, and it is not the same axis as the check itself: type = format says what kind of rule this is, format = "..." carries the pattern.
severity is a closed taxonomy of exactly two members, error and warning. There is no third level: a rule cannot be graded finer than those two, which is the part of the contract this page can state. What a non-blocking rule DOES at runtime is not declared anywhere in the language, so this surface does not tell you — saying it would be prose no check could defend. enforcement is a separate axis again: it says WHERE the rule is applied (validator, computed_field, immutable, state_machine, business_logic, description), not how loudly it complains.
Validation rules
validations {
User.email {
type = format
format = "^[^@]+@[^@]+$"
error = "Invalid email address"
}
User.username {
type = uniqueness
enforcement = validator
severity = error
error = "Username already taken"
}
User.bio {
type = format
format = "^.{0,500}$"
severity = warning
error = "Bio is longer than we display"
}
User.age {
type = range
range = "0..150"
error = "Age must be between 0 and 150"
}
}
security
Authentication, authorization, rate limiting, and password policies.
Beyond the attributes in the example below:
- refresh_token_rotation = — issue a NEW refresh token on every use and invalidate the old one. It is what turns a stolen refresh token into a detectable event instead of a standing key; refresh_token_enabled alone only decides that refresh tokens exist.
- token_revocation = — check a revocation list when validating, so a session can be ended before its expiry rather than at it.
- microservice_mode = and auth_service = "" — this service does not authenticate on its own; the named module does, and this one trusts and verifies what it issues. Declaring the mode without the module leaves the service expecting an authority nobody appointed.
- max_request_body — the largest request body accepted, as a size string. It belongs here rather than in a proxy config because the generated service enforces it: a limit that lives only in front of the service disappears the moment anything reaches the service another way.
security also carries four sub-blocks, and their attributes are not interchangeable with the platform-scope surfaces that share their names.rate_limit { } — service scope, and it throttles LOGIN, not traffic. Three attributes, all of them about credential guessing:
- login_attempts = — failed attempts before the account locks.
- lockout_duration = "..." — how long the lock holds. Without it a threshold is only a speed bump: the attacker waits out nothing and continues.
- requests_per_minute = — the per-caller ceiling on this service.
This is a DIFFERENT block from the platform-scope rate_limits { } (which takes per_tenant / per_ip / per_route policies) and from an adapter's rate_limit_policy { }. Three surfaces, three spellings, three scopes — and an attribute from one does not parse in another.
sessions { } — whether a user can see and end their own sessions. enabled turns session tracking on; list_endpoint = true and revoke_endpoint = true each generate one route. They are separate on purpose: listing sessions without being able to revoke them shows a user a compromise they cannot act on.
token_domains { { ... } } (ADR-181) — more than one JWT audience inside one service, each with its own key and its own slice of the URL space. Per domain: issuer, audience, algorithm, token_expiry_minutes, claims = { ... }, plus
- secret_env_var = "..." — the environment variable holding the signing secret. A reference, never the secret: the value stays out of the spec and out of git.
- route_prefixes = [...] — the paths this domain governs. It is what makes the domains actually separate; two domains whose prefixes overlap are two keys accepted on the same route, which is one key with extra steps.
policy "" { } is the fourth, and its body uses a different syntax from everything above — entity = plus allow [] for [], positional and without =. It is the one place in security where an attribute-shaped line is wrong.
`policy "<name>" { }` is how an author declares who may do what: `allow [<roles>] for [<actions>]`, optionally scoped to an entity with `entity = <Entity>` and conditioned with `when <predicate>(<arg>)`. Grammar: `policy_block` at `grammar.lark:1313`, inside `security { }` — the parser accepts it and the transformer lowers it into `SecurityModelIR.policies`. Emitted endpoint enforcement remains independently governed by endpoint bindings and its security gates. The syntax below is derived from the grammar, not copied from any spec.
policy { } — declared access control
security {
auth_scheme = jwt
roles = [ADMIN, MANAGER, MEMBER]
policy "shipment_write" {
entity = Shipment
allow [ADMIN, MANAGER] for [create, update]
allow [MEMBER] for [read] when owns(shipment_id)
}
}
The `mfa { }` sub-block declares the platform's TOTP capability. `method` is a closed taxonomy (totp / sms / email / hardware_key); `algorithm` is SHA1 / SHA256 / SHA512. `config_entity` / `enabled_field` / `secret_field` name the entity + fields that persist per-user MFA state (structural, never name-matched). The `@otp_code` field annotation marks the verify-code field; the validator joins its `String(N)` length against `code_digits = N`. Lowers to `SecurityModelIR.totp_ir` so the TOTP emitter fires and G_MFA becomes red-able by construction.
When the password hash lives on a separate credential entity carrying a credential-kind enum, `credential_type_field` names that enum field and `password_credential_kind` names the member that denotes a password credential. Both keys are optional; omitting them is byte-equal.
Custom error definitions with HTTP status codes, categories, and messages.
Error definitions
errors {
USER_NOT_FOUND {
status = 404
category = not_found
message = "User not found"
entity = User
field = id
}
INVALID_CREDENTIALS {
status = 401
category = auth
message = "Invalid email or password"
}
}
jobs
Platform-scope scheduled jobs (ADR-260) — declared inside platform { jobs { ... } }; the per-service top-level jobs block was retired. Scheduling, retry policies, and queue routing.
notifications {
providers {
email { adapter = smtp }
sms { adapter = twilio }
push { adapter = firebase }
}
event "order_confirmed" {
entity = Order
channels = [email, push]
recipient = entity_field("customer.email")
// The string after `template` is the CHANNEL this body belongs to, not a
// template name: the compiler derives the name itself, as
// <event>__<channel>__<locale>. One template per channel the event declares.
template "email" {
locale = "es"
subject = "Your order is confirmed"
body = "Order {{ id }} is on its way."
}
template "push" {
locale = "es"
subject = "Order confirmed"
body = "Order {{ id }} is on its way."
}
}
}
file_storage
File upload configuration with size limits and type restrictions.
⚠️ cdn_enabled = true cannot be satisfied from DMX today. The block takes exactly three attributes — virus_scan, cdn_enabled, and bucket "..." { } (file_storage_attr, grammar.lark:1382). FileStorageIR raises cdn_domain is required when cdn_enabled is True at construction (file_storage_ir.py:301), and cdn_domain appears nowhere in the grammar — measured 2026-07-28, zero occurrences. So turning the CDN on HALTS the spec and there is nothing the author can add to fix it: the required companion has no declaration surface. Reported; until it has one, the example below leaves the flag off rather than teaching a spec that cannot compile.
Docker, Redis, RabbitMQ, Elasticsearch, Celery, API gateway, CORS, and deployment configuration.
redis { cache { ... } } — ttl is the default lifetime of a cached entry and the only thing standing between a cache and permanently stale data; namespace keeps two services sharing a Redis from colliding on the same key. pool_max_connections and pool_min_idle size the connection pool: the ceiling is what a traffic spike cannot exceed, the floor is how many connections stay warm so the first request after a quiet period does not pay a handshake.
rabbitmq { ... } — the connection is declared as ENV VAR NAMES, never values: host_env, port_env, user_env, password_env. protocol_version pins the AMQP version, so a broker upgrade cannot silently move the wire format under a running service.
Per-queue (queue "" { ... }), the three below decide what survives:
- exclusive = true — the queue belongs to ONE connection and is deleted when that connection drops. Useful for a short-lived per-connection queue, wrong for a work queue: it makes the queue as durable as the process that opened it.
- auto_delete = true — deleted once its last consumer goes away. Combined with durable = true it reads as a contradiction, and the auto-delete wins: surviving a broker restart does not help a queue that vanished when the consumer redeployed.
- message_ttl_ms — how long a message may sit unconsumed before the broker drops it (or routes it to dead_letter_exchange). Without a DLX declared, this is silent data loss on a timer.
elasticsearch { auth { ... } } — method picks the scheme and the rest name the ENV VARS that carry the credential: basic_auth_user_env + basic_auth_password_env, or bearer_token_env, or api_key_env. The spec names the variable, never the secret.
api_gateway { ... } — gateway_type picks the gateway product. ⚠️ type and gateway_type are TWO SPELLINGS OF THE SAME ATTRIBUTE — the grammar routes both to the same production — so a spec using one and a spec using the other are saying the same thing, and neither is more correct than the other.
- jwt_claims_to_verify = [...] — which claims the gateway checks before the request ever reaches a service. A claim absent from this list is not verified at the edge no matter what the token carries.
- jwt_max_expiration — the longest token lifetime the gateway will accept. It is the gateway's defence against a token minted with a generous lifetime elsewhere; the issuer's own expiry claim is a promise, this is an enforcement.
- rate_limiting { per_minute / per_hour / per_day } — three independent windows, not one expressed three ways. per_minute bounds a short spike, per_day bounds a quota, and declaring only the minute leaves a caller free to spend all day at the limit.
Multi-service platform orchestration. Declares modules with their compile-time dependencies; the compiler derives topological build order automatically.
Platform intent.description, purpose = "..." and principles = ["...", "..."] carry what the platform is FOR and the rules it holds itself to. principles is an array of free strings, not a taxonomy — the compiler does not enforce them, which is precisely why they are worth writing where the spec lives instead of in a document nobody opens next to the code. They are the platform's own statement of what a change must not break.
Per-module attributes. Beyond spec and dependencies, each module inside modules { } may declare who answers for it and how it is grouped:
- owner = "..." and contact = "..." — the team that answers for the module and the address to reach it. They travel into the emitted service so a page that fires at 3am names a destination instead of a repository.
- billing_tag = "..." — the tag the module's cost is attributed under. It is the only way a per-service cost split exists at all: without it every module's spend lands in one undivided total.
- sla { response_time_p99_ms, rps, criticality } — the module's declared service level. response_time_p99_ms is a p99 and not an average on purpose: an average hides the tail that users actually notice. rps is the load it is sized for, and criticality is what decides whose page fires first when several modules degrade at once — without it, every module is equally urgent, which is the same as none being urgent.
- bounded_context = "..." — the semantic boundary the module belongs to. This is intent, not topology: two modules may sit in the same bounded context and still be deployed apart, and the compiler will not infer it from dependencies.
- exclude_kernel_components = [...] — shared-kernel components this module opts OUT of.
The kernel set every module receives is decided in two places, and both only ever SUBTRACT. shared_kernel { exclusions = [...] } drops components platform-wide — a name listed there is gone from every module, including modules that never mention it — and each module's exclude_kernel_components drops more, for that module alone. The two lists are unioned, never differenced: a module cannot bring back a component the platform excluded by leaving it off its own list.
shared_kernel (what every module gets, and the floor it needs)
A platform-scope block naming the modules every service inherits — errors, jwt_utils, base_repository and the rest of a closed set the grammar enforces at parse time, so a name that is not a real kernel module does not parse rather than resolving to nothing later.
- modules — what every service gets.
- exclusions — what is held back, by bare identifier.
- python_requires = "..." — the interpreter floor the shared kernel needs, written the way a package does it (">=3.12"). It belongs to the KERNEL and not to a module: everything inherits the kernel, so its floor is the platform's floor, and there is nowhere else in the language to say it once.
As with every block on this page, this documents the LANGUAGE — what the compiler parses. Whether a declaration here changes the emitted output is a separate axis this surface does not measure and does not claim.
Module names are a closed set enforced by the grammar; the version floor is a string.
A WebSocket endpoint declares its messages. Marking an endpoint is_websocket = true opens a message_types { } block, and each entry is a QUOTED message name carrying payload_fields, a list of bare identifiers. So the wire vocabulary of the socket is declared the same way a REST body is — a client is not left to discover the message names by watching traffic. The endpoint also takes subscription_params, heartbeat_interval and max_connections beside it.
default_level (in a service's audit { }) is a closed taxonomy of five: none, minimal, standard, full, debug. It sets the level for the service; an entity's own @audit(level: ...) is the per-entity override. Two places, one taxonomy — and the service-level default is what applies to everything that does not override it, which is usually most of the model.
storage_model (in tenant_config { }) is the one attribute in that block that is not a setting definition: it declares HOW tenant settings are stored ("dedicated_table" and friends), while every other entry in the block is a named setting with its own data_type, default and optional platform_minimum / platform_maximum. It reads like one more setting and is the rule the rest are stored under.
As with every block on this page, this documents the LANGUAGE — what the compiler parses. Whether a declaration here changes the emitted output is a separate axis this surface does not measure and does not claim.
Message names are quoted; `payload_fields` are bare identifiers.
A declared socket vocabulary, an audit default, and tenant storage
health_check · test_database · partition_by (shaping the run, not the code)
Three attributes that do not change what the service DOES, and change whether it behaves under load, under test, and over time.
- health_check { interval, timeout, retries } sits inside docker { }. interval and timeout are duration STRINGS ("30s", "5s"); retries is a plain integer. The three multiply: with 30s and 3 retries a container is not declared unhealthy for about a minute and a half, which is the number worth knowing before an orchestrator restarts something mid-request.
- test_database { name = "..." } is its own top-level entry in infrastructure { }, a SIBLING of database { } and not a field inside it. It is the only way to say that tests must not run against the same database as the service — declaring it is what makes that separation part of the spec instead of a convention in someone's shell.
- partition_by belongs to a ClickHouse table, alongside engine, order_by and ttl. It takes an EXPRESSION as a quoted string ("toYYYYMM(occurred_at)"), not a column list — which is what separates it from order_by, its neighbour, which takes bare identifiers. The two are different axes: order_by decides how rows are sorted inside a part, partition_by decides which part they land in, and only the second one is what a ttl can drop wholesale.
As with every block on this page, this documents the LANGUAGE — what the compiler parses. Whether a declaration here changes the emitted output is a separate axis this surface does not measure and does not claim.
`test_database` is a sibling of `database`; `partition_by` takes an expression while its neighbour `order_by` takes identifiers.
Health probe timings, a separate test database, and a partitioned table
acks_late · celery_worker · internal (what happens when something dies)
Three booleans that look like tuning and are not — each one decides what survives a failure.
- acks_late (in the platform's celery { }) says WHEN a task is acknowledged. Acknowledging late means the broker is told the task is done only after it actually ran, so a worker that dies mid-task leaves the message on the queue and someone picks it up again. Acknowledging early means the message is gone the moment it is handed over, and a worker crash loses it silently. The cost of true is that a task can run twice — which is why it belongs next to idempotent on the interaction, not on its own.
- celery_worker (inside a module's output { }, beside generate_tests and generate_frontend) decides whether that module gets a worker at all. It is a per-module switch: a platform can declare celery once and still emit workers for only the modules that need one.
- internal (on a rabbitmq { exchange "..." { } }) marks an exchange that publishers cannot post to directly — it only receives from other exchanges. It is how a fan-out stays an implementation detail: clients publish to the public exchange, and the internal one cannot be reached even by a client that learns its name.
As with every block on this page, this documents the LANGUAGE — what the compiler parses. Whether a declaration here changes the emitted output is a separate axis this surface does not measure and does not claim.
`output { }` is per module; `celery { }` is declared once for the platform.
Late acks, and a worker only for the module that needs one
request_params · reply · integration_ref (asking something and waiting)
Most cross-service interactions are one-way: a producer emits, a consumer reacts, nobody waits. A request-reply interaction is the one that waits, and it needs two things a fire-and-forget edge never declares — what goes out and what must come back.
- request_params { } declares the parameters of the outbound call, and each one carries WHERE it travels: @path, @query or @body, plus @required. This is the part worth reading closely — the location is declared, never inferred from the name. order_id UUID @path @required and coupon String @body are the same shape of line and end up in different parts of the request.
- reply { } is a nested block that takes the SAME attributes as the interaction itself, so the answer declares its own response_schema and its own timeout_ms. Its sibling request { } works the same way. The two halves are described separately because they can differ: the outbound side may be idempotent while the answer is what actually carries the deadline.
Without a reply, response_schema and timeout_ms on the interaction describe a call whose answer nobody declared a shape for — which parses, and is usually not what was meant.
integration_ref solves a different problem, on flows. A top-level integration "name" { } declares an outside system once — its type, its provider, its required_env_vars, what happens when it is down (fallback_behavior). A flow then BINDS to it by name with integration_ref = "name". The reference is resolved during compilation into the flow's action, so the emitted handler does not look the integration up by string at emission time: the binding is a declaration, not a lookup.
As with every block on this page, this documents the LANGUAGE — what the compiler parses. Whether a declaration here changes the emitted output is a separate axis this surface does not measure and does not claim.
`integration_ref` names the declaration; it does not repeat its contents.
An integration declared once, bound by name from a flow
integration "stripe_payments" {
type = payment_service
provider = "stripe"
version = "2024-06-20"
fallback_behavior = fail_fast
is_critical = true
required_env_vars = ["STRIPE_SECRET_KEY", "STRIPE_WEBHOOK_SECRET"]
}
flow "Charge Order" {
type = workflow
trigger = http(method="POST", path="/orders/{id}/pay")
entity = Order
integration_ref = "stripe_payments"
step 1 "Charge the card" {
action = create
target = Order
}
}
Each parameter declares WHERE it travels. `reply { }` carries the answer's own schema and deadline.
retention · on_delete · buckets (the same word, different blocks)
Three blocks are spelled retention and they are not the same block. Each lives at a different scope and takes a different attribute set, so copying one into another's place does not parse:
- file_storage { bucket "..." { retention { } } } — how long a BUCKET keeps its objects. Takes days, regulatory_basis = "..." (the rule that obliges the window — it is a citation, not a description, and it is what makes a retention window auditable instead of arbitrary) and allow_overwrite. That last one is the one to read twice: false means an object at a key cannot be replaced for the whole window, which is what write-once evidence requires and what makes a correction a new key rather than an edit.
- platform { retention { module X { } } } — how long a MODULE's data is kept. Takes days, on_delete = "..." (what happens when the window closes) and storage_class = "...", a quoted string.
- object_storage { document_type X { retention_days = N } } — not a block at all here, a plain integer attribute.
on_delete also has two homes with two syntaxes, which is the easier of the two mistakes to make:
- On a foreign key it is an ARGUMENT with a colon and a bare word: @fk(Order.id, on_delete: cascade).
- In platform retention it is an ATTRIBUTE with an equals and a quoted string: on_delete = "archive".
The same split hits the storage class. storage_class (platform retention) takes a QUOTED string; to_storage_class, inside an object_storage lifecycle, takes a BARE identifier. And a lifecycle transition is a single construct that requires BOTH keys, in that order — after_days = N to_storage_class = CLASS on one line. to_storage_class cannot be written on its own, and the two halves cannot be swapped.
Object storage has TWO lifecycle surfaces, and only one of them can delete. The service-scope lifecycle { } shown above only moves objects between classes. The platform-scope one is a lifecycle_rule { } block per rule, with rule_kind naming which of the two things it is — transition (move to storage_class after after_days) or expiration (delete after after_days, and no storage class, because there is nowhere to move to). A bucket declares one lifecycle_rule per rule, so tiering and deletion are separate declarations rather than one entry with an optional half. If a retention window must actually END, expiration is the only thing that says so — the service-scope lifecycle cannot express it.
buckets is a false friend. It has nothing to do with storage: it is the explicit bucket boundaries of a histogram metric, inside service_observability { metrics { } }. Object storage spells its own container bucket (singular, a block with a label); the plural is always the histogram.
As with every block on this page, this documents the LANGUAGE — what the compiler parses. Whether a declaration here changes the emitted output is a separate axis this surface does not measure and does not claim.
Service scope. Note `on_delete:` with a colon on the FK, and `buckets` belonging to a metric rather than to storage.
Bucket retention, object lifecycle, and histogram buckets
Twenty-seven attributes across the infrastructure { } blocks end in _env, and every one of them follows the same rule: the value is the NAME of an environment variable, never the secret itself. db_password_env = "ORDER_SERVICE_DB_PASSWORD" does not declare a password — it declares which variable the running service will read one from. Writing the literal password there would put a credential in the spec, which is exactly what the suffix exists to prevent.
The convention is uniform, so once it is read once it holds everywhere: bootstrap_servers_env, sasl_user_env, sasl_password_env, api_key_env, client_secret_env, hosts_env, url_env and the rest all name variables.
keystore and truststore are not two words for the same file. Both appear as a path/password pair, in kafka { } and again in elasticsearch { }:
- tls_keystore_path_env / tls_keystore_password_env — the identity THIS service PRESENTS. The keystore holds our own certificate and private key; it is what makes mutual TLS mutual. Declaring only this side means we prove who we are and accept whoever answers.
- tls_truststore_path_env / tls_truststore_password_env — the authorities this service ACCEPTS. The truststore holds the CA certificates a peer must chain to. Declaring only this side means we verify the broker and stay anonymous ourselves.
Mixing them up is the common mistake and it fails asymmetrically: a wrong truststore is refused at handshake, while a missing keystore is only refused when the peer actually asks for a client certificate — which may be in production and not in staging.
use_ssl (in redis { }) is a plain boolean and sits at the block's own level, a SIBLING of cache { } / broker { } / pubsub { } rather than inside one of them — it is a property of the connection, not of a role.
retention_bytes (in a kafka { topic "..." { } }) is the size-based companion to retention_ms: a cap in bytes per partition. The two are independent and either may be omitted; a topic that declares both discards a segment when it crosses EITHER limit, whichever comes first.
As with every block on this page, this documents the LANGUAGE — what the compiler parses. Whether a declaration here changes the emitted output is a separate axis this surface does not measure and does not claim.
Every secret is referenced by variable NAME. The keystore pair is what we present; the truststore pair is what we accept.
Env-bound credentials, mutual TLS, and topic retention
infrastructure {
docker {
compose_version = "3.8"
base_image = "python:3.12-slim"
db_password_env = "ORDER_SERVICE_DB_PASSWORD"
}
redis {
use_ssl = true
pool_size = 10
cache { enabled = true }
}
kafka {
bootstrap_servers_env = "KAFKA_BOOTSTRAP_SERVERS"
security_protocol = "SSL"
// what we PRESENT — our own cert + key
tls_keystore_path_env = "KAFKA_TLS_KEYSTORE_PATH"
tls_keystore_password_env = "KAFKA_TLS_KEYSTORE_PASSWORD"
// what we ACCEPT — the CAs a broker must chain to
tls_truststore_path_env = "KAFKA_TLS_TRUSTSTORE_PATH"
tls_truststore_password_env = "KAFKA_TLS_TRUSTSTORE_PASSWORD"
consumer_group_id = "order_service.consumer"
topic "orders" {
partitions = 6
replication_factor = 3
retention_ms = 604800000
retention_bytes = 10737418240
}
}
}
depends_on · imports · exports (module boundary)
How a module says what it needs and what it offers. Two surfaces declare this, at two scopes, and imports means something different in each — the same word is a KEYWORD in one and a BLOCK OPENER in the other.
- depends_on { } — inside service { }. Each entry is a module name followed by the keyword imports and the symbols taken from it: UserService imports [User, Role]. The keyword sits BETWEEN the name and the array. optional_imports is the same shape for a dependency the service can run without.
- imports { } — inside a modules { X { } } entry. Here imports opens a block, and inside it the plain entry drops the keyword entirely: UserService [User, Role], name then array, nothing between. The optional form is the exception that KEEPS its keyword: UserService optional_imports [Audit]. So the asymmetry survives inside the block — required entries are bare, optional ones are labelled.
- exports { } — inside the same module entry. Only two entry kinds exist, and they do not take the same literal: entities [User, Role] takes bare identifiers, services ["user-api"] takes strings. Exporting an entity and exporting a service are different declarations, not one list with two spellings.
dependencies and imports are not alternatives. A module entry may carry dependencies = [UserService] — which orders the build — AND an imports { } block, which names the symbols crossing the boundary. The first answers *what must compile first*; the second answers *what is actually taken*. Declaring the dependency does not declare the import, and a module that lists a dependency it imports nothing from is telling the compiler to serialise a build for no reason.
Inside service { }: the module name, then the keyword, then the array.
Service scope — `imports` is a keyword
service "OrderService" {
version = "1.0.0"
database = "order_service"
depends_on {
UserService imports [User, Role]
AuditService optional_imports [AuditEntry]
}
}
Inside platform { modules { ... } }: no keyword on the required entry; `optional_imports` keeps its own. `exports` separates entities (identifiers) from services (strings).
Module scope — `imports` is a block, and required entries are bare
A module's declared testing commitment, written inside a modules { X { } } entry. Four attributes, and three of them do not take a number:
- integration, e2e, contract — each takes the string "required" or "optional", naming whether that suite is expected for the module. They are a commitment, not a measurement. The plausible reading — a percentage like "80%" — is wrong: these say WHETHER the suite is owed, never how much of it exists.
- target_branch_coverage — the one number in the block, a float (0.85, not "85%" and not 85). It is the branch-coverage target the module is aiming at, which is a different question from whether a suite is owed at all.
So the block answers two questions with two shapes: *which suites does this module owe* (three strings) and *how deep should the unit tests cut* (one float). Mixing them — writing e2e = 0.8 or target_branch_coverage = "required" — does not parse.
As with every block on this page, this documents the LANGUAGE — what the compiler parses. Whether a declaration here changes the emitted output is a separate axis this surface does not measure and does not claim.
Three strings say which suites are owed; the float is the branch target.
Platform-scope ingress/egress for non-REST partner traffic, declared inside platform { integration_gateway { ... } }. Five bodies, all optional and repeatable: inbound_endpoints { endpoint "" { path, protocol, target_capability } } — where protocol is a closed taxonomy (csv-rfc4180 / fhir-r4 / hl7v2-2.5 / https) and each endpoint may carry a signature_validation { kind, secret_ref } sub-block; outbound_dispatch { "" = "" } mapping entries; parsers = [ ... ]; dead_letter_queue { backend, stream_key_template, table_name, max_depth_threshold }; and retry_orchestration_policy { drain_interval_seconds, max_redrives_per_message, backoff_factor }.
integration (service-scope · ADR-317)
How a service talks to something outside the platform. integration "" { ... } declares its type — a closed taxonomy of webhook_receiver / api_client / oauth_flow — plus provider, version, documentation_url, whether it is_critical, the required_env_vars it needs, the depends_on_entities it touches, and a fallback_behavior (log_and_continue / fail_fast / queue_retry) saying what happens when the far side does not answer. Provider names are free strings on purpose: the compiler enumerates no catalogue of vendors. One of three sub-blocks carries the transport detail — webhook { ... }, oauth { ... }, api_client { ... }.
Cross-origin policy at the platform edge: allowed_origins, allowed_methods, allowed_headers, exposed_headers (arrays), allow_credentials (boolean), and max_age_seconds. allow_credentials together with a wildcard origin is the combination browsers refuse — declare the origins you mean.
THERE IS A SECOND cors BLOCK AND EVERY NAME IS DIFFERENT. Inside infrastructure { api_gateway { cors { ... } } } the same five concepts are spelled with different keywords, and none of them is a synonym the parser accepts — each is valid in one block and a parse error in the other:
| what it sets | platform { cors } | infrastructure { api_gateway { cors } } |
| --- | --- | --- |
| origins | allowed_origins | allow_origins |
| methods | allowed_methods | allow_methods |
| headers | allowed_headers | allow_headers |
| credentials | allow_credentials | credentials |
| cache lifetime | max_age_seconds | max_age |
| exposed headers | exposed_headers | — |
Five near-misses is worse than five unrelated names: a wrong one here does not look wrong when you read it back. Check which block you are inside before checking the spelling.
output (platform-scope)
Where and what the compiler writes: base_dir is the output root, timestamp_prefix puts each run in its own directory, and the four booleans generate_frontend, generate_portal_apis, generate_tests, run_tests select what the build produces. Frontend generation is not production-ready today.
validation (platform-scope)
Which platform-assembly checks are enforced, all booleans: check_circular_deps (a cycle in the module graph), validate_exports and require_all_imports (a module importing what no module offers), fail_on_missing_spec (a modules row pointing at a spec that is not there), and strict_mode. Distinct from the service-scope validations { } block, which constrains DATA — this one constrains the platform's own wiring.
jwt_domains (platform-scope · ADR-255 / ADR-256)
Multi-domain JWT configuration, declared inside platform { jwt_domains { ... } }. Each domain "" { ... } row declares issuer, audience, algorithm, and its key material by reference — secret_env, secret_ref, private_key_ref, public_key_ref, or jwks_uri — plus path_scope (the request paths the domain governs) and the lifetimes token_lifetime_minutes, refresh_token_lifetime_days, key_rotation_days. Keys are named, never inlined: the DSL declares a reference and the secret stays out of the spec.
authority (platform-scope)
Binds the platform to an external authority spec pack: spec_pack names the pack, binding and enforcement are identifiers (not strings) selecting how it attaches and how strictly it is enforced, and coverage lists what it covers.
edge_agents (platform-scope · ADR-255 / ADR-260)
Declares the edge agent fleet inside platform { edge_agents { ... } }. Each agent "" { ... } carries deployment_target, runtime, local_store, the protocols it speaks, its sync_endpoint and config_update_channel, the cadences heartbeat_interval_s and telemetry_emission_rate, and its offline_buffer_policy — what the agent does with data it cannot ship yet.
rate_limits (platform-scope)
Platform-scope rate limiting. Three policy shapes, each taking the same three attributes limit, window_seconds, burst_capacity: per_tenant { ... }, per_ip { ... }, and per_route "" { ... } for a single route. The per-service rate_limit block is a different surface; this one governs the platform edge.
mtls (platform-scope)
Mutual TLS material for platform-to-platform traffic, by reference only: client_cert_ref, client_key_ref, ca_bundle_ref. No certificate or key is ever written into a spec.
security_headers (platform-scope)
HTTP response hardening at the platform edge: hsts_max_age_seconds, hsts_include_subdomains, hsts_preload, frame_options, content_type_options_nosniff, referrer_policy, content_security_policy. The three HSTS flags and the nosniff switch are booleans; the rest are strings carrying the header value verbatim.
adapters (platform-scope · ADR-255 / ADR-258)
The registry of OUTBOUND integrations, declared inside platform { adapters { ... } }. Each adapter "" { ... } describes one third party and, more importantly, what the platform does when that third party misbehaves — which is the part a caller cannot decide for itself at the call site.
- adapter_type = "..." — what kind of integration this is, and protocol the wire it speaks. capabilities = [...] is what it can DO; capability_routes below dispatches on exactly those names, so a capability absent here is unroutable.
- owned_by = "..." — the team that answers when it breaks. An outbound integration always has an owner; declaring it is what makes the owner findable from the generated service instead of from memory.
- countries = [...] — where this adapter is usable. It is a routing constraint, not documentation: a capability route may resolve differently per tenant because of it.
- timeout_ms and fallback_adapter_id = "..." — how long to wait, and who takes over. A fallback without a timeout never triggers, because nothing ever gives up.
- credential_spec { kind, value, ttl_seconds } — a REFERENCE to the credential (env_var / vault_path / kms_key_id) and how long it may be cached. The secret itself never appears in a spec.
- retry_policy { max_attempts, backoff_factor, backoff_cap_ms, retryable_error_classes } — retryable_error_classes is the one that decides correctness rather than patience: retrying a non-idempotent failure is how one timeout becomes two charges.
circuit_breaker_policy { ... } is retry's counterpart, and the three attributes are one decision each:
- error_threshold — how many failures open the breaker. Below it the platform keeps calling a service that is already down, and every one of those calls costs the caller its timeout.
- recovery_timeout_ms — how long the breaker stays open before testing again. Too short and the recovering dependency is re-drowned by the traffic that broke it.
- half_open_max_calls — how many probes are allowed through while testing. This is what makes recovery a MEASUREMENT rather than a gamble: without a cap, half-open is just the closed state with extra steps.
rate_limit_policy { requests_per_second, burst, scope } bounds what the platform sends OUT — a limit the third party would otherwise have to enforce by rejecting. requests_per_second is the sustained rate and burst is how much of it may be spent at once: without a burst allowance a caller that batches work is throttled for being bursty rather than for exceeding its share, and scope decides whether the budget is the platform's or each tenant's. state_code_mapping { "" = "" } translates the partner's status vocabulary into the platform's, so a partner renaming a code is a spec edit and not a code change.
capability_routes { "" { adapter, for_tenants, default } } picks which adapter serves a capability, optionally per tenant, with one default = true as the fallback. It is the reason an adapter can be swapped for one tenant without touching the service that calls the capability.
quotas (platform-scope · ADR-256 Phase 10)
Per-module resource ceilings, declared inside platform { quotas { ... } }. Each module { ... } row — the module is an identifier, not a quoted string — takes max_concurrent_requests, storage_budget_gb, and egress_gb_per_day, all integers. Same module { } shape as the sibling blocks retention and alerts.
alerts (platform-scope · ADR-261)
Per-module alert routing: module { sev0_route, sev1_route, sev2_route, escalation_minutes }. The three routes are strings naming where each level goes; escalation_minutes is how long before it escalates.
cicd (platform-scope · ADR-261)
Platform delivery pipeline: provider names the CI system, deploy_order is an array of module identifiers giving the order services roll out in, and manual_approval_envs is an array of the environments that require a human before promotion.
command
A named state-changing operation on an entity: command "" { ... } at file top level. Where a flow describes steps, a command describes a TRANSITION — which entity, from which states, to which one — and the compiler emits the route, the guard and the state write together rather than leaving them to agree by hand.
- entity = and verb = "..." — what it acts on and what it is called.
- state_field = "..." — the entity attribute holding the state.
- state_from = [...] — the states the command may be issued from. An array, because most real transitions are legal from more than one, and listing them is what lets the compiler refuse the rest.
- state_to = "..." — the state it leaves the entity in.
- endpoint_path = "..." and http_method — the route it is served on.
- precondition { } — conditions checked before the transition. Same block the flow validate step uses, so a guard is written once and means the same thing in both places.
state_from is the attribute worth getting right: omitting it does not mean *from anywhere*, it means the transition is declared without the one fact that makes it refusable.
Legal from two states, and from no others. Top-level block, not nested in the entity.
The file side of a flow: flow "..." { file_operation { ... } }. type picks one of four operation kinds and decides which of the other attributes apply — the block is one production, not four, so the grammar accepts attributes that a given kind ignores.
- generate — build a new file at request time. template_entity (identifier) is the entity the template renders from, template_name names the template and template_engine the renderer. content_type sets the MIME type served and filename_pattern the name the client receives.
- export — entity rows out to a file. export_entity (identifier) is the source, export_fields an array selecting the columns, and export_format one of csv, json, xlsx, xml, pdf.
- download — stream a stored file. storage_backend is local, s3 or gcs, and storage_field names the entity attribute holding the object key.
- upload — accept a file from the client. allowed_types restricts what is accepted, max_file_size caps it in bytes, upload_destination is where it lands, and process_async hands the work to a worker instead of the request.
export_format and storage_backend are closed sets — the compiler rejects a value outside them at parse time rather than emitting something that fails later.
`export_fields` selects columns; `export_format` is one of the five closed values.
Exporting entity rows
flow "ExportInvoices" {
type = workflow
trigger = http(method="GET", path="/invoices/export")
entity = Invoice
file_operation {
type = export
export_entity = Invoice
export_fields = ["number", "issued_at", "total"]
export_format = csv
}
// A flow needs at least one step: the validator rejects an empty body rather
// than emitting a flow that does nothing, and `file_operation` configures the
// export without being one.
step 1 "Export the rows" {
action = export
target = Invoice
output_format = csv
}
}
`max_file_size` is an integer in bytes; `process_async` moves the work off the request.
Entities that become immutable only after they reach a given state — the conditional form of @append_only. Declared at file top level, as a peer of platform { } rather than inside it. Each row is { after_state = "", state_field = "" }: the entity name is an identifier, both values are quoted strings. state_field names the attribute holding the state and after_state the value past which rows stop accepting updates. Where @append_only forbids updates always, this forbids them from a point in the lifecycle onward — an invoice that stays editable while its status is a draft and freezes once it has been issued.
The generated service enforces this: once an entity is past the declared state, an update is refused with the canonical 409 Conflict rather than silently applied. The rule is per-entity — declaring one here does not freeze the others.
Its unconditional sibling @append_only forbids updates outright. Reach for this block when a record is meant to be editable for part of its life and fixed after: @append_only would refuse the early edits too.
Top-level block. Both values are quoted strings; the entity name is not.
The per-environment sub-block of deploy { }: environment "" { namespace, ingress_host, ingress_tls_secret, cluster_ref }. One of these expands into one deployment environment; deploy itself carries image_registry and default_replicas.
Overrides which plugin handles a dispatch slot when the default resolution order does not pick the one you want. Each " row is keyed by an authorial label so diffs and docs can reference it — the label itself is not consumed by resolution — and declares the slot (language, emitter_id, optional tech_kind) together with the plugin_id and plugin_version that must serve it.
plugin_policy (platform-scope)
Platform-wide plugin allow/deny: allowed_third_party_plugins and denied, both arrays.
interactions (cross-service registry · WAVE 0)
Platform-scope cross-service interaction registry, declared inside platform { interactions { ... } }. Each "" { ... } row declares a producer → consumer edge with its tier, trigger, and task/queue. Registry-centric (WAVE 0) enriches a producer edge with these declared attributes:
- event_schema = "Name@version" — a versioned reference into the platform event_schemas { } registry. Resolution is *lenient*: a bare Name resolves when the registry holds a single version of it; the @version suffix is required only when two or more versions of that Name are declared.
- ordering_key = "..." — advisory per-key ordering hint (also accepted inside delivery { }).
- payload_map { = } — envelope projection. Each is a closed discriminator in three kinds: a *literal* (string/number/bool), $instance. (the triggering entity), or $context. (request/tenant context). $computed and $result are not valid inside a payload_map, and $input is not yet supported here.
- delivery { ... } — delivery + reliability policy: transport ∈ {celery, redis_streams, kafka, sqs, rabbitmq}, outbox = (transactional outbox), ordering_key, plus nested retry { ... } (max_attempts · backoff · initial_delay_ms · max_delay_ms) and a baredlq { ... } block (stream_name · max_attempts_before_dlq · dlq_archive_strategy · archive_retention_days) — distinct from the saga dlq_topic / dlq_retention attributes, and reusing the platform-tier worker policy rules verbatim (M4 single-source).
The producer derives every field canonically from the registry; the compiler reads the IR directly (no name heuristics). The shapes above are declared-syntax (validator-enforced) — independent of emit behavior.
The rest of the row's attributes. A row is one production, so these sit beside the ones above rather than in a variant of their own:
- consumers = [A, B] — the plural of consumer, an array of identifiers, for one producer fanning out to several services. Use one or the other, not both.
- trigger_operation = "..." and trigger_state = "..." — narrow *when* the edge fires: the operation that fires it, and the entity state it fires on. They refine trigger, they do not replace it.
- direction and channel_kind — identifiers describing which way the edge runs and what kind of channel carries it. Unlike transport inside delivery { } these are open identifiers, not closed sets.
- response_schema = — for edges that answer, the schema of what comes back.
- event_schema_version = "..." — the version alone, for rows that name the schema elsewhere. event_schema = "Name@version" carries both in one string; this is the separate form.
- timeout_ms = — how long the caller waits, in milliseconds. Its sibling retry is a plain integer count here, distinct from the nested retry { } block inside delivery { }.
- authority_refs = [...] — the authority rule codes this cross-service contract cites.
Row i-11: a producer → consumer edge with a versioned event schema, payload projection from the triggering instance, and a full delivery policy (transport + outbox + retry + DLQ).
Domain event definitions for event-driven communication between services. Each entry is "" { ... }. Attributes (grammar rule event_attr): entity (the entity the event concerns) and actor_type (the acting principal — user, service, system, scheduler or integration, where integration means an OUTSIDE party acted through an inbound integration and service means another service of this platform did) classify it; transport selects the backend (redis_streams / kafka / sqs / in_memory), stream names the stream and consumer_group the delivery group; dlq (bool) enables a dead-letter queue and dual_channel (bool) mirrors the event over a second channel; idempotency_key names the field used to de-duplicate deliveries and correlation_fields lists the fields carried for correlation. trigger_entity + trigger_action bind the event to a CRUD operation that emits it; publisher_function_name overrides the generated publisher name; event_version pins the schema version; description documents it; internal_mirror_path sets the in-app mirror route. A nested payload { ... } block declares the event's payload fields. NOTE: the producer↔consumer binding is declared in interactions{} (canonical); events{} carries the payload + delivery contract.
tenant_config
Multi-tenancy configuration: isolation strategy, tenant resolution, and feature flags per tier.
Per-setting attributes. Each named setting inside the block declares what a tenant may change and how far:
- data_type = "..." — the setting's type, and enum_values = [...] the closed list when it is one.
- default — the value a tenant gets without choosing.
- platform_minimum and platform_maximum — the bounds the PLATFORM imposes, which a tenant cannot widen. They are the difference between a setting a tenant tunes and one a tenant can use to take resources from everyone else.
- elevation_direction = "..." — which way counts as *more* privilege for this setting, so the platform knows whether a tenant raising it or lowering it is the request that needs approval. Without it, a numeric bound cannot tell a stricter setting from a looser one.
workers (platform-scope, ADR-260)
Platform-scope worker topology: tiers, queue routing patterns, retry + dead-letter policies, concurrency, scaling hints. Declared inside platform { workers { ... } }. Superseded the per-service task_queue block (ADR-211). Optional tasks { "" { consumes_from = [...]; produces_to = [...] } } sub-block (ADR-309 Phase E) declares which entities a task reads/writes, emitting the WorkerTask→Entity CONSUMES_FROM / PRODUCES_TO edges.
Block-level attributes.broker_url_env_var and result_backend_env_var name the ENV VARS the workers read their broker and result backend from — the spec carries the variable name, never the URL, so a connection string never lands in a spec. worker_framework picks the runtime that executes the tiers.
Per-tier attributes (tier "" { ... }) — a tier is a pool with its own queues and its own scaling, and the four below are what make it one:
- min_replicas and max_replicas — the floor and ceiling of the pool. The floor is the interesting one: at 0 the tier scales to nothing and the first job after an idle period pays the cold start.
- scale_trigger = "..." and scale_threshold — what the autoscaler watches and the value that moves it. Declaring the ceiling without the trigger gives a pool that is allowed to grow and has nothing telling it to.
- concurrency and queue_routing_patterns decide how much one replica takes at once and which queues this tier drains — the routing patterns are what keep a slow tier from eating the queue a fast tier was sized for.
row_level_isolation
Row-level security policies for data isolation.
- strategy = ... — rls_session_variable, rls_role_based or application_only. The first two put the isolation in the DATABASE; the third leaves it in application code, which means every future query is a chance to forget it. The grammar accepts any identifier here; the closed set is enforced by the validator (INV-DSL-RLI-001), so a typo surfaces at validation rather than at parse.
- session_variable_name = "..." — the Postgres session variable the policies read the current tenant from. It is the one name the emitted SET LOCAL and the emitted USING clause must agree on.
- force_rls_on_owner = true — applies the policies to the table's OWNER too. Without it, Postgres exempts the owner, and the account the migrations run as is typically the owner: RLS looks configured and the one role most likely to run an ad-hoc query bypasses it.
- exempt_superuser = ... — whether BYPASSRLS roles are exempted. This is not a convenience toggle: it decides whether tenant isolation is a property of the DATA or a property of who is asking.
sequences
Ordered sequence generators (e.g., invoice numbers). Each entry is a named generator whose attributes decide how the value is built and when the counter goes back to the beginning:
- format_template = "..." — the shape of the emitted value, into which the counter is rendered.
- start_value = — where the counter begins.
- prefix_field = "..." — an entity attribute whose value is used as a prefix, so one generator can produce independently numbered series.
- reset_policy — when the counter restarts (never, or on a calendar boundary).
- tenant_scoped = — whether each tenant gets its own counter. Leaving it off means one shared series across tenants, which is visible to them: consecutive numbers tell one tenant how much business the others did in between.
observability
Canonical observability surface (ADR-361) — metrics, structured logging, OTEL tracing, correlation, and health probes in one block. Carries the union of the legacy service_observability + infrastructure.observability surfaces plus tracing_enabled / tracing_exporter / log_level_env_var.
THREE BLOCKS ARE SPELLED observability AND THEY ARE NOT THE SAME BLOCK. Which attributes are legal depends on where you opened it, and a .dmx that mixes them gets a parse error naming an attribute that is perfectly valid one scope over:
- the canonical one (ADR-361), described above — the one to write in new specs.
- inside infrastructure { } — the older, smaller surface: metrics, tracing and logging are booleans that switch each subsystem on, while log_level and log_format are strings, and health_endpoint / metrics_endpoint / tracing_endpoint are the paths each is served on.
- inside platform { } — platform-wide, and none of its attributes are booleans: logs, metrics and traces are strings naming the backend each signal goes to, with dashboards_tool and alerting_tool beside them. Three more govern behaviour rather than destination: pii_attrs = [...] lists attributes to keep out of logs, log_sample_rate is a float between 0 and 1, and log_sample_floor_level names the least important level that sampling may still drop — it is the floor sampling never goes under, not the level it samples at.
metrics is the sharpest of the collisions: a boolean in infrastructure, a string at platform scope.
The canonical block's own attributes.metrics_enabled, structured_logging and tracing_enabled switch each subsystem on; metrics_path, health_liveness_path and health_readiness_path are the routes they are served on. Liveness and readiness are two questions, not one — liveness asks whether the process should be restarted, readiness whether it should receive traffic — and pointing both at the same route makes a service that is merely warming up look dead.
correlation_id_header names the header a request's correlation id travels in, and propagate_correlation = decides whether outbound calls carry it onward. Without the second one the id stops at the first hop, which is where a trace across services stops being a trace. log_level_env_var and tracing_exporter name the environment variable holding the level and the exporter to send spans to.
Two sub-blocks refine it. metrics { { ... } } declares individual metrics. health_probes { { probe_type, timeout_seconds } } declares checks beyond the two paths above — a dependency the service needs before it can honestly report itself ready. Each carries its own timeout, because a probe without one turns a slow dependency into a hung probe, and a hung probe reads as healthy for exactly as long as nothing times it out.
event_bus (platform-scope)
Platform-wide event streaming, declared inside platform { event_bus { ... } }. transport picks the carrier — redis_streams, kafka, sqs or in_memory — and stream_prefix namespaces every stream the platform creates.
The rest is consumer and delivery behaviour: consumer_batch_size is how many messages a consumer takes at once and consumer_block_ms how long it waits when there are none. dead_letter_enabled turns on the dead-letter path and dead_letter_max_retries is how many attempts precede it. idempotency_ttl_seconds is how long a processed message id is remembered — it is what makes redelivery safe, so it should outlast the retry window rather than merely exist.
in_memory is a real transport and behaves like one, but nothing survives a restart; it belongs in a single-process environment, not in one where a dropped event is a lost record.
allowed_transports is the platform's narrowing, and it is a different thing from transport: transport is the carrier this block uses, while allowed_transports is the list of carriers anything in the platform may use. One declared policy governs BOTH surfaces — the event bus here and every cross-service delivery.transport — which is why its vocabulary is the union of the two: redis_streams, kafka and sqs are legal on either side; celery and rabbitmq are cross-service only; in_memory is event-bus only. Omitting the block permits the full taxonomy, so declaring it is how a platform says *these and no others* — a Celery-only platform is allowed_transports = [celery], and a platform that forbids Celery just leaves it out. Listing in_memory additionally requires allow_in_memory = true in the same block: the non-durable transport has to be asked for twice, never arrived at by default.
databases (platform-scope)
Platform database topology: platform { databases { database "" { ... } } }. Each entry is a quoted name plus its configuration, so a platform can declare several.
- dialect — postgresql or clickhouse, a closed set.
- owned_by = "" — which module owns this database. Ownership is the boundary the compiler enforces: another service reaches this data over its API, never with a query of its own.
- host_ref and port_ref — the names of the environment variables holding host and port. References, not values: no address is baked into the emitted artifacts.
- name and credential_spec — the database name, and the env-var name holding the credential. Same rule — the reference is declared, the secret is not.
- pool_size, max_overflow, pool_timeout_s — connection pool ceiling, how many extra connections it may open past that, and how long a caller waits before failing.
- migration_tool — alembic or liquibase.
service_observability (deprecated alias)
Deprecated alias for observability (same attributes, minus tracing / log_level_env_var). Parses identically; migrate to observability.
object_storage
Platform-scope object storage topology — declares the cloud provider, credentials, and per-bucket configuration. Five providers are supported (closed taxonomy enforced parse-time): s3 / gcs / azure-blob / minio / cloudflare-r2. Each bucket carries name, region, owning_service, access_pattern (read/write/admin/shared), encryption_kind (SSE-S3/SSE-KMS/SSE-C), optional encryption_secret_ref (required for SSE-KMS+SSE-C), presigned_url_ttl_s, and lifecycle_rule blocks (transition/expiration with after_days). Cloudflare R2 rejects SSE-KMS at IR construction (R2 has no KMS integration).
Block-level attributes, which apply to everything the block declares:
- bucket_name_env_var and endpoint_url_env_var — the ENV VARS carrying the bucket and endpoint. Naming the variable rather than the value is what lets one spec compile for staging and production without a diff.
- path_prefix_template = "..." — the key prefix every object is written under. It is what keeps one tenant's objects from sharing a key space with another's, so a listing operation cannot enumerate across tenants.
- enable_server_side_encryption = true — encryption at rest, applied by the provider. Per-bucket encryption_kind chooses WHICH scheme; this decides whether there is one at all.
document_type { ... } classifies what may be stored, and the rules travel with the class rather than with each upload site:
- allowed_content_types = [...] and max_file_size_mb — what the platform accepts. Declared here they are enforced for every route that writes this document type, which is the point: a limit that lives on one endpoint stops applying the moment a second endpoint writes the same bucket.
- write_once = true — the object cannot be replaced after it is written. This is the storage-side counterpart of @append_only, and it is what makes a stored document usable as evidence: a record that can be silently overwritten proves nothing about what it said yesterday.
- presigned_url_ttl_seconds — how long a handed-out download link stays valid. It is the entire access control on that link: once issued, nothing revokes it before the TTL expires, so the number is a decision about blast radius, not a convenience.
- publish_event_on_upload = true (with event_name) — emits a domain event when an object of this type lands, so downstream work triggers off the upload instead of polling the bucket for it.
- retention_days — how long objects of this type are kept before the lifecycle rules act on them.
permissions
Role-based and attribute-based access control definitions. The `sod_rules { ... } sub-block declares Separation-of-Duties rules; each named rule lists conflicting_permissions = [...] (the permission set that must not be co-held), an optional scope, breakglass_eligible flag, description, and an enforcement mode. enforcement is a closed taxonomy: block (the DEFAULT — omit and the rule behaves as block — records the conflict at read time and denies with 403, the fail-closed floor), warn and log_only (both record the conflict forensically and then ALLOW the request). Grant-time is always block structurally (not tunable); the enforcement mode only relaxes read-time behavior. Provided by C-SOD-2.
breakglass { ... } is the other half of Separation of Duties: the declared, audited way to hold a conflicting permission on purpose. Without it, block leaves an operator with an emergency and no legitimate path, and the path they find instead is a shared account nobody can attribute.
- enabled — whether the escape hatch exists at all. Off is a real choice; it just has to be a choice.
- max_duration_hours — how long an elevation lasts before it expires on its own. This is what makes it break-GLASS rather than a second role: an elevation that has to be revoked by hand is one that stays.
- requires_reason = true — the operator must state why. The reason is what the audit record is FOR; without it the trail says someone elevated and cannot say whether they should have.
- approver_permission = "..." — the permission a second person needs in order to approve the elevation. Naming it here is what keeps break-glass from being self-service, which would defeat the SoD rule it exists beside.
- audit_event_name = "..." — the event the elevation emits, so it lands in the same stream that is already monitored rather than only in a log somebody has to think to read.
A rule marked breakglass_eligible = true opts INTO this path; rules that omit it stay unbreakable, which is the correct default for the conflicts that have no legitimate emergency.
Two attributes sit directly in permissions { }, outside every sub-block:
- default_deny = — what happens to a request no rule mentions. This is the single most consequential line in the block: with it false, every permission you forget to declare is granted, and the spec cannot tell you which ones those are.
- cache_ttl_seconds = — how long a resolved permission decision is cached. It is the lag between revoking access and access actually stopping. ⚠️ The same attribute name also exists in the platform-scope feature_flags { } block, where it caches flag evaluations instead — same spelling, two blocks, two different things being cached.
permission "" { } declares one permission. Besides description:
- requires_step_up = — holding the permission is not enough; the caller must re-authenticate to use it. This is what separates a permission a session carries from one a person has to prove again at the moment of use.
- step_up_window_minutes = — how long that fresh proof counts for. Declaring the step-up without the window is the common mistake: re-authentication with no expiry converts back into an ordinary permission on the second call.
abac_constraints { { ... } } is the attribute-based half — a permission answers *may this role do this?*, a constraint answers *on WHICH rows?* Each named constraint takes:
- constraint_type = — the kind of comparison, written as a bare identifier, not a string.
- attribute_name = "..." — the attribute it reads.
- description = "..."` — what the constraint means, which is the part that survives the person who wrote it.
Constraints narrow what an already-granted permission reaches; they never grant. A constraint on a permission nobody holds changes nothing, and a permission with no constraint reaches every row.
feature_flags (platform-scope · ADR-211)
Runtime toggles, declared inside platform { feature_flags { ... } }. The point of declaring them in the spec rather than in a config file is that the generated services read them through one resolver instead of each inventing its own.
Three attributes govern the whole block:
- storage_backend = — where flag state lives, as a bare identifier.
- cache_ttl_seconds = — how long an evaluated flag is cached before it is read again. This is how long it takes for a flip to change how the platform behaves, differently, so it is the number to look at when a flip appears not to have worked. ⚠️ The permissions { } block has an attribute of the same name that caches permission decisions — two blocks, one spelling, unrelated caches.
- admin_endpoint_enabled = — whether the platform exposes a route to read and change flags at runtime. Off means flags change by deploy; on means they change by request, and that route governs behaviour without going through review.
Each flag { ... } then declares:
- flag_type = — what kind of value it holds, as a bare identifier.
- default_value = "..." — the value used when nothing has been set, written as a string whatever the type. It is what every service sees on first boot and after a backend outage, which makes it the flag's real behaviour rather than its fallback.
- scope = — how widely a set value applies.
- description = "..." — what the flag controls. A flag whose meaning is lost cannot be removed, because nobody can prove what turning it off would do.
sagas
Distributed transaction orchestration with compensating actions. Each `saga declares a sequence of step definitions; on failure of any step, prior completed steps run their compensation action in reverse order. Two patterns supported: orchestration (default — central coordinator drives steps) and choreography (event-driven — each step subscribes to the previous step's completion event). Persistence: outbox (default — DB-backed state machine + transactional outbox table for event publishing in the same TX as business state) or in_memory_only (opt-in for non-critical sagas where crash recovery is not required). Brokers: none (in-process — orchestration only), redis (redis-streams), kafka (aiokafka). Choreography requires a non-none broker. Per-step action_handler and compensation_handler are fully-qualified Python references (e.g. src.services.payment.charge); when omitted the compiler defaults to src.services.sagas... idempotency_key_field is the request-payload field used as the unique key for retry / replay safety. max_retries per step overrides the saga-level default. DLQ topic + retention (integer + days|hours unit) configure the dead-letter queue for failed events that exceed retries. Provided by ADR-319 Distributed Sagas Productive Closure.
The keywords for the three settings described above, because the concept being explained is not the word an author types: the persistence choice is written persistence_strategy = ..., the dead-letter retention unit is dlq_retention_kind = ... (paired with the integer dlq_retention), and the broker credential is broker_url_secret_ref = "..."` — a REFERENCE to the secret, never the URL itself, which is why a saga definition is safe to read in a spec review.
protocol_handlers (non-REST transport fleet)
Platform-scope registry of non-REST transport handlers, declared inside `platform { protocol_handlers { ... } }. Each handler "" { ... } row declares one transport listener with seven attributes:
- transport_kind = "..." — the wire transport (e.g. tcp, udp, mllp, serial).
- transport_config { = "..." } — a bare nested block (no =) of transport-specific settings as IDENT = "string" pairs (e.g. host / port).
- framing_strategy = "..." — how messages are delimited on the wire (e.g. length_prefixed, delimiter, fixed_length).
- wire_format_parser_ref = "..." — the named parser that decodes each frame into a domain payload.
- owner_service_or_agent = "..." — the service or edge agent that owns (deploys + runs) the handler.
- delivery_semantics = "..." — the reliability contract (e.g. at_least_once, at_most_once, exactly_once).
- idempotency_key_source = "..." — where the dedup key is read from (e.g. header:X-Message-Id, body:message_id).
The kind-valued fields (transport_kind / framing_strategy / delivery_semantics`) are opaque (INV-260-1): the compiler never branches on their values — plugins resolve the concrete emitter — so the values above are *examples*, not a closed taxonomy. Provided by ADR-260 / ADR-264.
A length-prefixed TCP listener owned by the ingest service, decoding frames via a named binary parser with at-least-once delivery keyed off a message-id header.
Actions available in flow steps. Each action maps to a specific operation on the target entity or system.
Name
Description
Syntax
create
Create a new entity instance
action = create
update
Update an existing entity
action = update
delete
Delete an entity instance
action = delete
query
Query entities with filters
action = query
validate
Run validation rules
action = validate
extract
Extract fields from data
action = extract
calculate
Compute derived values
action = calculate
state_change
Trigger a state machine transition
action = state_change
external_call
Call an external service
action = external_call
batch
Batch multiple operations
action = batch
export
Export data to a format
action = export
infra
Infrastructure step
action = infra
foreach
Fan out a do{} body per item of a source collection (per-item loop)
action = foreach
upsert
Insert the entity, or update it when it already exists (#569)
action = upsert
Preconditions (Guards)
Structured, kind-discriminated guards. The taxonomy is closed (20 productive + 2 markers), but executability is placement-specific: a kind accepted on one carrier is not automatically valid on commands, flow-level guards and validate STEPs. Workshop and the compiler reject an unsupported placement instead of silently reducing it to another guard. Every entity, field and integration reference is spec-declared; no domain vocabulary is inferred.
Commands currently execute `field_value_compare` only. Validate STEPs execute `permission_required`, `related_rows_in_state`, `no_overlapping_interval` and `integration_signature_valid`. The three B162 guards are STEP-only. Flow-level guards retain their own productive roster and reject STEP-only kinds. A missing HMAC secret, mismatched parallel match arrays, missing required field or unsupported placement fails closed and loud.
Name
Description
Syntax
field_value_compare (default)
Compare a field to a value. operator ∈ eq, not_eq, in, not_in, gt, gte, lt, lte, is_null, is_not_null. Carries error_message / error_code.
field, operator, value
role_required_single
The principal holds the single role.
role = "admin"
role_required_any_of
The principal holds ANY of roles (≥ 2).
roles = ["admin", "manager"]
permission_required
The principal holds a named permission.
permission_name = "invoice:write"
not_expired
A timestamp field is still in the future (not expired).
expiry_field = "expires_at"
workspace_present
A workspace/tenant id is present on the request.
workspace_source = jwt | header | jwt_or_header
field_present
An input field is supplied (non-null).
field = "email"
field_unique
The field value is unique across entity rows.
entity = "User", field = "email"
fk_valid
An FK field resolves to a live target_entity row.
field = "user_id", target_entity = "User"
min_list_cardinality
A list/collection field has at least count items.
field = "items", count = 1
no_conflict
No overlapping entity row exists on time_field (time-window conflict).
entity = "Booking", time_field = "slot"
token_valid
A token is valid and unrevoked.
token_source = "refresh_token"
entity_exists
An entity row identified by id_field exists.
entity = "Order", id_field = "order_id"
entity_active
The resolved entity row is active (active_field defaults to is_active).
entity = "User", active_field = "is_active"
relationship_exists
A join-table row links the subject to the object (e.g. reviewer assigned to submission).
Verifies the exact raw request body using the named integration's HMAC contract; missing secret fails closed.
integration_ref = "..."
trigger_marker (marker)
Non-productive: flags an API/routing-layer trigger handled at the route, not as a guard. No emit.
label = "..."
session_scope_marker (marker)
Non-productive: flags an auth-service-layer session concern. No emit.
session_scope = single | global | current
Omitting kind preserves the legacy field_value_compare command contract. Other explicit command kinds are rejected unless the command placement roster supports them.
Legacy command comparison guard
command "CloseInvoice" {
entity = Invoice
verb = "close"
precondition { // kind omitted → field_value_compare
field = "status"
operator = eq
value = "open"
error_message = "Invoice must be open to close"
error_code = "INV_409"
}
}
Parallel match_fields/match_values arrays are positional and must have equal cardinality. latest requires order_field; require_nonempty keeps an empty result fail-closed.
Functions used in @triggers annotations and event routing.
Name
Description
Syntax
event()
Emit a domain event
@triggers(event("user_created"))
job()
Enqueue a background job
@triggers(job("send_welcome_email"))
state_change()
Trigger a state machine transition
@triggers(state_change("activate"))
Platform Spec
A platform spec declares the architecture of a multi-service application. It lives in a separate platform.dmx file and is never compiled directly — it provides context when compiling individual service specs.
Two file types in DMX:
- Platform spec (platform.dmx): Declares shared infrastructure, lists all services/modules, defines compilation order, cross-service interactions, Celery workers, event bus, and infrastructure config. ONE per application.
- Service spec (service_name.dmx): Defines ONE microservice — its entities, APIs, flows, security, state machines. The service "name" must match the module key in the platform's modules {} block.
The compiler compiles service specs one at a time, using the platform spec as infrastructure context.
Platform-Level Blocks
Name
Description
Syntax
version
Platform version string
version = "1.0.0"
platform_roles { }
Compile-time RBAC role definitions (optional)
modules { }
REQUIRED: service/module declarations. Each module's dependencies = [...] drive the compilation order — no explicit ordering block
ABAC row-filter scope catalog: declares scope columns once (claim → strategy). A per-entity field @scoped(claim="<key>") activates a catalog entry. Optional required_for_roles = [role, ...] makes a scope-dim role-conditional (#294-B).
DDD Strategic Design classification (closed taxonomy). Industry-agnostic. See module_category Values section for the 6 canonical keywords.
module_category = core_domain
module_category Values (DDD Strategic Design)
Closed taxonomy from Domain-Driven Design Strategic Design (Eric Evans, Domain-Driven Design, 2003; Vaughn Vernon, Implementing Domain-Driven Design, 2013). Industry-agnostic — applies to any platform regardless of business vertical. Validated parse-time by the grammar; non-canonical keywords surface a parse error inline.
Name
Description
core_domain
The strategic differentiator. Where the business invests the most because it provides competitive advantage. Custom-built, evolves with strategy.
supporting_subdomain
Necessary for the business but not differentiating. Usually built in-house when no off-the-shelf option fits; can sometimes be outsourced.
generic_subdomain
Solved problem with widely available solutions (auth, notifications, audit, document storage). Buy / adopt / standardise rather than build.
integration_layer
Bridges to external systems / third-party adapters / gateway concerns. Maps the bounded contexts of partners to the platform's ubiquitous language.
presentation_layer
User-facing surfaces (web UI, portals, dashboards, BFFs that exist only to serve the UX). Composes responses from other modules; usually no own entities.
infrastructure_layer
Platform plumbing: control planes, deploy automation, observability scaffolding, secrets / cert / identity management at the platform level (not business-domain auth).
service_type Values
Name
Description
standard
Default. Normal microservice with entities, APIs, business logic
consumer
Event consumer with no REST API (e.g., data lake ingestor)
reader
Read-only service querying external data (e.g., analytics API over ClickHouse)
stateless
No database, real-time processing (e.g., WebSocket dashboard)
aggregator
Portal API that composes responses from multiple services (no own entities)
Compilation Order
Derived automatically from each module's dependencies. There is no explicit ordering keyword — the compiler builds a topological DAG and rejects any legacy phase/ordering block.
control_plane has no deps so compiles first; identity depends on control_plane; registry and catalog depend on identity and compile in parallel; lab waits for both connectivity and preanalytic.
Canonical list-response pagination defaults. Drives the wrapper class, route Query() ge/le constraints, service signature, and BaseRepository.list defaults end-to-end (α-PAGINATION program / ADR-329). When omitted, the compiler falls back to default_limit = 100 + max_limit = 1000.
Platform-scope compliance profiles (ADR-255 / ADR-257 / ADR-317 INV-317-1.b Op α Phase 1.B 2026-05-15). Each profile is a registered framework identifier the spec author binds to the platform per its jurisdictional scope; the compiler treats the name as opaque taxonomy and reads concrete obligations from the canonical compliance_frameworks registry + the attributes declared inside the block. The Workshop validator queries `registry_loader.is_supported_compliance_framework`; non-canonical names are rejected at validate-time as COMPLIANCE_PROFILE_UNKNOWN. The full canonical set as of 2026-05-15: CCPA / FEDRAMP / GDPR / HIPAA / HITRUST / ISO-27001 / NIST-800-53 / OWASP-API / OWASP-WEB / PCI-DSS / SOC2 / SOX + enterprise jurisdictional extensions ISO_15189 / AFIP_ARCA / FABA_AOL / LEY_25_326 / LEY_17_132 (5 entries Op α Phase 1.B added). ADR-317 E.2 extends the per-profile attribute surface so the spec is the source of truth for the full ComplianceFrameworkIR shape — when an attribute is omitted the compiler falls back to the canonical default registry shipped under `src/dsl/compliance_framework_default_registry.py`.
Name
Description
Syntax
scope
Free-form scope description (e.g. data residency rule).
A minimal spec: one entity, REST API, basic security. Note the shape — `entity`, `api` and `security` are SIBLINGS of `service`, not nested inside it, and entity annotations go INSIDE the braces.
Comments
//. Block comments use/* ... */.