Modelable Language Reference
Authority: This document defines the current
.mdllanguage. Governance annotations and CEL expression rules are included here so authors do not need to reconcile separate language specifications.
Date: 2026-05-14
Status: Approved
Scope: New .mdl IDL language — syntax, type system, projections, output targets, toolchain
Context
Modelable needs a format for defining domain-owned canonical models, projections with explicit lineage, and output target declarations. Three options were evaluated:
- Option A — Custom YAML DSL: Full control, already partially specced, but verbose for complex projections and every emitter must be written from scratch.
- Option B — Extend TypeSpec: Gets OpenAPI/Protobuf emitters for free, but TypeSpec's API-centric model fights the projection/lineage/domain-ownership concepts that are Modelable's core.
- Option C — Custom text IDL (chosen): Purpose-built grammar for Modelable's concepts. More expressive than YAML for derivation logic, LLM-friendly due to explicit delimiters and consistent structure, enables a language server.
Primary authoring personas are application developers and data/platform engineers. The CLI (including LLM integration) is the primary interaction path — developers use modelable generate and modelable transform to create and evolve files, then review the output.
1. Syntax Style and File Structure
1.1 File extension
.mdl
1.2 Overall style
- Brace-delimited blocks — no significant whitespace, unambiguous for LLM generation
@decoratorannotations for governance metadata@to pin a version on a definition (Customer @ 2)(additive)/(breaking)inline after the version number?suffix for optional fields- No trailing semicolons
domain customer {
owner: "customer-platform"
description: "Customer identity and lifecycle data."
entity Customer @ 2 (additive) {
@key customerId: uuid
legalName: string
@pii email?: string
status: enum(active, blocked, deleted)
createdAt: timestamp
}
}
1.3 File layout convention
One domain per file. The compiler merges across files within a workspace.
models/
customer/
Customer.mdl
Address.mdl
billing/
Invoice.mdl
projections/
BillingCustomer.mdl
2. Fields and Type System
2.1 Built-in types
| Type | Notes |
|---|---|
string |
UTF-8 string |
int |
64-bit integer |
u8, u16, u32, u64, u128 |
Fixed-width unsigned integers |
i8, i16, i32, i64, i128 |
Fixed-width signed integers |
float |
64-bit float |
bool |
Boolean |
uuid |
UUID v4 |
uuid(7) |
UUIDv7 (timestamp-ordered); uuid with no argument is unchanged and still defaults to v4 |
timestamp |
UTC datetime with microsecond precision |
date |
Calendar date (no time) |
time |
Time of day (no date) |
duration |
ISO 8601 duration |
decimal(p,s) |
Arbitrary-precision decimal |
binary |
Raw bytes, variable length |
binary(N) |
Raw bytes, fixed length N (1..=4096) |
array<T> |
Ordered list |
map<K,V> |
Key-value map |
ref<Domain.Model> |
Cross-domain reference |
enum(a, b, c) |
Inline enumeration |
object { ... } |
Inline structured object; contains field declarations, exactly like a model body |
union<discriminator> { tag: T, ... } |
Discriminated union; each variant is selected by the discriminator property |
For example, a payment method can carry one of two object shapes while making
the selected shape explicit on kind:
method: union<kind> {
card: ref<payments.Card>,
bank: ref<payments.Bank>
}
JSON Schema and OpenAPI emit this as oneOf branches with a discriminator
mapping. Variant branches are required to be object-shaped at the target
boundary so the discriminator property can be validated.
Union versions are compatibility-classified like any other field change:
changing a union's discriminator property (union_discriminator_changed),
adding a variant (union_variant_added), removing one
(union_variant_removed), or changing an existing variant's type
(union_variant_changed) each produce their own compatibility finding, so
version bumps can be validated against exactly which part of the union moved.
| json | Arbitrary JSON value, opaque to Modelable; maps to serde_json::Value (Rust), unknown (TypeScript), {} (JSON Schema) |
An object type is an inline record of named fields:
entity Invoice @ 1 (additive) {
@key invoiceId: uuid
billingAddress: object {
street: string
city: string
country: string
}
}
Nested fields of an object are addressable from projections through CEL field selection (see section 3).
The type system is platform-neutral. Target emitters map each type to the closest equivalent in the output format (e.g., uuid → string format uuid in JSON Schema, UUID in Avro, uuid in Postgres DDL).
2.1.1 ref<> version constraints
ref<Domain.Model> accepts the same @ version_spec forms as a projection from/join clause:
| Form | Example | Meaning |
|---|---|---|
| Exact | ref<Domain.Model @ 2> |
Resolves to that published version only |
| Range | ref<Domain.Model @ >=2 <3> |
Resolved to the highest published version satisfying the constraint; the range has no extra closing bracket |
| Min | ref<Domain.Model @ >=2> |
Resolved to the highest published version at or above the floor |
| Pinned | ref<Domain.Model @ 2#hash> |
Exact version, rejected unless it also matches the declared content hash; hash is a hexadecimal signature and may begin with a digit |
ref<Domain.Model> with no @ version_spec still parses. It resolves to the latest matching version, but produces a non-blocking REF-coded diagnostic recommending a version constraint be added. A ref<> whose target or version cannot be resolved at all is a SEM validation error.
A ref<>'s version constraint is independent of its target for compatibility purposes: changing the target model is a breaking change, but tightening, loosening, or removing the version constraint alone is not.
2.2 Field declaration syntax
@annotation fieldName: Type
@annotation optional?: Type
@annotation fieldName: Type = default-expression
Fields may declare value constraints after their type:
age: int constraint { min: 0, max: 150 }
code: string constraint { min_length: 2, pattern: "^[A-Z]+$" }
tags: array<string> constraint { min_items: 1, unique_items: true }
Supported constraints are min, max, min_length, max_length, pattern,
format, min_items, max_items, and unique_items. Constraints are part of
the canonical field shape and are emitted as JSON Schema keywords where the
target supports them. Direct projections inherit constraints from their source
field; generated auto-projections preserve the same constraint metadata.
A field may carry a default value, written as = EXPRESSION. The expression
uses the same CEL subset as computed projection fields (see
section 9); it is validated when the model is
compiled.
2.3 Available annotations
| Annotation | Meaning |
|---|---|
@key |
Identity field (required for entity and aggregate) |
@pii |
Contains personally identifiable information |
@classification("level") |
Governance classification (open, internal, confidential, restricted, secret) |
@deprecated(replacedBy: "field") |
Field is deprecated |
@owner("team") |
Field-level ownership override |
@server |
Field is assigned by the server at write time (e.g. auto-generated IDs, timestamps). Excluded from request auto projections by default. |
@wire(key: value, ...) |
Wire-format hint passed through to emitters; values are strings or { key: "value" } maps (e.g. @wire(avro: "logicalType", json: { name: "x" })). Temporal fields have canonical RFC 3339 / ISO 8601 defaults; target-specific rust.type and postgres.type values override them. |
@pitCutoff(EXPRESSION) |
Point-in-time cutoff bound for the field, used by event-sourcing / time-travel resolution |
@latestBefore(EXPRESSION) |
Resolve the latest value at or before the given point in time |
@latestOnly |
Keep only the latest value; older values are not retained |
@pitCutoff, @latestBefore, and @latestOnly control how history is
resolved for mutable fields. @wire attaches arbitrary per-target metadata
that emitters can interpret; unknown wire targets are tolerated and surfaced
as non-blocking diagnostics.
2.4 Model kinds
| Keyword | Rules |
|---|---|
entity |
Requires @key; has independent lifecycle |
aggregate |
Requires @key; owns a consistency boundary |
event |
No @key required; immutable fact |
value |
No @key; embedded in other models |
2.5 Versioning
Each version is a full independent declaration. The compiler diffs consecutive versions and enforces changeKind.
entity Customer @ 1 (additive) {
@key customerId: uuid
legalName: string
createdAt: timestamp
}
entity Customer @ 2 (additive) {
@key customerId: uuid
legalName: string
@pii email?: string
status: enum(active, blocked, deleted)
createdAt: timestamp
}
(additive) — only backward-compatible changes (new optional fields, deprecation marks, documentation). Existing projections remain valid.
(breaking) — at least one incompatible change (field removed, renamed, type changed, required field added). All projections referencing this model must be re-validated.
2.6 Protobuf reservations
Model and projection versions may reserve deleted Protobuf field numbers and field names:
reserved protobuf {
numbers: [3, 7]
names: ["legacy_status"]
}
Reservations are version-local. A field in the same version may not reuse a
reserved number, source field name, or generated Protobuf field name. The
Protobuf and gRPC targets use these reservations to render reserved
declarations and to validate target compatibility.
2.7 Model version evolution (evolves)
A new model version can be authored as a delta against an exact prior version instead of a complete field list:
entity Order @ 1 (additive) {
@key orderId: uuid
total: decimal(10, 2)
legacyNote: string
}
entity Order @ 2 (breaking) evolves @ 1 {
add note?: string
remove legacyNote
rename total -> amount
replace amount: decimal(12, 2)
}
The compiler resolves evolves @ N against the model's existing version
history and expands it into a complete ModelVersion — a deep copy of
version N's fields with the operations applied in order — before any
validation, compatibility check, or codegen ever runs. An add-only or mixed
delta form is indistinguishable from an equivalent hand-written complete
form at every one of those boundaries: identical normalized fields,
identical signatures, identical generated output.
Base resolution. N must be the highest existing version of the same
model and model kind strictly below the new version. Numeric gaps between
versions are fine — @ 5 evolves @ 1 is valid as long as 1 is still the
highest version below 5 — but evolving from a version that has since been
superseded by a later one ("branching"), from a version of a different
model kind, or from a version that doesn't exist, is rejected with a
diagnostic naming the actual required base.
Operations apply sequentially against the current intermediate state,
not just the base, so a rename followed later by a replace of the same
field (as in the example above) works correctly within one block:
add field: type— appends a new field. Rejects a name already present.remove name— deletes the complete field. Rejects an unknown name (this also covers removing the same field twice — the secondremovesees no matching field).rename old -> new— keeps the field's position; only its name changes. Rejects an unknown source name or a target name already held by another field. Renaming a field to its own current name is a harmless no-op.replace field: type— keeps the field's position; the target is identified by the field name in the declaration itself, and its complete definition (type, constraints, defaults, annotations) is replaced. Rejects a name that matches no existing field.
Provenance. The expander records, for every field in an evolved version,
which operation last determined its identity — inherited unchanged from the
base, or last touched by add/rename/replace (with the prior name for a
rename). This is diagnostic/tooling metadata only and is not part of the
canonical signature.
Model-level metadata — the @wire(...) annotations before the model
keyword and the access { ... } block — follow the same rule as field
operations: present on the evolves declaration, they completely replace
what the base had; omitted, they are inherited from the base unchanged.
Protobuf reservations are the one exception: they are version-local exactly
like a full-form declaration (§2.6) and are never inherited — an
evolves declaration that needs to reserve a removed field's number must
say so explicitly, the same as a full-form declaration would.
index declarations are not part of evolves and are always declared
independently at the domain level.
Compatibility note: compatibility classification ((additive) /
(breaking)) is derived from the expanded field list by comparing it
against the base, the same way it compares any two full-form versions.
A rename still requires (breaking) — the old field name genuinely stops
existing, which is source-breaking regardless of intent — but the
provenance recorded above lets the comparison identify which removed name
and added name are actually the same field renamed, rather than treating
them as two unrelated changes. This matters when a rename shares an
evolves block with an unrelated remove/add: without provenance, all
four field-name changes would be indistinguishable; with it, the rename is
reported as one renamed_field fact instead of contributing to a pile of
unrelated removed_field/added_field facts, and diagnostics name the
responsible operation (e.g. "renamed_field total (declared via evolves
rename)") rather than leaving the reader to guess.
Adopting or reverting delta authoring on an existing history (evolution
plan A2) is never required — a full-form and an evolves-form declaration
of the same version are indistinguishable at every downstream boundary (see
§3.10's "Normalized version" row), so switching between them is purely an
authoring-ergonomics choice. modelable compact-version domain.Model@version
(see CLI Reference §5.26)
proposes an evolves delta against the version's base for review, and
modelable expand-version domain.Model@version (see CLI Reference
§5.27)
renders an evolves-form version back out as a complete declaration.
Both verify every implemented codegen target's output is byte-identical
before writing anything, and both refuse to touch a version whose field
block contains any comment, rather than risk discarding it silently.
compact-version only ever proposes a rename when the removed field
carries @deprecated(replacedBy: "...") naming the added field — the same
evidence the compatibility comparison above already recognizes — and
refuses to compact a version whose fields were reordered relative to its
base, since add can only ever append (never insert), so reproducing a
reordered field list would mean silently changing generated field/column
order in every codegen target.
3. Projections, Lineage, and Derivation
3.1 Lineage operators
| Syntax | Meaning |
|---|---|
target <- source.field |
Direct mapping — lineage is unambiguous |
target = expression |
Computed field — compiler extracts referenced source fields from the CEL expression |
Every field in a projection carries an explicit back-reference to its origin. No field can exist in a projection without a <- or =.
3.2 Simple projection (subset)
domain billing {
projection BillingCustomer @ 1
from customer.Customer @ 2 as c
{
billingCustomerId <- c.customerId
name <- c.legalName
@pii invoiceEmail <- c.email
isBillable = c.status == "active"
}
}
3.3 Multi-source join
projection OrderWithCustomer @ 1
from orders.Order @ 3 as o
join customer.Customer @ 2 as c on o.customerId == c.customerId
{
orderId <- o.orderId
customerName <- c.legalName
@pii email <- c.email
total <- o.totalAmount
isHighValue = o.totalAmount > 1000.00
}
A join is written join <Model> @ <version> as <alias> on <CEL predicate>. The
predicate must be a boolean CEL expression (see section 9).
Optional join options:
left—left joinkeeps all rows from the primary source even when there is no matching join row. Join fields from aleftsource become optional.cardinality: one | many— declares whether the join is guaranteed to produce at most one match (one) or may produce many (many). The declared value is used for target type-shaping and validation.- annotations — any field annotation may be attached to a join prefix to mark
the join itself (for example
@piion a join that introduces sensitive data).
projection OrderWithOptionalCustomer @ 1
from orders.Order @ 3 as o
left join customer.Customer @ 2 as c on o.customerId == c.customerId
{
orderId <- o.orderId
customerName <- c.legalName
}
A where clause filters the primary source before grouping and projection:
projection ActiveOrders @ 1
from orders.Order @ 3 as o
where o.status == "open"
{
orderId <- o.orderId
}
The where predicate is a CEL expression over the primary source alias only.
3.4 Aggregation
projection CustomerOrderStats @ 1
from orders.Order @ 3 as o
group by o.customerId
{
customerId <- o.customerId
orderCount = count(o.orderId)
totalSpent = sum(o.totalAmount)
lastOrderAt = max(o.createdAt)
}
Aggregation functions (count, sum, min, max, avg) are a closed set — not arbitrary expressions — so lineage remains fully traceable.
3.5 Version ranges
projection BillingCustomer @ 1
from customer.Customer @ >=2 <3 as c
{
...
}
Resolved to the highest published version satisfying the constraint at compile time. If that version carries changeKind: breaking, the compiler raises an error until the projection is updated.
3.5.1 Field selection: pick and omit
Instead of listing every field in the projection body, a projection may select
its field set with a pick(...) or omit(...) clause written after the
from/join source block:
pick(alias.field, ...)— the projection body then only contains the listed source fields, mapped under their own names (apickrequires no body).omit(alias.field, ...)— the projection body contains every source field except the ones listed.
projection BillingContact @ 1
from customer.Customer @ 2 as c
pick(c.customerId, c.legalName, c.email)
{
}
projection CustomerWithoutSecrets @ 1
from customer.Customer @ 2 as c
omit(c.password, c.apiKey)
{
displayName = c.legalName
}
A pick/omit clause may reference model fields (alias.field) or annotation
filters (@pii, @classification("level")). A selection clause may also be
combined with a projection body that declares additional fields — either
computed (name = expr) or explicit direct mappings (name <- alias.field) —
as long as a body field does not reuse an output name generated by the
selection clause. Redeclaring a name that pick/omit already produces is a
compile error. pick and omit remain mutually exclusive, and pick() is
invalid. The clauses are also available inside
auto projections via exclude [...].
3.6 Lineage record (compiler output per field)
| Field | Kind | Source fields |
|---|---|---|
billingCustomerId |
direct | customer.Customer@2.customerId |
invoiceEmail |
direct | customer.Customer@2.email |
isBillable |
computed | customer.Customer@2.status |
totalSpent |
aggregation | orders.Order@3.totalAmount |
3.7 Auto Projections
Auto projections generate four standard derived models from a single entity or aggregate definition. They eliminate the need to hand-author repetitive projection boilerplate for the most common use cases: a persistence contract, an API write model, an API read model, and a change event.
Kinds
| Kind | Generated name | Purpose | Excludes by default |
|---|---|---|---|
db |
{Entity}Db |
Persistence contract; used for SQL DDL generation | Nothing |
request |
{Entity}Request |
Write model for API create/update | Fields annotated @server |
reply |
{Entity}Reply |
Read model for API responses | Nothing |
event |
{Entity}Event |
Change event emitted on entity state transitions | Nothing |
Syntax
domain customer {
entity Customer @ 1 (additive) {
@key customerId: uuid
legalName: string
@pii email: string
phoneNumber?: string
status: enum(active, suspended, deleted)
@server createdAt: timestamp
@server updatedAt?: timestamp
}
auto projections Customer @ 1 {
db
request
reply
event
}
}
The compiler expands this into four fully explicit projections, each carrying complete field-level lineage. The expansion is included in the plan document and is inspectable with modelable inspect Customer@1 --auto.
Compiler expansion
The example above expands to the following four projections:
// CustomerDb — full entity, for persistence layer
projection CustomerDb @ 1
from customer.Customer @ 1 as c
{
customerId <- c.customerId
legalName <- c.legalName
email <- c.email
phoneNumber <- c.phoneNumber
status <- c.status
createdAt <- c.createdAt
updatedAt <- c.updatedAt
}
// CustomerRequest — write model, @server fields excluded
projection CustomerRequest @ 1
from customer.Customer @ 1 as c
{
legalName <- c.legalName
email <- c.email
phoneNumber <- c.phoneNumber
status <- c.status
}
// CustomerReply — read model, all fields
projection CustomerReply @ 1
from customer.Customer @ 1 as c
{
customerId <- c.customerId
legalName <- c.legalName
email <- c.email
phoneNumber <- c.phoneNumber
status <- c.status
createdAt <- c.createdAt
updatedAt <- c.updatedAt
}
// CustomerEvent — change-event projection, all fields
projection CustomerEvent @ 1
from customer.Customer @ 1 as c
{
customerId <- c.customerId
legalName <- c.legalName
email <- c.email
phoneNumber <- c.phoneNumber
status <- c.status
createdAt <- c.createdAt
updatedAt <- c.updatedAt
}
The event projection maps to the standard change event envelope defined in the system spec (section 6.1). The on list is an option of the auto projections declaration, not of the expanded projection: it controls which operations emit events. When omitted, all operations (created, updated, deleted) are included.
Customisation
Individual kinds can be customised with inline options. Unspecified kinds use their defaults.
auto projections Customer @ 1 {
db
// Exclude a specific field from the write model
request exclude [status]
// Exclude all PII from API responses
reply exclude [@pii]
// Emit events only on creation and deletion
event on [created, deleted]
}
exclude accepts:
- A list of field names: exclude [fieldName, ...]
- An annotation filter: exclude [@pii], exclude [@classification("secret")]
- A combination: exclude [internalScore, @pii]
on accepts: any subset of [created, updated, deleted].
Versioning
Each auto projections block is bound to one entity version. When the entity is updated to a new version, add a new auto projections block for that version.
entity Customer @ 2 (additive) {
@key customerId: uuid
legalName: string
@pii email: string
tier: enum(standard, premium) // new field
@server createdAt: timestamp
}
auto projections Customer @ 2 {
db
request
reply
event
}
The compiler generates CustomerDb @ 2, CustomerRequest @ 2, CustomerReply @ 2, and CustomerEvent @ 2 — each a distinct immutable projection version, separately tracked in lineage records.
Constraints
auto projectionsmay only targetentityoraggregatemodels.- The generated names (
CustomerDb,CustomerRequest,CustomerReply,CustomerEvent) are reserved for the entity in that domain. Defining an explicit projection with one of those names for the same entity version is a compile error. - All auto-generated projections follow the same immutability rules as hand-authored projections.
- Auto projections do not support joins, aggregations, or computed fields. Use explicit projections for those cases.
3.8 Semantic Types
A semantic declaration gives a domain-meaningful name to a primitive, decimal(p,s), binary(N), enum, or another semantic type. It does not introduce a new representation — the underlying type is unchanged for parsing, validation, and (where an emitter doesn't yet support semantic types) serialization. Its purpose is to let field declarations reference ModuleId instead of string, everywhere the same domain concept is used. Enum declarations may be versioned with the same @ version (additive|breaking) header used by models.
Syntax
domain catalog {
semantic ModuleId: string
semantic ProductStatus: enum(active, blocked)
semantic ProductStatus @ 2 (additive): enum(active, blocked, archived)
semantic ProductSku: string {
registry: true
}
}
registry: true marks the type for deterministic registry ID allocation.
modelable compile allocates a small, monotonically increasing integer id for
every registry: true declaration and persists it in a git-tracked
registry-ids.lock ledger at the workspace root — ids are never reassigned or
reused, even if a declaration is later removed. See the
CLI Reference compile section for the allocation
mechanics and flags. Rust exposes the allocated ID in generated doc comments
and REGISTRY_ID; Protobuf and gRPC expose it in schema-manifest
semantic_types entries.
Referencing a semantic type
Semantic types are referenced by name. A bare name resolves against the current domain's own declarations first; if the current domain has no matching declaration, resolution falls back to a workspace-wide search, but only succeeds if exactly one domain declares that name. If more than one domain declares a semantic type with the same name, a bare reference is ambiguous and is a compile error. Use a domain-qualified reference (orders.Id) — the same dotted syntax ref<Domain.Model> already uses — to name a specific domain's declaration explicitly. When a domain contains multiple versions of a semantic type, references and generated targets use the highest version.
domain catalog {
semantic ModuleId: string
entity Module @ 1 {
@key moduleId: ModuleId
name: string
}
}
Chaining
A semantic type's underlying type may itself be another semantic type, forming a chain:
semantic RawSku: string
semantic ProductSku: RawSku
Chains must be acyclic and are limited to 32 levels. Referencing an undeclared semantic type, or a self-referential/mutually-referential chain, is a compile error.
Constraints
- The underlying type must be a primitive,
decimal(p,s),binary(N), enum, or another semantic type — not anarray,map,object, or model reference. - Versioned semantic declarations for one name must have strictly increasing versions. An additive enum version may add values but may not remove one; removing a value requires
(breaking). Additive versions may not change a non-enum underlying type. - A semantic type's name must not collide with a model name in its domain.
Emitter support (this slice)
The Rust emitter generates a newtype wrapper for each semantic declaration,
while the Protobuf and gRPC targets generate nominal declaring-domain wrapper
messages (see the Compiler Reference). Other targets
(TypeScript, SQL, JSON Schema, Avro, FHIR, GraphQL, dbt, OpenLineage,
OpenMetadata, ODCS, markdown, Go, Java, C#, Python) continue to resolve a
semantic type reference structurally. Extending nominal semantic-type support
to those targets is deferred to follow-up slices.
3.8.1 Enum projections
An enum projection declares a nominal, versioned, exact subset of an
enum-backed semantic declaration's members — the enum equivalent of a
record projection's pick/omit field selection (§3.5.1), but deriving
from a semantic enum's members rather than a model's fields:
semantic OrderStatus @ 1 (additive): enum(draft, submitted, approved, rejected, cancelled, deleted)
enum projection PublicOrderStatus @ 1 (additive)
from OrderStatus @ 1
pick(submitted, approved, rejected, cancelled)
enum projection HiddenDrafts @ 1 (additive)
from OrderStatus @ 1
omit(draft, deleted)
pick(...)— the projection's member set is exactly the listed members, normalized to the source's exact member identities.omit(...)— the projection's member set is the source's complement: every source member not listed.- The source is resolved against an exact version (
from OrderStatus @ 1, never a range); the projection's member set is fixed at that source version and does not grow if a later source version adds members — re-basing onto a newer source version requires a new projection version that names it explicitly. - Listing an unknown member, listing the same member more than once, or an
omitthat would leave zero members, are all compile errors. - An enum projection's name shares the same nominal namespace as models, record projections, and semantic types within its domain — declaring one with a name already used by any of those is a compile error.
Nominal identity. An enum projection is a distinct contract entity from
its source, and from any other enum projection, even when their exact member
sets coincide — two projections that both happen to select {submitted,
approved} from the same source are still two separate declarations with
independent compatibility history, exactly as two equal-shaped semantic
enums are never treated as the same type (see §3.10).
Conversion direction. A projection's members are always a subset of its
source's, so converting a projection value to its source is always total
(cannot fail). Converting a source value to the projection is a checked,
possibly-failing conversion — unless the projection happens to cover every
source member, in which case it is total too. The Rust emitter reflects this
exactly: impl From<Projection> for Source always, and either impl
TryFrom<Source> for Projection (partial) or impl From<Source> for
Projection (total, full coverage) depending on which case applies.
Compatibility. Enum projection changes are visible in compatibility
reports as their own finding kinds — enum_projection_source_changed
(pointing at a different source declaration or version),
enum_projection_member_added/enum_projection_member_removed (a
pick/omit clause edit), and enum_projection_implicit_member_added (an
omit projection picking up a member the source gained since the
projection's last version, without the projection's own clause changing) —
distinct from the source semantic enum's own
enum_member_added/enum_member_removed findings.
Emitter support (this slice)
The Rust emitter generates the From/TryFrom conversions described above.
No other target currently emits a dedicated artifact for an enum projection
declaration — this is a genuine, currently-permanent structural loss for
those targets, not a deferred gap pending more emitter work, since most
target ecosystems (JSON Schema, TypeScript, SQL, Protobuf, Avro, and so on)
have no native concept of "a checked subset of another enum" to encode into.
The projection's normalized member set, source reference, and compatibility
history remain fully tracked in the registry snapshot and compatibility
reports regardless of target support.
3.9 Index Declarations
An index declaration is bound to one model version, parallel in shape to auto projections. It declares the model's primary key column order and any secondary indexes.
entity Order @ 3 (additive) {
@key orderId: uuid
customerId: uuid
status: enum(pending, shipped, delivered)
createdAt: timestamp
}
index Order @ 3 {
primary orderId
secondary byCustomer {
key: [customerId]
sort: [createdAt desc]
unique: false
}
secondary byStatus {
key: [status, createdAt]
}
}
primary
primary must name exactly the model version's @key field(s) — no more, no fewer, and every model version today has exactly one @key field (composite keys aren't representable in the language yet, so in practice primary is always a single name). The declaration is still required explicitly, rather than inferred from @key, so that composite-key column order has a place to be recorded once composite keys are supported.
secondary
Each secondary block names a lookup index:
key— required. The equality-lookup column(s), in order.sort— optional, defaults to none. Additional column(s) appended afterkey, with an optionalasc/descdirection (ascis the default and rarely written explicitly).unique— optional, defaults tofalse.
key and sort field names reference fields on the indexed model — not a projection. Both primary and every secondary's key/sort list are validated against the model version's own fields at compile time.
Constraints
indexmay only targetentityoraggregatemodels (the only kinds that carry@key).- One
indexdeclaration per model version — declaring it twice for the same(model, version)is a compile error. - Secondary index names must be unique within one
indexblock.
Compatibility visibility
Changing an index declaration between two published model versions (adding, removing, or altering primary or a secondary block) is surfaced as an index_changed entry in that model's compatibility report (registry.db's compatibility_reports table) — visible, but not yet classified as breaking or additive; no compiler guard prevents publishing an index change today.
Emitter support
The Postgres SQL emitter consumes index declarations, generating CREATE INDEX/CREATE UNIQUE INDEX statements for each secondary block (see Compiler Reference). The ClickHouse SQL emitter renders each secondary block as an inline INDEX ... TYPE bloom_filter GRANULARITY 1 data-skipping index on the generated MergeTree table; a unique: true block still emits the index but adds a diagnostic warning, since ClickHouse cannot enforce uniqueness. The Protobuf target records declared indexes in schema manifests, and the gRPC target records read_indexes in service manifests. modelable validate-compat --target grpc reports read-index changes as requires_read_rebuild.
3.10 Terminology: enum, model version, and projection variants
Several similarly-named constructs exist for related but distinct purposes. None of the pairs below are interchangeable, and shape equality never implies identity between them:
| Term | Declared as | Identity |
|---|---|---|
| Anonymous enum | enum(a, b, c) written inline as a field's type (§2.1) |
No name of its own. Two fields independently declared enum(active, blocked) are unrelated — there is nothing to compare for "sameness" beyond the field's own type-compatibility rules. |
| Semantic enum | semantic Name @ v (kind): enum(...) (§3.8) |
Named and versioned. Two semantic enums with identical member sets (e.g. Grade and Rank, both enum(gold, silver, bronze)) are still two distinct types — shape alone never implies identity. |
| Enum projection | enum projection Name @ v from Source @ v pick(...)\|omit(...) (§3.8.1) |
Named, versioned, and derived from an exact semantic-enum version. A projection covering every source member is still a distinct contract entity from its source, not an alias for it. |
| Non-enum semantic type | semantic Name @ v (kind): <primitive\|decimal\|binary\|semantic type> (§3.8) |
Same nominal-naming mechanism as a semantic enum, but wraps a non-enum underlying type. No pick/omit/projection concept applies — there is nothing to select a member subset of. |
| Value model | value Name @ v { field: type, ... } (§2.4) |
A model kind with no @key, used for embedded/reusable multi-field groups. Distinct from a semantic type: a semantic type names one wrapped type, a value model is a record shape with its own fields. |
| Full declaration | entity Name @ v (kind) { field: type, ... } — the complete field list written out |
The ordinary authoring form for any model version: every field the version has, stated directly. |
| Evolved declaration | entity Name @ v (kind) evolves @ base { add/remove/rename/replace ... } (§2.7) |
A version authored as a delta against an exact prior version instead of a complete list. Purely an authoring-time choice — never required, and switchable per version (one version can be full-form while its neighbors are evolved-form). |
| Normalized version | Neither — the expanded ModelVersion object that exists once workspace loading completes |
What both a full declaration and an evolved declaration become before validation, compatibility, signatures, or codegen ever run. A full-form and an evolved-form authoring of the same version are indistinguishable once normalized: identical fields, identical signature, identical generated output at every target (verified directly by the Q1 convergence suite, cli/tests/test_q1_convergence.py). |
| Record projection | projection Name @ v from Source @ v as alias ... { field <- alias.field, ... } (§3) |
A derived model-shaped view — its own fields, built from one or more source models'/projections' fields via joins, filters, and computed expressions. Not to be confused with an enum projection, which derives a member subset, not a field list, and has no join/filter/computation concept. |
Discovering repeated anonymous enum shapes. Since an anonymous enum has
no name, nothing prevents the same member set from being independently
hand-typed at more than one field. The compiler surfaces this as a
non-blocking ENUMSHAPE warning: when two or more field types resolve to
the exact same member set (order-independent; array/map/object-nested enum
fields are included), every occurrence is listed by its
domain.Model@version.field location. This is discovery, not a
correctness rule — it makes no claim the fields represent the same
concept, imposes no requirement to act on it, and a semantic enum
reference is never flagged (it already has a name and its own version
history). Whether to extract a shared semantic enum declaration for a
reported group remains an explicit human decision — modelable
extract-enum (see CLI Reference
§5.25)
performs that extraction once the human has made it, taking the exact
domain.Model@version.field locations this warning lists.
4. Output Targets
The
generate { }block below (§4.1–4.2) does not itself trigger code generation. It parses, validates, and round-trips through canonical formatting, but nothing in the compiler currently reads it to run an emitter — see §4.3. To actually generate artifacts today, usemodelable compile --target <name>; runmodelable capabilitiesfor the authoritative, up-to-date list of implemented targets.
4.1 Workspace-level generate block
workspace {
generate {
openapi -> "./generated/api/"
typescript -> "./generated/types/"
avro -> "./generated/avro/"
sql(postgres) -> "./generated/sql/"
jsonschema -> "./generated/jsonschema/"
docs -> "./generated/docs/"
}
}
4.1.1 Workspace-level AI configuration
LLM-backed CLI commands may read optional AI defaults from workspace.mdl. Command flags and environment variables take precedence over this block.
workspace "commerce-platform" {
ai {
provider: "anthropic"
model: "claude-opus-4-7"
}
}
The ai block is authoring configuration only. It does not affect published model or projection semantics, and changing it does not require new model or projection versions.
4.2 Per-domain override
domain customer {
generate {
openapi
typescript
avro
sql(postgres)
}
...
}
4.3 Declared target vocabulary vs. implemented targets
The generate { } block accepts a closed grammar vocabulary of target
names: openapi, typescript, avro, protobuf,
sql(postgres | mysql | clickhouse | sqlite), jsonschema, asyncapi,
docs. As noted above, declaring these does not run an emitter. Several of
these names still have no implemented emitter behind them
(asyncapi and the mysql/sqlite SQL dialects — only
postgres and clickhouse are implemented; openapi is implemented, see
modelable compile --target openapi, including schemas and explicit
paths/operations. See the capability/doc-consistency slices (B2 and the
F-slices) in
ROADMAP.md.
modelable compile --target <name> is the actual code-generation path, and
its target names and descriptions are compiler-owned data, not this table —
run modelable compile --help for the current --target choices, or
modelable capabilities / modelable capabilities --format json for the
full list with status and description:
modelable capabilities
4.4 Adapter bindings
Bindings wire a model to a specific runtime instance. They are separate from output targets.
binding customer-postgres {
model: customer.Customer @ 2
adapter: postgres
table: customers
fields: {
customerId -> customer_id
legalName -> legal_name
createdAt -> created_at
}
}
The postcard adapter marks a model (and projections sourced from it) as
encoded with a non-self-describing binary format. Generated Rust for such
models keeps #[serde(default)] but omits #[serde(skip_serializing_if =
"Option::is_none")]: omittability has no encoding in a positional format, and
skipping None fields silently corrupts the stream. JSON output for unbound
models is unchanged.
A binding's @ version selects which version's field shape is used to
resolve fields: mappings, but the postcard suppression itself applies to
every version of the bound model, not just the version named. A workspace
that encodes all versions of a model with postcard only needs one binding
for it.
Because the suppression is opt-in per model, a domain where every durable
model is postcard-encoded needs a binding on each one; an optional field on
a model with no binding keeps the JSON-shaped skip_serializing_if, which
postcard cannot decode once presence changes across a write and a read. To
catch the most common way this goes wrong — a new model added next to
already-bound ones, without a binding of its own — the compiler emits a
POSTCARD warning for any model with optional fields that has no postcard
binding, in a domain where at least one other model is bound to postcard.
There is no diagnostic for a domain with no postcard bindings at all: the
warning only fires once a domain has established that it treats postcard
encoding as the norm.
5. Toolchain
5.1 Parser
Library: Lark (Python EBNF parser)
The grammar lives in modelable.lark alongside the CLI source. This file is the canonical language definition and is versioned with the CLI. The published form of the grammar — generated from that file — is grammar.md; the generated page is kept in sync by a CI test.
.mdl file
→ Lark parser (EBNF grammar)
→ parse tree
→ Pydantic model graph
→ semantic validation
→ normalized IR
→ target emitters
Lark was chosen over ANTLR (no code generation step, native Python, good error messages) and pyparsing (cleaner grammar notation for a non-trivial language).
5.2 Language Server (LSP)
A modelable-lsp server (same repo, separate package) provides IDE support via the Language Server Protocol:
- Autocomplete for keywords, type names, domain references
- Inline diagnostics (type mismatches, broken
ref<>links, version conflicts, missing@key) - Go-to-definition for
ref<customer.Customer>→ opensCustomer.mdl - Hover showing lineage for a projected field
Implementation: pygls (Python LSP framework). VS Code extension ships a thin wrapper that starts the server. JetBrains and Neovim via standard LSP protocol.
5.3 LLM integration
LLM commands operate on .mdl text output — reviewable, diffable, committable. All LLM output is validated through the normal Lark parser pipeline before files are written.
| Command | Behaviour |
|---|---|
modelable describe Customer@2 |
Plain-English explanation of the model and its lineage |
modelable generate --from "<description or source artifact>" |
Produces a .mdl file from freeform input or supported schema/contract files |
modelable transform Customer@2 --to avro --explain |
Emits the target artifact and explains mapping decisions |
modelable suggest-projection --source Customer@2 --consumer billing |
Proposes a projection with field derivations |
6. Registry Federation and Imports
See compiler-reference.md for registry and distributed-lineage behavior. This section covers only the IDL syntax.
6.1 registry Block in workspace.mdl
A registry block turns the workspace into a named node in the federation graph. Peers are other git repositories that own domains this workspace depends on.
workspace "analytics-platform" {
description: "Analytics registry — projects across customer and orders."
registry {
id: "analytics-registry"
owns: ["analytics"]
}
peers: [
{
id: "customer-platform-registry"
git: "git@github.com:acme/customer-models.git"
branch: "main"
sync: eager
writeback: pr
},
{
id: "orders-registry"
git: "git@github.com:acme/orders-models.git"
branch: "main"
sync: eager
writeback: commit
}
]
generate {
docs -> "./generated/docs/"
typescript -> "./generated/types/"
jsonschema -> "./generated/jsonschema/"
}
}
registry block fields:
| Field | Required | Description |
|---|---|---|
id |
Yes | Stable unique name for this node. Used as registryId in lineage events and as the directory name written into peer consumers/ trees. |
owns |
Yes | Domains this node is authoritative for. |
peers entry fields:
| Field | Required | Description |
|---|---|---|
id |
Yes | Peer registry identifier. Must match the peer's own registry.id. Used in import … from registry "…". |
git |
Yes | Git remote URL. The CLI runs git fetch against this remote to sync the mirror. Authentication uses the host machine's git credential configuration. |
branch |
No | Branch to track. Default: main. |
sync |
No | eager — sync on every compile; lazy — sync on first reference (default); pinned — never sync, always use local mirror. |
writeback |
No | How consumer entries are pushed back to the peer: commit — push directly; pr — open a pull request via the git hosting API; none — skip. Default: commit. |
A workspace without a registry block operates in local mode — no sync, no write-back, no lineage log. This is the default for single-team workspaces and requires no migration.
6.2 import domain Declaration
Placed at the top of any .mdl file that references a foreign domain, before any domain, projection, or binding block.
import domain customer from registry "customer-platform-registry"
import domain orders from registry "orders-registry"
A pinned import locks to a specific model version and content signature:
import domain customer from registry "customer-platform-registry"
at customer.Customer@3#a3f8b2c1d4e5f6a7
The compiler rejects the import if the fetched model does not hash to the declared value.
6.3 Content Signature Suffix in References
Any from … @ version reference may append #<hash> to pin to a specific content:
projection BillingCustomer @ 1
from customer.Customer @ 2#a3f8b2c1d4e5f6a7 as c
{
billingCustomerId <- c.customerId
invoiceEmail <- c.email
}
The # suffix is optional in hand-authored files. The compiler always writes it into plan documents and lineage records.
6.4 Consumer Entry (Written by the CLI)
The compiler writes a small MDL file back to each upstream peer's consumers/ directory during the write-back phase. This file is never authored by hand.
// consumers/analytics-registry/CustomerOrderSummary@1.mdl
consumer {
registry: "analytics-registry"
projection: "analytics.CustomerOrderSummary@1"
uses: [
"customer.Customer@3#a3f8b2c1d4e5f6a7"
]
registeredAt: "2026-05-14T09:05:00Z"
}
6.5 LSP Changes for Federation
- Resolve
import domain … from registry "…"against the localmirror/directory. - Autocomplete foreign model names, field names, and version numbers from the mirror.
- Warn when an import references a peer not declared in
workspace.mdl. - Error when a
#-pinned reference does not match the mirrored model.
7. Implementation Map
| File | Purpose |
|---|---|
language-reference.md |
Full IDL language reference (grammar, all constructs, type system) — this document |
grammar.md |
Published grammar, generated from modelable.lark |
cli/src/modelable/grammar/modelable.lark |
Lark EBNF grammar (canonical source) |
cli/src/modelable/parser/ |
Parse tree to Pydantic IR |
cli/src/modelable/emitters/ |
Generated artifact backends |
cli/src/modelable/lsp/ |
pygls language server |
vscode/ |
VS Code extension |
cli/src/modelable/registry/ |
Local registry graph and lineage index |
8. Deferred Language Scope
- Subscription runtime execution (Phase 5)
- Registry HTTP server (no server needed for dev-time use; deferred if ever needed)
- Catalog / governance sync (Phase 3)
- GraphQL target (post-MVP)
- Non-Python parser implementations
9. CEL Expression Rules
CEL is the expression language for computed projection fields, join predicates, filters, aggregation guards, field defaults, and future runtime parameter expressions. The compiler parses and type-checks CEL and extracts field-level lineage before an expression can reach a runtime adapter.
9.1 Grammar
The compiler implements a deterministic subset of CEL. Its grammar, in EBNF:
expression := ternary
ternary := or ("?" expression ":" expression)?
or := and (("||") and)*
and := not (("&&") not)*
not := "!" not | comparison
comparison := additive (("==" | "!=" | "<" | "<=" | ">" | ">=") additive)*
additive := multiplicative (("+" | "-") multiplicative)*
multiplicative:= unary (("*" | "/" | "%") unary)*
unary := "-" unary | primary
primary := literal | list | object | function | "(" expression ")"
literal := STRING | FLOAT | INT | "true" | "false"
list := "[" expression ("," expression)* "]"
object := "{" IDENT ":" expression ("," IDENT ":" expression)* "}"
function := IDENT "(" arg ("," arg)* ")" postfix*
arg := expression | IDENT ":" expression
postfix := "." IDENT | "[" expression "]"
Precedence, from loosest to tightest:
- conditional
?: - logical
||, then&&, then unary! - comparison
== != < <= > >= - additive
+ - - multiplicative
* / % - unary
- - primary (parentheses, literals, calls, field access)
Field references are written alias.field; a bare identifier is rejected by
the validator. alias.* matches every field of an alias. Runtime namespaces
(request, auth, params) are reserved identifiers.
9.2 Token set
| Token | Form |
|---|---|
STRING |
"..." or '...', with backslash escapes |
FLOAT |
\d+\.\d+ |
INT |
\d+ |
| operators | && \|\| ! == != <= >= < > + - * / % ? : . |
| punctuation | ( ) { } [ ] , |
IDENT |
[A-Za-z_][A-Za-z0-9_]* |
9.3 Functions
The scalar function vocabulary is a closed set (deterministic helpers):
lower, upper, trim, contains, startsWith, endsWith, slice,
date, daysBetween, date_diff, truncate, coalesce, toString,
toDecimal, hashHmacSha256, hmac_sha256, now, today, decimal,
round, collect
Aggregate functions, valid only in projections with a group by clause:
count, sum, min, max, avg, countif, count_distinct, mode
min/max with two or more arguments act as scalar greatest/least,
not row aggregates. Functions not in these sets are validation errors. The
following are recognized but rejected as non-deterministic: random, uuid,
currentUser.
9.4 Validation
Expression types must be assignable to the declared destination field. Unknown
aliases, unknown fields, unsafe functions, and type mismatches are validation
errors. Runtime namespaces such as request, auth, and params are reserved
for deferred runtime contexts. Their presence in the grammar does not imply
that a runtime feature is currently available.
A field whose type is an inline enum(...) or an exact-versioned reference
to an enum-backed semantic declaration (Name @ version) may be compared
to a string literal with ==/!= either way — c.status == "active" is
valid for both. A bare, unversioned semantic-type reference is not widened
this way, since it may resolve to a non-enum underlying type.
10. Ownership, Classification, and Access
Ownership and governance metadata are definition-time contract metadata:
- Every model belongs to one domain and has an explicit owner.
- Published versions are immutable, including their governance metadata.
@piiidentifies personally identifiable information.@classificationuses the ordered levelsopen,internal,confidential,restricted, andsecret.- Projection fields inherit source restrictions through lineage. A projection may narrow access but must not silently broaden it or lower classification.
- Access declarations document
read,project, and related grants. The local compiler reports deterministic governance findings; it does not claim to be an organizational authorization service.
10.1 Access blocks
Models and projections may carry an access { } block declaring explicit
grants. A grant names a principal (a literal * for everyone, or a
principal string) and a comma-separated permission list:
domain customer {
entity Customer @ 1 (additive) {
@key customerId: uuid
ssn: string
access {
entity * [read]
property ssn * [read, redact]
}
}
}
The access block may appear in a model body and in a projection body. Two
grant forms are available:
entity <principal> [<permission>, ...]— applies to the whole model.property <fieldName> <principal> [<permission>, ...]— scopes a grant to one field.
The permission vocabulary is: read, project, subscribe, write,
transfer, manage_access, derive, redact. Projection fields inherit
source restrictions through lineage; a projection may narrow access but must
not silently broaden it or lower classification.
Generated artifacts must preserve ownership, classification, lineage, and point-of-record metadata where the target supports extensions. Otherwise the compiler must emit companion metadata or an explicit loss diagnostic.
11. Language Authority
This file is the detailed syntax and language-semantics reference. The
architecture remains authoritative for product concepts and
published-contract guarantees. The authoritative grammar is
modelable.lark — published in generated form in
grammar.md. If an example in this file conflicts with the
grammar or validator, the implementation and its tests identify a
documentation defect; they do not silently redefine the product model. Every
mdl example in this file is parsed by a CI test (docs-as-tests), so drift
between prose and grammar is caught automatically.