0050. Entity file syntax: kind #name options (amended by ADR-0066)
0050. Entity file syntax: kind #name options
Amended by ADR-0066: a declaration is now kind :name options.
Amended by ADR-0067: members are &name, entities are imports, actions have one input section and files are static. The reference file and rules below use v4; the original decision and quotations remain historical.
Status
Accepted. Amends ADR-0002 (what the tree contains). Builds on ADR-0049.
Amended 2026-10-05 (evening) by ADR-0066: names and references are atoms, so a declaration is now kind :name options and the reference file below is written in that spelling. The rulings quoted in this record are the ones as they were made on the morning of 2026-10-05, with #name; the sample code, the rules list and the option tables below have been moved to the amended spelling, which is the only one the docs use.
Date
2026-10-05
Deciders
operator (Saulo Vallory)
Context
An entity file declares one entity: its data, the operations on it and the rules around them (ADR-0049). It is written in MX concise syntax, which is indentation-based Marko syntax parsed by MX, a separate project (ADR-0041). MX returns a static tree of tags and attributes and runs nothing (ADR-0002).
The vocabulary copied from Ash gave each kind of line its own shape: attribute="title" type="string" allow-nil=false, belongs-to="author" destination="user", update="publish", calculate="excerpt" type="string" with a child value. A reader had to learn where the name goes for each tag. ADR-0049 freed the vocabulary from Ash; this record is the shape it took.
MX concise syntax already has a shorthand for an id: #name after a tag. It arrives in the tree as the tag’s id. Using it for every name means one sigil carries every name.
Decision
The operator’s rulings of 2026-10-05, rulings of 2026-10-04, sections “Entity file syntax (2026-10-05, operator)” and “Entity file syntax, continued (2026-10-05 morning, operator)”. In summary:
- Line shape. “Every declaration is
kind #name options: the tag is the kind,#name(the id shorthand) names it. Names are unique within their scope; Mesh checks that. One sigil only;:nameis not used in entity files.” (:nameis allowed only as the label of acheck, ADR-0053.) - Entity.
entity #Invoice table="invoices". - Attributes. “The type is the tag:
uuid #id primary-key,string #number unique,enum #status values=[...] default="draft",timestamp #insertedAt on="create". Required by default;nullablemarks the exception.” Shape rules for one field (min,max,match) go on its line. - Relationships. “The destination is the tag’s value:
belongs-to=Customer #customer,has-many=InvoiceLine #lines,has-one=Payment #payment.” - Computed fields. “One
computedsection replacescalculationsandaggregates.” A calculation is a typed field with a method body,boolean #isOverdue({ self }) { return ... }. A rollup iscount #lineCount of="lines"orsum #total of="lines.amount";ofis a path string checked at build time against generated path types, with a function form where a path cannot express it. - The record. “Functions receive the record as
self(fixed key), besideactor,input,context. Not a name derived from the entity.” - Sections.
attributes,relationships,computed,actions,policies; inside an action,arguments,validateanddo. Sections may come in any order; only the order of lines inside a section matters.
Later rulings of the lead, delegated by the operator, fill in what the syntax rulings left open (rulings of 2026-10-04, sections “Rulings on the user-docs author’s choices (2026-10-05, lead under delegation)” and “Rulings after the review of the user docs (2026-10-05, lead under delegation)”):
- Attribute types.
uuid,string,integer,float,decimal,boolean,enum,date,datetime,timestamp. - Rules about one field go on its line, always:
minandmax(length for a string, value for a number) andmatch. Acheckis only for rules across fields or about stored state (ADR-0053). “Never two ways to write the same thing.” - Relationships.
belongs-to=List #listcreates the attributelistId: the relationship’s name plusId.nullablemakes a relationship optional, the same word as on an attribute. - Rollups.
count,sum,avg,min,max. - What
selfholds depends on where the function runs; the table is in ADR-0053.
Actions, steps and validations are in ADR-0052 and ADR-0053; policies in ADR-0055; expressions in ADR-0056; file names and the MX host in ADR-0051.
The reference file
This is the worked reference the user docs and the code follow. It uses every v1 construct once.
// src/domain/billing/invoice.mesh.mx
import { Customer } from "./customer.mesh.mx"
import { InvoiceLine } from "./invoice-line.mesh.mx"
import { Payment } from "./payment.mesh.mx"
import { formatMoney, isStaff } from "./invoice.helpers"
entity :Invoice table="invoices"
attributes
uuid :id primary-key
string :number unique match=/^INV-\d+$/
enum :status values=[:draft, :sent, :paid, :cancelled] default=:draft
decimal :amount min=0
date :issuedOn
date :dueOn
datetime :paidAt nullable
string :notes nullable max=2000
boolean :needsReview default=false
uuid :paidById nullable
timestamp :insertedAt on=:create
timestamp :updatedAt on=:update
relationships
belongs-to :customer entity=Customer
has-many :lines entity=InvoiceLine
has-one :payment entity=Payment
computed
boolean :isOverdue() {
return &status === :sent && &dueOn < today()
}
string :label() {
return &number + " · " + formatMoney(&total)
}
count :lineCount of="lines"
sum :total of="lines.amount"
actions auto=[:read, :destroy] on:load=&visible
always types=[:create, :update]
validate
check :dueAfterIssue [
that=() => &dueOn >= &issuedOn
code="invalid_dates"
message="the due date cannot be before the issue date"
]
create :create
input
&number
&customer
&amount
&issuedOn
&dueOn
¬es
update :send
validate
check :invoiceHasLines [
that=() => &lineCount > 0
code="invalid_state"
message="an invoice needs at least one line"
]
do
set
&status=:sent
update :pay
input
&paidAt
validate
check :invoiceIsSent [
that=() => &status === :sent
code="invalid_state"
message="only a sent invoice can be paid"
]
check :invoiceHasLines [
that=() => &lineCount > 0
code="invalid_state"
message="an invoice needs at least one line"
]
do
set
&status=:paid
&paidById=({ actor }) => actor.id
when=() => &amount > 10000
set
&needsReview=true
load=[&customer]
update :applyDiscount
input
decimal :percent min=0 max=100
do
set
&amount=({ input }) => &amount * (1 - input.percent / 100)
read :visible
filter=() => &status !== :cancelled
read :overdue
filter=() => &isOverdue
sort
asc &dueOn
read :forCustomer
input
uuid :customerId
filter=({ input }) => &customer.id === input.customerId
policies
policy :staffOrOwnerReads types=[:read]
authorize-if=({ actor }) => isStaff(actor) || &customer.userId === actor.id
policy :staffWrites types=[:create, :update, :destroy]
authorize-if=({ actor }) => isStaff(actor)
policy :neverDestroyPaid types=[:destroy]
forbid-if=() => &status === :paid
The rules a reader must know
Written in syntax v4 (ADR-0067).
- A declaration is
kind :name options: the tag says what it is,:namenames it. Names are unique within their scope. - Sections group declarations:
attributes,relationships,computed,actions,policies; inside an action:input,validate,do. - An attribute’s type is its tag. Attributes are required unless marked
nullable. Rules about one field (min,max,match) go on its line, never in acheck. - A relationship is
has-many :lines entity=InvoiceLine, withInvoiceLineimported by relative path. Members of this entity are referenced with&name; declaration names and fixed-set values remain atoms. - A computed field is either a typed field with a body, or a rollup (
count,sum,avg,min,max) withof=a path. - Functions receive
{ self, input, actor, context }. A function whose body is one expression (an arrow, or a method body that is a singlereturn) is translated when Mesh can translate it, and then also runs in SQL; anything else runs in memory. Where SQL is required (a filter, a sort, a policy), an expression that cannot be translated is a build error. actions auto=[...]generates the plain actions of those types, named after the type. Every written action istype :name.on:load=&namesays which read Mesh uses when it loads this entity through a relationship; without it, the auto read.- One
inputsection takes declared members (&number,&customer) or declares typed arguments (decimal :percent). Member lines take no options; duplicate input names fail.validateruns first, on the record with member inputs applied:check :label [ that code message ]covers cross-field or stored-state rules.doruns next, top to bottom:setwith&field=valuelines,when=condwith nested steps,load=[&customer], andrun(...) { }for plain code. alwaysunderactionstakes an action body and applies it to every action in its scope.- A policy has a scope (
types=,actions=, or neither for all) and checks. A policy passes when none of itsforbid-ifholds and, if it has anyauthorize-if, at least one holds. Every policy covering an action must pass; an action no policy covers is forbidden. - Files end in
.mesh.mx; one entity per file; the folder undersrc/domain/is the module. Two folders may declare the same entity name: imports distinguish them. Files are static, with no conditionals or loops that change the tree. Conditions arewhen=on a policy or check, or part of an expression.
Not in v1
lock, relate, after-commit, reusable steps defined in MX (step :slugify), the raw-SQL escape hatch and bypass are planned or rejected and are not shown to users (ADR-0053, ADR-0055, ADR-0056).
Options considered
Option A: kind #name options for every line (chosen)
Pros: one shape for attributes, relationships, computed fields, actions, arguments and policies; uses an MX shorthand that exists; the type, the relationship kind or the action type is the first word a reader sees.
Cons: depends on MX parsing #id after a space (ADR-0051); the set of tag names grows with every attribute type, so the contracts must be generated from the type registry.
Option B: Ash’s shapes (ADR-0034)
Pros: already on main.
Cons: several shapes to learn; see ADR-0049.
Option C: A name attribute on every tag (attribute name="title" type="string")
Pros: no dependency on MX shorthands.
Cons: longer lines, and two words (attribute, type) where one carries the meaning.
Trade-off analysis
Option A minimises what a user must remember, which the operator ranks first. Its costs fall on Mesh: contracts per attribute type, an MX dependency, and a name-uniqueness check Mesh owns. Option C keeps Mesh independent of MX’s shorthands at the price of every line in every user file.
Consequences
- Easier: the whole syntax fits in a short list of rules, each taught where it applies; an agent can write an entity file from them.
- Harder: the attribute-type tags (
string,uuid,decimal, …) are generated from the type registry inpackages/model, so the open question of ADR-0037 (contracts or registries as the source of truth) now decides tag names too. requiredby default inverts Ash’sallow_nil? truedefault. A missingnullableis a build-time and type-level error, never a silent null.selfreplaces the record name derived from the entity (post,todo) in every function.- New attribute types (
integer,float,decimal,date,timestamp) and the rollupssum,avg,min,maxenter the registry. - The reference file was corrected after the review of the user docs: the one-field rule
amountNotNegativebecamedecimal #amount min=0, thealwaysexample became the cross-field checkdueAfterIssue(which neededdate #issuedOn), thesendaction’s checkinvoiceHasNoLinesbecameinvoiceHasLines(the label said the opposite of its condition, rulings of 2026-10-04, section “Rulings after the review of the contributor docs (2026-10-05, lead under delegation)”).#labelkeeps its singlereturn: it callsformatMoney(self.total), cannot be translated, and so runs in memory, which is not an error for a computed field (ADR-0056). It was corrected once more after the second review of the user docs:update #payacceptspaidAtinstead of taking it as an argument, because a value stored in a field as it was sent is accepted; its checks are named for the rules they carry (invoiceIsSent,invoiceHasLines); andupdate #applyDiscountis theargumentsexample (rulings of 2026-10-04, section “Rulings after the second review of the user docs”). - The code on
mainstill has the M1 vocabulary until the realignment task (ADR-0064).
Action items
- Realignment task: contracts, fixtures and the model follow this record;
examples/blogis rewritten in this syntax. - M7: the rollups
count,sum,avg,min,maxand the generated path typesofis checked against.