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.
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 { 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 :customer
has-many=:InvoiceLine :lines
has-one=:Payment :payment
computed
boolean :isOverdue({ self }) {
return self.status === :sent && self.dueOn < today()
}
string :label({ self }) {
return self.number + " · " + formatMoney(self.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=({ self }) => self.dueOn >= self.issuedOn
code="invalid_dates"
message="the due date cannot be before the issue date"
]
create :create accept=[:number, :customerId, :amount, :issuedOn, :dueOn, :notes]
update :send
validate
check :invoiceHasLines [
that=({ self }) => self.lineCount > 0
code="invalid_state"
message="an invoice needs at least one line"
]
do
set
:status=:sent
update :pay accept=[:paidAt]
validate
check :invoiceIsSent [
that=({ self }) => self.status === :sent
code="invalid_state"
message="only a sent invoice can be paid"
]
check :invoiceHasLines [
that=({ self }) => self.lineCount > 0
code="invalid_state"
message="an invoice needs at least one line"
]
do
set
:status=:paid
:paidById=({ actor }) => actor.id
when=({ self }) => self.amount > 10000
set
:needsReview=true
load=[:customer]
update :applyDiscount
arguments
decimal :percent min=0 max=100
do
set
:amount=({ self, input }) => self.amount * (1 - input.percent / 100)
read :visible
filter=({ self }) => self.status !== :cancelled
read :overdue
filter=({ self }) => self.isOverdue
sort
asc :dueOn
read :forCustomer
arguments
uuid :customerId
filter=({ self, input }) => self.customerId === input.customerId
policies
policy :staffOrOwnerReads types=[:read]
authorize-if=({ self, actor }) => isStaff(actor) || self.customer.userId === actor.id
policy :staffWrites types=[:create, :update, :destroy]
authorize-if=({ actor }) => isStaff(actor)
policy :neverDestroyPaid types=[:destroy]
forbid-if=({ self }) => self.status === :paid
The rules a reader must know
Written in the amended spelling: kind :name, not kind #name (ADR-0066).
- 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:arguments,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 names its destination entity as an atom, the tag’s value:
has-many=:InvoiceLine :lines. - 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.validateruns first, on the record with the accepted input applied, plusinput:require=[...]andcheck :label [ that code message ]for rules across fields or about stored state.doruns next, top to bottom:setwith:field=valuelines,when=condwith nested steps,load=[...],run(...) { }for one-off 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.
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.