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 |
float |
64-bit float |
bool |
Boolean |
uuid |
UUID 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 |
array<T> |
Ordered list |
map<K,V> |
Key-value map |
ref<Domain.Model> |
Cross-domain reference |
enum(a, b, c) |
Inline enumeration |
json |
Arbitrary JSON value, opaque to Modelable; maps to serde_json::Value (Rust), unknown (TypeScript), {} (JSON Schema) |
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.2 Field declaration syntax
@annotation fieldName: Type
@annotation optional?: Type
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, 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. |
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.
model Customer @ 1 (additive) {
@key customerId: uuid
legalName: string
createdAt: timestamp
}
model 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.
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
}
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.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
on [created, updated, deleted]
{
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 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.
4. Output 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 Target catalog
| Target | Output |
|---|---|
openapi |
OpenAPI 3.1 schema objects per model and projection |
typescript |
TypeScript interfaces with x-modelable JSDoc lineage tags |
avro |
Avro Schema JSON, one file per model version |
protobuf |
.proto file per domain |
sql(postgres / mysql / sqlite) |
CREATE TABLE DDL |
jsonschema |
JSON Schema 2020-12 with x-modelable vendor extensions |
asyncapi |
AsyncAPI 3.0 message schemas for event models |
docs |
Markdown documentation with lineage tables |
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
}
}
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.
.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 |
cli/src/modelable/grammar/modelable.lark |
Lark EBNF grammar |
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, 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.
The supported compiler subset includes literals, field selection, boolean and arithmetic operators, comparisons, conditional expressions, list membership, and the deterministic helper functions implemented by the validator. 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.
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, 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.
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. If an example here conflicts with the grammar or validator, the implementation and its tests identify a documentation defect; they do not silently redefine the product model.