Skip to content

Modelable Language Reference

Authority: This document defines the current .mdl language. 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
  • @decorator annotations 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., uuidstring 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 second remove sees 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:

  • leftleft join keeps all rows from the primary source even when there is no matching join row. Join fields from a left source 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 @pii on 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 (a pick requires 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 projections may only target entity or aggregate models.
  • 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 an array, 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 omit that 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 after key, with an optional asc/desc direction (asc is the default and rarely written explicitly).
  • unique — optional, defaults to false.

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

  • index may only target entity or aggregate models (the only kinds that carry @key).
  • One index declaration per model version — declaring it twice for the same (model, version) is a compile error.
  • Secondary index names must be unique within one index block.

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, use modelable compile --target <name>; run modelable capabilities for 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> → opens Customer.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 local mirror/ 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:

  1. conditional ?:
  2. logical ||, then &&, then unary !
  3. comparison == != < <= > >=
  4. additive + -
  5. multiplicative * / %
  6. unary -
  7. 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.
  • @pii identifies personally identifiable information.
  • @classification uses the ordered levels open, internal, confidential, restricted, and secret.
  • 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.