Vocabulary mapping: Ash DSL to Mesh
Vocabulary mapping: Ash DSL to Mesh
Date: 2026-10-04. Status: the mapping covers everything the roadmap touches. The alignment of the contracts (the first part of milestone M1, roadmap, “Vocabulary alignment first”) is done for every row that is on main: rows marked “on main (aligned)” in Section 3 and rows marked “Done” in Section 4 are applied in packages/compiler/src/contracts.ts, with tests. The rows for later milestones are not applied. Two rows are held for the operator (X1, X2, Section 5), and the research lookups are checked and recorded in Section 6.
0. The naming rule
Mesh names are Ash’s names in kebab-case with the trailing ? dropped. belongs_to becomes belongs-to, uuid_primary_key becomes uuid-primary-key, allow_nil? becomes allow-nil, require_atomic? becomes require-atomic. The mapping is mechanical and one-to-one: _ becomes -, a trailing ? is dropped, nothing else changes. A trailing ? is disallowed by ruling, not by MX. The MX maintainers first measured on MX main at 7a404916 that an attribute name takes letters, digits and ._:- and never ? (recorded in rulings of 2026-10-04, section “Lead decisions of the architecture-docs brief”), and later confirmed that the data target could allow it in attribute names; MX still allows it in tag names, as Marko does, and MX decision 144 reserves ?= as a future attribute operator and bans a trailing ? in attribute names on every target. The operator ruled on 2026-10-04 (table “Rulings after the decision review”, row “Trailing ? in attribute names”) that Mesh does not use it, in attribute names or in tag names. Reasons: in TypeScript name? means optional, so allow-nil? reads wrong; it would permanently block a future name?=expr syntax; it diverges from Marko’s translator; and a bare boolean attribute already carries the predicate meaning (public, allow-nil=false). The spelling is unchanged: Ash names in kebab-case with ? dropped. Mesh declares no tag and no attribute whose name ends in ? or contains _; a test asserts it (contracts.test.ts, “no tag name and no attribute name ends in ? or contains _”). _ would be accepted by MX in tag and attribute names; Mesh does not use it. Scope of the rule. It applies to tag names and attribute names only: the vocabulary. Three things are not vocabulary and the rule does not touch them. (1) Attribute values are strings the author writes: insertedAt in create-timestamp="insertedAt" is a value. (2) The names a resource author chooses for attributes, relationships and actions (the fixture’s authorId, commentCount) are values too; they stay as the author writes them. (3) Everything inside an expression is JavaScript, parsed by Babel, and must be valid JavaScript: a call name keeps Ash’s snake_case as an identifier (action_type("read"), before_action(...), set_attribute(...)), because action-type("read") would parse as a subtraction. Ash’s ^actor, ^arg, ^context, ^ref and ^tenant templates have no valid JavaScript spelling; Section 3.9 maps them to the declared parameters of the arrow function.
Decision record. 2026-10-04, recorded in rulings of 2026-10-04, section “Lead decisions of the architecture-docs brief”; a working decision (not an operator ruling); the operator may overrule. Alternative considered: Ash’s exact spelling with _ where MX allows it (belongs_to) and a different spelling only for booleans, which would have renamed every tag on main and left two conventions. Kebab-case matches what is on main, avoids renaming twice, and the operator revisits naming for MX after v1 (ADR-0034). Consequence: names that differ from Ash only by this rule are not deviations and are not listed in Section 4.
1. Introduction
The vocabulary of Mesh is the set of tag and attribute names a resource file may use. A resource file is a .mx file: Marko syntax, parsed by MX, a separate project. Mesh invents tag names, not syntax. For each tag name there is one MX tag contract (CustomTag) that says which attributes, children and parents the tag allows, plus analyze hooks for rules a declaration cannot express. MX is core in Mesh, not an adapter (ADR-0043), so the 26 contracts live in the compiler package, packages/compiler/src/contracts.ts, with tests in packages/compiler/test/. (M0 moved them there. The alignment kept the count at 26: defaults and timestamps left, create-timestamp and update-timestamp arrived. The contracts.ts:N line numbers on this page are those of the file before the alignment, at a3b52f4; rows marked aligned describe the new form in the Mesh name column and the old one in the “On main today” column.) They are the only vocabulary code so far. PR #1 (https://github.com/svallory/mesh/pull/1) added them; PR #2 (https://github.com/svallory/mesh/pull/2) adopted MX’s unknownTags option; PR #4 (https://github.com/svallory/mesh/pull/4) changed one test.
Where the 26 came from. The names were copied from an MX test fixture (packages/targets/data/fixtures/ash-resource/post.mx in the MX repository; MX project notes, getting-started section 1). The Mesh copy is packages/compiler/test/fixtures/post.mx; it has 31 tag occurrences and 25 distinct names. The 26th contract, destroy, was added in round 2 of the review of PR #1. Several rules were inferred by the developer in PR #1 or required in four rounds of review of PR #1. The operator never ruled on them.
The ruling. On 2026-10-04 the operator ruled (rulings of 2026-10-04, table “Rulings after the decision review”, row “Vocabulary”):
Copy Ash’s DSL for now (names and structure). After v1, review it and optimise for what feels natural in MX. Resource files and every example always use MX concise syntax.
It is recorded in ADR-0034 (copy Ash now, optimise after v1) and, for the third sentence, ADR-0041. The consequence stated in ADR-0034: where the contracts or the roadmap deviate from Ash in names, defaults or inferred rules, they are aligned with Ash unless MX cannot express it. Only those exceptions go to the operator. A second reason is used on this page for the exceptions that remain: the deviation conflicts with a decision record (the record is named). After v1 the vocabulary is reviewed for what reads naturally in MX; that is a breaking rename for every resource file, planned separately (ADR-0034, Consequences).
What “copy Ash’s DSL” means here. Ash is the Elixir resource framework Mesh is modelled on. Its DSL is organised in sections (attributes, actions, …), each holding entities (attribute, create, …) that take options. Section 3 maps every Ash section, entity and option that Mesh v1 touches to a Mesh tag or attribute, with its status. Section 4 lists each place where contracts.ts or the roadmap differs from Ash and the alignment that follows. Section 5 holds the two deviations that go to the operator. Section 6 holds the points where Ash’s form is not in the research, so the alignment cannot be written yet. Section 7 shows the fixture after alignment. Appendix A is the vocabulary on main, as it is.
Sources used on this page: Ash features (cited by section), Ash DSL and extensions, research synthesis, roadmap, rulings of 2026-10-04, MX project notes, getting-started, MX project notes, contract-extensions, MX project notes, updates, and the contracts file contracts.ts:N (line N of packages/compiler/src/contracts.ts). Where a detail (a default, an option name) is not in the research, the page says “not in the research” and does not fill it from memory.
2. How to read the mapping
Positional arguments. Ash writes attribute :subject, :string: name and type are positional (Ash features section 12 examples). An MX tag has one default attribute (written attribute="subject", and arriving in the tree as an attribute named value) plus named attributes (MX project notes, getting-started section 1). The rule used here: the first positional becomes the default attribute; a later positional becomes a named attribute carrying the name Ash gives it (type for attribute and argument, keys for identity name, keys, destination for a relationship, relationship_path for an aggregate), and otherwise the name Mesh has now, marked “inferred”. The positional names were checked against Ash’s documentation (Ash 3.34.0, checked 2026-10-04, G9 in Section 6, https://hexdocs.pm/ash/dsl-ash-resource.html): attribute name, type; belongs_to name, destination; has_many name, destination; has_one name, destination; create name (also read, update, destroy); calculate name, type, calculation (the third is optional); count name, relationship_path (the other aggregates add field; custom adds type); identity name, keys; argument name, type; policy condition (optional); validate validation; change change; prepare preparation. The two names that were inferred before the check, relationship_path and the relationship’s destination, are Ash’s own: the relationship’s destination is destination= on belongs-to and has-many. policy and calculate are the two on-main tags whose positional Mesh does not name as Ash does (the policy’s condition arrives as the default attribute value; calculate’s third positional is the child tag value); see D22, D27 and G2, G8.
Concise syntax. Every example is concise syntax, as in the fixture: indentation nests, no angle brackets (ADR-0041).
Names. The rule of Section 0. Ash atoms (:read) become strings ("read"). A bare boolean attribute means true (public); allow-nil=false is the explicit form (contracts.ts:116-122 reads a BooleanLiteral).
Status values. On main (with line); M3, M5, M7, M8 (the milestone the roadmap adds it in); M4 (the expression milestone); M6 (extension host); after v1 (roadmap section 6 or a ruling); not planned (roadmap section 6, “Not planned at all”); out of M8 (named as out of scope of that milestone); not in roadmap (neither scheduled nor excluded).
3. The mapping
The “Contract check” column is machine-readable and is what the acceptance test reads (contracts.test.ts, describe “roadmap M1 acceptance test 7”). Every row whose status says “on main” carries one or more specs separated by ;, or an explicit n/a (reason) when the row is not about a tag or attribute (today only row 110, recorded deviation D37: an expression’s contents are not vocabulary); any other row carries -. The test pins the list of n/a rows, so a row cannot leave the check by being relabelled. A spec is tag: tokens: a plain token is an attribute the contract declares and a clean fixture uses; >name is a child tag the contract declares and a clean fixture nests in that tag; attr=v1,v2 is an attribute that a clean fixture sets to each of those values. A row marked “on main” whose cell does not parse fails the test, and so does a tag, attribute, child or value no clean fixture uses.
The “On main today” column and its line numbers describe the contracts before the alignment (a3b52f4). “On main (aligned)” means the Mesh name column is now what the contracts declare, with a positive and a negative test.
3.1 Resource level
| # | Ash section / entity / option (default, type) | Source | Mesh name | On main today | Status | Contract check |
|---|---|---|---|---|---|---|
| 1 | use Ash.Resource, domain: (an option of the resource, a module) |
Ash features §1.1 after the table | resource domain="blog" |
domain: str(), contracts.ts:318 |
on main; aligned (the value is a name, not a module) | resource: value domain |
| 2 | Ash.Domain DSL: sections domain, resources, execution, authorization |
Ash features §1.3 | none | none | not in roadmap | - |
| 3 | data_layer option (default Ash.DataLayer.Simple) |
Ash features §1.1 | none in the resource file; the project configuration names the adapter (roadmap M1, “Project configuration”) | none | not planned as a tag | - |
| 4 | table, which Ash puts in the data layer’s own section, for example postgres do table "users" end |
Ash DSL and extensions §3.3 (line 666), §4 (line 750) | resource table="posts" |
table: str(), contracts.ts:317 |
on main; exception X1 | resource: table |
| 5 | authorizers, extensions, notifiers options of use Ash.Resource (the policy authorizer is opt-in per resource) |
Ash features §1.1, §6.8 | project configuration, not a tag | none | M6, M8; exception X2 | - |
| 6 | resource section options: description, base_filter, default_context, trace_name, short_name, plural_name, require_primary_key?, others (defaults: not in the research) |
Ash features §1.1 row 5 | the Ash names in kebab-case | none | not in roadmap | - |
| 7 | code_interface (generates callable functions) |
Ash features §1.1 row 4 | none; generated action functions replace it (my reading of roadmap M2) | none | not planned as a tag | - |
| 8 | changes section (a change applied to every create/update/destroy) |
Ash features §1.1 row 7 and note | changes |
none | not in roadmap (M6 named reusable changes are the nearest) | - |
| 9 | preparations section (applies to every read) |
Ash features §1.1 row 8 | preparations |
none | not in roadmap | - |
| 10 | validations section (on: default [:create, :update]) |
Ash features §1.1 row 9, §9 | validations |
none | not in roadmap | - |
| 11 | pipelines (named bundle, pipe_through) |
Ash features §1.1 row 10 | pipelines, pipe-through |
none | not in roadmap | - |
| 12 | multitenancy |
Ash features §1.1 row 13 | multitenancy |
none | after v1 (roadmap section 6, ADR-0009 proposed) | - |
| 13 | temporal |
Ash features §1.1 row 14 | temporal |
none | not in roadmap | - |
3.2 Attributes
| # | Ash section / entity / option (default, type) | Source | Mesh name | On main today | Status | Contract check |
|---|---|---|---|---|---|---|
| 14 | section attributes |
Ash features §1.1 row 1 | attributes |
contracts.ts:330-337; required child of resource (320) |
on main | attributes: >attribute |
| 15 | uuid_primary_key name (sets writable? false, public? true, primary_key? true, type :uuid, default &Ash.UUID.generate/0; allow_nil? is false and is not accepted as an option; no generated?; checked, Ash 3.34.0, checked 2026-10-04, https://hexdocs.pm/ash/dsl-ash-resource.html) |
Ash features §2.3 | uuid-primary-key="id" |
uuid-primary-key, contracts.ts:338-342 |
on main (aligned) | uuid-primary-key: value |
| 16 | uuid_v7_primary_key |
Ash features §2.3 | uuid-v7-primary-key |
none | not in roadmap | - |
| 17 | integer_primary_key (type integer, generated? true) |
Ash features §2.3 | integer-primary-key |
none | not in roadmap | - |
| 18 | create_timestamp name (writable? false, match_other_defaults? true, allow_nil? false, default &DateTime.utc_now/0, type Ash.Type.UTCDatetimeUsec; public? is not overridden, so it stays false; primary_key? false; checked, Ash 3.34.0, checked 2026-10-04, https://hexdocs.pm/ash/dsl-ash-resource.html), used as create_timestamp :inserted_at |
Ash features §2.3, §12.1 | create-timestamp="insertedAt" |
timestamps, contracts.ts:355 |
on main (aligned): create-timestamp |
create-timestamp: value |
| 19 | update_timestamp name (same options as create_timestamp, plus update_default &DateTime.utc_now/0; public? false, allow_nil? false, writable? false; checked, Ash 3.34.0, checked 2026-10-04), used as update_timestamp :updated_at |
Ash features §2.3, §12.1 | update-timestamp="updatedAt" |
timestamps, contracts.ts:355 |
on main (aligned): update-timestamp |
update-timestamp: value |
| 20 | attribute name, type |
Ash features §2.2, §12 | attribute="subject" type="string" |
contracts.ts:343-354 |
on main | attribute: value type |
| 21 | option allow_nil? (default true) |
Ash features §2.2 | allow-nil=false |
required flag, contracts.ts:348 (opposite polarity) |
on main (aligned): allow-nil=false |
attribute: allow-nil |
| 22 | option public? (default false) |
Ash features §2.2 | public |
public flag, contracts.ts:349 |
on main; recorded in the model as Ash records it, nothing in v1 reads it (roadmap M1; ADR-0035) | attribute: public |
| 23 | option default (no default; “Value set on create”) |
Ash features §2.2 | default |
default: { literalOnly: true }, contracts.ts:351 |
on main Checked (G5, Ash 3.34.0, checked 2026-10-04, https://hexdocs.pm/ash/dsl-ash-resource.html): default’s type is (-> any) | mfa | any: a zero-arity function, an MFA tuple or a literal. The contract keeps literals only (R14); a function default is for the milestone that evaluates defaults. |
attribute: default |
| 24 | option update_default |
Ash features §2.2 | update-default |
none | not in roadmap Checked (G5): update_default has the same type (-> any) | mfa | any. |
- |
| 25 | option constraints (type-specific, for example one_of, max_length) |
Ash features §2.2, §2.4 | constraints |
none (today values plays the one_of role) |
on main (aligned): constraints with one_of on atoms; other constraints not in roadmap |
attribute: constraints |
| 26 | options description, sensitive? (false), source |
Ash features §2.2 | description, sensitive, source |
none | not in roadmap | - |
| 27 | options primary_key? (false), writable? (true), generated? (false) |
Ash features §2.2 | primary-key, writable, generated |
none | not in roadmap | - |
| 28 | options select_by_default? (true), always_select? (false) |
Ash features §2.2 | select-by-default, always-select |
none | not in roadmap | - |
| 29 | options filterable? (true, or :simple_equality), sortable? (true), match_other_defaults? (false) |
Ash features §2.2 | filterable, sortable, match-other-defaults |
none | not in roadmap | - |
| 30 | types string, boolean, uuid, datetime (short names in the registry) |
Ash features §2.1 | type="string" etc. |
ATTRIBUTE_TYPES, contracts.ts:43-50 |
on main | attribute: type=string,boolean,uuid,datetime |
| 31 | types integer, float |
Ash features §2.1 | type="integer", type="float" |
number, contracts.ts:45 |
on main (aligned): integer, float |
attribute: type=integer,float |
| 32 | enumerated values: :atom with constraints [one_of: [...]] (Ash features §12, get-started 236-261), or a module using Ash.Type.Enum (declares values/0, Ash features §2.1) |
Ash features §2.1, §12 | type="atom" constraints={ one_of: ["draft", "published"] } |
type="enum" values=[...], contracts.ts:47, 348 |
on main (aligned): type="atom" with constraints={ one_of: [...] } |
attribute: constraints type=atom |
| 33 | the other built-in types (decimal, date, map, utc_datetime, ci_string, and so on; 31 short names) |
Ash features §2.1 | the Ash names in kebab-case | none | not in roadmap | - |
| 34 | {:array, type} composite |
Ash features §2.1 | none | none | not in roadmap | - |
| 35 | NewType, embedded resources |
Ash features §2.1, §2.4 | none | none | not planned (roadmap section 6) | - |
3.3 Relationships
| # | Ash section / entity / option (default, type) | Source | Mesh name | On main today | Status | Contract check |
|---|---|---|---|---|---|---|
| 36 | section relationships |
Ash features §1.1 row 2 | relationships |
contracts.ts:357-363 |
on main | relationships: >belongs-to >has-many |
| 37 | belongs_to name, destination (the destination is a module) |
Ash features §3, §12 | belongs-to="author" destination="user" |
belongs-to, contracts.ts:364-368 |
on main aligned: resource= became destination= (G9) |
belongs-to: value destination |
| 38 | has_many name, destination |
Ash features §3, §12.1 | has-many="comments" destination="comment" |
has-many, contracts.ts:369-373 |
on main aligned: resource= became destination= (G9) |
has-many: value destination |
| 39 | has_one name, destination |
Ash features §3 | has-one |
none | M7 | - |
| 40 | many_to_many with a join resource (through, join_relationship, …) |
Ash features §3.1, §3.3 | many-to-many |
none | not planned (roadmap M7 out of scope) | - |
| 41 | belongs_to creates <name>_id automatically (example: representative_id) |
Ash features §12 (get-started 561-567) | implied foreign-key attribute, an author-level name (a value, Section 0); Ash generates representative_id for belongs_to :representative |
none (the fixture writes authorId, post.mx:17) |
M7 (roadmap M7: “adds its foreign-key attribute”) Checked (G4, Ash 3.34.0, checked 2026-10-04): the default source_attribute of belongs_to is <name>_id; its destination_attribute defaults to id. |
- |
| 42 | belongs_to option allow_nil? (on the generated attribute; default not in the research) |
Ash features §3.1 | allow-nil |
none | not in roadmap Checked (G4): allow_nil? on belongs_to defaults to true; it also exists on has_one (default true) and does not exist on has_many. |
- |
| 43 | belongs_to options define_attribute?, attribute_type, attribute_writable?, attribute_public?, attribute_always_select?, primary_key? |
Ash features §3.1 | the Ash names in kebab-case | none | not in roadmap | - |
| 44 | shared options source_attribute, destination_attribute (guessed by a transformer when absent) |
Ash features §3.1; Ash DSL and extensions §2.7 rows 7-8 | the Ash names in kebab-case | none | not in roadmap (M7 needs the derivation rule) Checked (G4): has_one/has_many source_attribute defaults to id; their destination_attribute has no default value in the option table and is guessed by a transformer from the last segment of the source resource’s module name, underscored, plus _id (MyApp.Blog.Post gives post_id; source src/lib/ash/resource/transformers/has_destination_field.ex, https://github.com/ash-project/ash). Other defaults on all three: public? false, writable? true, filterable? true, sortable? true. |
- |
| 45 | shared options public?, description, sort, default_sort, filterable?, sortable?, writable?, read_action, domain, and a nested filter entity; has_many option limit |
Ash features §3.1 | the Ash names in kebab-case | none | not in roadmap | - |
| 46 | manual, no_attributes?, through (traversal), from_many?, offset, could_be_related_at_creation? |
Ash features §3.1-3.2 | the Ash names in kebab-case | none | not in roadmap | - |
| 47 | manage_relationship change |
Ash features §3.4 | none | none | not in roadmap (roadmap M7 out of scope: “managing related records in a write”) | - |
3.4 Actions
| # | Ash section / entity / option (default, type) | Source | Mesh name | On main today | Status | Contract check |
|---|---|---|---|---|---|---|
| 48 | section actions |
Ash features §1.1 row 3 | actions |
contracts.ts:375-384 |
on main | actions: >create >update >destroy >read |
| 49 | section option defaults (list of action types; “creates a simple action of each specified type, with the same name as the type”) |
Ash features §1.2 | attribute of actions: actions defaults=["read", "destroy"] |
child tag defaults, contracts.ts:378, 385-389 |
on main (aligned): attribute of actions |
actions: defaults |
| 50 | section option default_accept (Ash 3 default: no attributes accepted) |
Ash features §1.2 | default-accept on actions |
none | not in roadmap | - |
| 51 | create name |
Ash features §4.1 | create="open" |
contracts.ts:390-395 |
on main | create: value |
| 52 | update name |
Ash features §4.1 | update="close" |
contracts.ts:396-401 |
on main | update: value |
| 53 | destroy name |
Ash features §4.1 | destroy="archive" |
contracts.ts:402-407 (not in the fixture; added in review of PR #1, round 2, item 6) |
on main | destroy: value |
| 54 | read name |
Ash features §4.1 | read="published" |
contracts.ts:408-413 |
on main | read: value |
| 55 | generic action (returns, run, constraints) |
Ash features §4.1 | none | none | not planned (roadmap section 6) | - |
| 56 | option accept (create, update, destroy only; :* = all public attributes; accept [] is valid) |
Ash features §4.1, §12 (get-started 317-335) | accept=["title", "body"] |
create (392), update (398); none on destroy (404) | on main (aligned): accept on create, update and destroy |
create: accept; update: accept; destroy: accept |
| 57 | option primary? (defaults are primary unless one exists) |
Ash features §1.2, §4.1 | primary |
none | not in roadmap | - |
| 58 | options description, public?, skip_unknown_inputs, touches_resources, transaction? (reads default false, writes true) |
Ash features §4.1 | the Ash names in kebab-case | none | not in roadmap | - |
| 59 | option require_atomic? (update and destroy, default true; require_atomic? false appears in Ash features §12.1) |
Ash features §4.9, §12.1 | require-atomic=false |
none | M5 (roadmap M5 already uses Ash’s name; it is required on an action with an opaque change or one that reads the stored record) | - |
| 60 | options atomic_upgrade? (false), atomic_upgrade_with |
Ash features §4.9 | the Ash names in kebab-case | none | not in roadmap | - |
| 61 | manual, manual? |
Ash features §4.10 | the Ash names in kebab-case | none | not planned (roadmap section 6) | - |
| 62 | create options upsert?, upsert_identity, upsert_fields, upsert_condition, return_skipped_upsert? |
Ash features §4.7 | the Ash names in kebab-case | none | after v1 (roadmap section 6) | - |
| 63 | bulk actions (Ash.bulk_create/update/destroy) |
Ash features §4.8 | none (a run-time call, not a tag) | none | after v1 (roadmap section 6) | - |
| 64 | destroy option soft? |
Ash features §4.13 | soft |
none | not in roadmap | - |
| 65 | options error_handler, notifiers, action_select, require_attributes, allow_nil_input, delay_global_validations?, skip_global_validations?, multitenancy |
Ash features §4.1 | the Ash names in kebab-case | none | not in roadmap (notifiers: outbox, after v1) | - |
| 66 | nested change entity (24 built-ins such as set_attribute, relate_actor; a module; change {Module, opts}) |
Ash features §4.3, §2.5, §12 | change= taking a call or an arrow function |
contracts.ts:414-417; in create (393), update (399), destroy (405) |
on main; the expression form is deviation D16 | change: value |
| 67 | nested validate entity (24 built-ins; message inside the block); allowed in create, update, destroy, read and generic |
Ash features §4.4, §1.2, §12 | validate= with message= |
contracts.ts:418-422; in update (399) and destroy (405) only |
on main (aligned): in create, update, destroy and read | validate: value; create: >validate; update: >validate; destroy: >validate; read: >validate |
| 68 | validation options: resource-level validate has where, on, only_when_valid?, message, description, before_action?, always_atomic?; the action-level options are not in the research |
Ash features §9 | where, on, only-when-valid, message, description, before-action, always-atomic |
only message (contracts.ts:420) |
message on main; always_atomic? relates to the M5 protocol; the rest not in roadmap; G7 Checked (G7, Ash 3.34.0, checked 2026-10-04, https://hexdocs.pm/ash/dsl-ash-resource.html): the action-level validate options are where (default []), only_when_valid? (false), message, description, before_action? (false; not with atomic actions) and always_atomic? (false); there is no on. The action-level change options are where, only_when_valid?, description, always_atomic?; no message, no on. on exists only on the resource-level validate and change (default [:create, :update]), because an action already fixes its type. None of these is added to the contracts now. |
validate: message |
| 69 | nested argument name, type; options description, constraints, allow_nil?, public?, sensitive?, default (defaults of these: not in the research) |
Ash features §4.2, §1.2 | argument="title" type="string" |
none | M5 | - |
| 70 | nested prepare on read (built-ins: set_context, build, before_action, after_action; “preparations take no action-input arguments, they rewrite a query”) |
Ash features §4.5, §1.2 | prepare= |
none | M5 | - |
| 71 | hooks written as built-in changes: before_action(fn changeset, context), after_action(fn changeset, record, context), before_transaction, after_transaction (and around_* on the changeset API) |
Ash features §4.6 | change=before_action(...), change=after_action(...), change=before_transaction(...), change=after_transaction(...) (call names are JavaScript identifiers) |
none | M5 for hooks inside the transaction and after commit (roadmap M5); which Ash hook maps to which roadmap phase is G6; before_transaction not in roadmap Checked (G6, Ash 3.34.0, checked 2026-10-04, https://hexdocs.pm/ash/Ash.Changeset.html): inside the transaction run before_action (after validations and changes, before the data layer action) and after_action (after the data layer action, success only), and around_action; outside run before_transaction (before it starts) and after_transaction (after it ends, whether it committed or rolled back; it receives {:ok, record} or {:error, reason}). So after_transaction is not “after commit”. Signatures: change before_action(fn changeset, context), change after_action(fn changeset, record, context), change before_transaction(fn changeset, context), change after_transaction(fn changeset, result, context). Mapping these to the roadmap’s phases is M5 work. |
- |
| 72 | read-side hooks written as built-in preparations: prepare before_action(fn query, context), prepare after_action(fn query, records, context) |
Ash features §4.5, §4.6 | prepare=before_action(...), prepare=after_action(...) (call names are JavaScript identifiers) |
none | not in roadmap (M5 names preparations, not their hooks) | - |
| 73 | nested pagination on read: keyset? (false), offset? (false), via_data_layer?, default_limit, countable (true), max_page_size (250), stable_sort (primary key), required?, paginate_by_default? |
Ash features §4.11, §1.2 | pagination child of read with offset, keyset, default-limit, max-page-size, countable, stable-sort, required |
none | M3 (offset and keyset; the other options not in roadmap) | - |
| 74 | nested filter on read |
Ash features §1.2 | filter= |
contracts.ts:423-426 |
on main; expression form D16 | filter: value |
| 75 | sorting a read: Ash’s read action has no sort entity; relationships have sort/default_sort options; the build preparation exists, its options are not in the research |
Ash features §1.2, §3.1, §4.5 | sort=["-insertedAt"] until G1 is answered |
contracts.ts:427-431 |
on main; G1 Checked (G1, Ash 3.34.0, checked 2026-10-04, https://hexdocs.pm/ash/Ash.Query.html): build passes its keyword list to Ash.Query.build/2, whose options include filter, filter_input, sort, sort_input, default_sort, distinct_sort, limit, offset, load, strict_load, select, ensure_selected, aggregate, calculate, distinct, context; example prepare build(sort: [song_rank: :desc], limit: 10). Sort strings: no prefix or + is ascending, ++ ascending nulls first, - descending, -- descending nulls last; comma separated or a list. So the way to sort a read in Ash is a prepare build(sort: ...) call, not a sort entity; Mesh’s sort=["-insertedAt"] uses the - prefix as Ash does. Whether sort stays a tag or becomes prepare=build(...) is decided in M3/M5 (D17); the contracts keep sort. |
sort: value |
| 76 | nested pipe_through, metadata |
Ash features §1.2 | pipe-through, metadata |
none | not in roadmap | - |
| 77 | read options get?, get_by, modify_query, timeout, manual |
Ash features §4.1 | the Ash names in kebab-case | none | not in roadmap | - |
3.5 Identities
| # | Ash section / entity / option (default, type) | Source | Mesh name | On main today | Status | Contract check |
|---|---|---|---|---|---|---|
| 78 | section identities; identity name, keys (example identity :email, [:email]) |
Ash features §9, §12.1 | identities; identity="email" keys=["email"] |
none | after v1 (rulings of 2026-10-04, “After v1”) | - |
| 79 | identity options where, nils_distinct? (true), eager_check?, eager_check_with, pre_check?, pre_check_with, description, field_names, message, all_tenants? |
Ash features §9 | the Ash names in kebab-case | none | after v1 | - |
3.6 Calculations
| # | Ash section / entity / option (default, type) | Source | Mesh name | On main today | Status | Contract check |
|---|---|---|---|---|---|---|
| 80 | section calculations |
Ash features §1.1 row 12 | calculations |
contracts.ts:451-454 |
on main | calculations: >calculate |
| 81 | calculate name, type, expression, for example calculate :full_name, :string, expr(...) (the third positional’s name is not in the research) |
Ash features §5.1 | calculate="excerpt" type="string" plus the expression |
contracts.ts:455-463, expression as child value (464-467) |
on main; G2 Checked (G2, Ash 3.34.0, checked 2026-10-04, https://hexdocs.pm/ash/dsl-ash-resource.html): the signature is calculate name, type, calculation \\ nil; the third positional is named calculation, is optional (a do block or option can carry it instead), and takes expr(...), a module, {module, opts} or a function (records, context) -> results. A multi-line form is a do block holding options, not a multi-line expression. The contract keeps the child tag value for the body until the milestone that implements calculations designs it (D27, M7); this task does not restructure calculate. |
calculate: value type >value |
| 82 | module-based calculation (calculate :duration, :string, Module) |
Ash features §5.1, §12.1 | a named, reusable calculation | none | M6 (roadmap M6) | - |
| 83 | options async?, constraints, description, public?, sensitive?, load, allow_nil?, filterable?, sortable?, field?, multitenancy |
Ash features §5.1 | the Ash names in kebab-case | none | not in roadmap | - |
| 84 | nested argument |
Ash features §5.1 | argument |
none | not in roadmap | - |
3.7 Aggregates
| # | Ash section / entity / option (default, type) | Source | Mesh name | On main today | Status | Contract check |
|---|---|---|---|---|---|---|
| 85 | section aggregates |
Ash features §1.1 row 11 | aggregates |
contracts.ts:469-472 |
on main | aggregates: >count |
| 86 | count name, relationship_path (count :assigned_ticket_count, :reported_tickets; relationship_path is Ash’s name for the second positional, G9, Ash 3.34.0, checked 2026-10-04) |
Ash features §5.2 | count="comment_count" relationship-path="comments" |
count, attribute relationship, contracts.ts:473-477 |
on main (aligned): relationship-path |
count: value relationship-path |
| 87 | kinds exists, first, sum, list, max, min, avg, custom (example sum :duration_seconds, :tracks, :duration_seconds) |
Ash features §5.2, §12.1 | the Ash names (kebab-case), with field |
none | M7 (“count and the other aggregates”; which ones is not stated) |
- |
| 88 | shared options relationship_path, read_action, filter, description, default, public?, filterable?, sortable?, sensitive?, authorize?, multitenancy; kind options field (all but exists), uniq?, include_nil?, sort, join_filter |
Ash features §5.2 | the Ash names in kebab-case | none | not in roadmap | - |
3.8 Policies (Ash.Policy.Authorizer, a separate extension)
| # | Ash section / entity / option (default, type) | Source | Mesh name | On main today | Status | Contract check |
|---|---|---|---|---|---|---|
| 89 | section policies; option default_access_type (:filter) |
Ash features §1.4, §6.1-6.2 | policies |
contracts.ts:433-436 |
on main; moves to ext-policies in M8 (roadmap M8) |
policies: >policy |
| 90 | policy condition do ... end (options description, access_type, condition, error_message; the condition is “a check or list of checks”) |
Ash features §6.1 | policy=action_type("read"); several: policy=[action_type("read"), ...] |
policy action-type="read" / policy action="publish", contracts.ts:437-445 |
on main (aligned): a check call or a list, D22 Checked (G8, Ash 3.34.0, checked 2026-10-04): Ash’s policy takes an optional condition (policy condition \\ nil); with none it always applies. This is not applied: Mesh’s policy keeps a required condition until M8 (D24). |
policy: value >authorize-if |
| 91 | built-in checks (22), among them action(:name) and action_type(:read) or action_type([:update, :destroy]), always, actor_attribute_equals, relates_to_actor_via |
Ash features §6.3, §12.1 | action("publish"), action_type("read") (call names, JavaScript identifiers) |
action, action-type attributes (440-441) |
action and action_type on main; the other 20 not in roadmap |
policy: value |
| 92 | authorize_if check |
Ash features §1.4, §6.1 | authorize-if= |
authorize-if, contracts.ts:446-449 |
on main | authorize-if: value |
| 93 | forbid_if check |
Ash features §1.4, §6.1 | forbid-if= |
none | M8 | - |
| 94 | authorize_unless, forbid_unless |
Ash features §1.4, §6.1 | the Ash names in kebab-case | none | not in roadmap | - |
| 95 | decision rule: checks run top to bottom, the first decisive one decides; no applicable policy means forbidden | Ash features §6.1, §6.8 | same rule | none | M8 (ADR-0036) | - |
| 96 | the authorizer is opt-in per resource (authorizers: [Ash.Policy.Authorizer]) |
Ash features §6.8 | see row 5 | none | M8; exception X2 | - |
| 97 | bypass, policy_group |
Ash features §1.4, §6.1 | the Ash names in kebab-case | none | out of M8 (roadmap M8) | - |
| 98 | field_policies (field_policy, field_policy_bypass) |
Ash features §1.4, §6.1 | the Ash names in kebab-case | none | out of M8 | - |
| 99 | policy access_type (:strict, :filter, :runtime) |
Ash features §6.2 | access-type |
none | out of M8 | - |
| 100 | Ash.can? and related |
Ash features §6.7 | a generated can function per action |
none | M8 (roadmap M8) | - |
3.9 Expressions, and the references inside them
Ash writes a translatable expression as expr(...) and refers to the caller and the call with templates (Ash features section 5.5). Inside a resource file an expression is JavaScript, parsed by Babel (MX project notes, getting-started section 1), so it must be valid JavaScript and the naming rule of Section 0 does not apply to it. Mesh’s roadmap starts from an arrow function’s parsed form (roadmap M4): a translatable expression may use only its declared parameters and registered functions, and a free variable is a build error. The registry of functions and operators is Mesh’s own and starts small (roadmap M4: attribute and parameter references, literals, comparison and boolean operators, string length, assignment to an attribute). The exact expression form is designed in M4; this table records what Ash has and which Mesh element corresponds.
| # | Ash section / entity / option (default, type) | Source | Mesh name | On main today | Status | Contract check |
|---|---|---|---|---|---|---|
| 101 | expr(...) wrapper, for example calculate :full_name, :string, expr(first_name <> " ") and authorize_if expr(public == true) |
Ash features §5.1, §6.1 | the arrow function’s body; whether an expr(...) call wraps it is designed in M4 |
arrow functions: ({ post }) => ... (post.mx:24, 29); contracts.ts:78 |
M4 (conversion from MX’s parsed form); D16 | - |
| 102 | operators: 15 plus is_nil; registered IsNil, Eq, NotEq, In, LessThan, GreaterThan, LessThanOrEqual, GreaterThanOrEqual, and + * - / <> || and &&; and/or are boolean expressions; aliases equals, not_equals, gt, lt, gte, lte |
Ash features §5.3 | JavaScript’s operators (valid JavaScript is required); the registry entry for each is designed in M4 | none | M4 registry: comparison and boolean operators, numeric + and - (roadmap M4); the rest not in roadmap |
- |
| 103 | functions: 39 registered, by module name (Length, StringLength, Contains, Now, Ago, If, Round, …); the names used inside expr(...) are not in the research |
Ash features §5.4 | one call per registry entry, named with an Ash name as a JavaScript identifier (snake_case) | none | M4 registry: string length only; G3 Checked (G3, Ash 3.34.0, checked 2026-10-04, src/lib/ash/filter/filter.ex and each use Ash.Query.Function, name: :...): the 39 are module names; the call names inside expr(...) are: ago, at, composite_type, contains, count_nils, date_add, datetime_add, fragment, from_now, get_path, has, is_distinct_from, is_nil, is_not_distinct_from, if, intersects, lazy, length, - (module Minus), now, range_adjacent, range_contains, range_lower, range_overlaps, range_upper, error, rem, round, today, type, start_of_day, string_downcase, string_ends_with, string_join, string_length, string_position, string_split, string_starts_with, string_trim; custom_expressions application config can add more. Built-in changes, validations and checks are not functions inside expr(...): they are DSL calls (change set_attribute(...), validate present(...), authorize_if action_type(:read)). That the validation and check builtins are plain JavaScript-style identifiers in Mesh is not verified (open, G3). |
- |
| 104 | special forms exists/2, path.exists/2, parent/1, lazy/1, error expressions, inline aggregates |
Ash features §5.4 (guide references) | calls with Ash’s names as JavaScript identifiers | none | M7 for relationship traversal (exists); the others not in roadmap |
- |
| 105 | template ^actor(:key) and ^actor([:key1, :key2]) |
Ash features §5.5 | the arrow function’s declared parameter actor: ({ post, actor }) => ... actor.id |
the same, post.mx:17 |
M4 (the actor is bound from the scope, roadmap M4); exact form designed in M4; D37 | - |
| 106 | template ^arg(:name) |
Ash features §5.5 | a declared parameter carrying the action’s arguments; exact form designed in M4 | none | M5 (arguments) | - |
| 107 | template ^context(:key) |
Ash features §5.5 | the declared parameter context, from the scope (ADR-0007); exact form designed in M4 |
none | M4 | - |
| 108 | template ^ref(:key), ^ref([:path], :key) |
Ash features §5.5 | none | none | not in roadmap | - |
| 109 | template ^tenant() |
Ash features §5.5 | none | none | after v1 (multitenancy) | - |
| 110 | a reference to an attribute inside expr(...) is a bare name (public == true) |
Ash features §6.1 | a property of a declared parameter: post.state |
the same, post.mx:24 |
on main; recorded deviation D37 | n/a (the contracts do not look inside an expression; the form is designed in M4) |
That is 110 rows.
4. Deviations
“Now” is what contracts.ts or the roadmap has today. “Alignment” is the change the ruling implies. Exceptions go to Section 5; open research to Section 6.
4.1 Names and structure
| # | Mesh now | Ash | Alignment |
|---|---|---|---|
| D1 | kebab-case names (uuid-primary-key, belongs-to, authorize-if, action-type) and booleans without ? (public, required) against Ash’s snake_case and ? |
uuid_primary_key, belongs_to, authorize_if, action_type, allow_nil?, public? |
Not a deviation: the naming rule of Section 0 maps one to the other. Only the names that differ by more than the rule (required against allow-nil, timestamps, number, enum) are listed below. Done: the names in contracts.ts follow the rule; a test checks every tag and attribute name. |
| D3 | attribute="title" type="string": name as the default attribute, type as a named attribute |
attribute :subject, :string (Ash features §12) |
Aligned in the only way MX holds positionals (Section 2). |
| D4 | uuid-primary-key="id" (338-342) |
uuid_primary_key :id, with writable? false, public? true (Ash features §2.3) |
The name follows the rule; the model applies the same defaults. |
| D5 | one timestamps tag (355) |
two entities, create_timestamp name and update_timestamp name (Ash features §2.3, §12.1). Whether Ash also has a combined entity: not in the research. |
Align: replace with the two entities, each with an explicit name. Done: create-timestamp and update-timestamp, each optional and at most once, each with a required name. |
| D6 | required (348), a flag meaning “not null” |
allow_nil?, default true (Ash features §2.2) |
Align: replace with allow-nil=false. The default (nullable) is the same. Done: allow-nil=false; required is gone. |
| D7 | public flag (349) |
public?, default false (Ash features §2.2) |
The name follows the rule. The roadmap already treats the meaning as Ash does: recorded, not read in v1 (roadmap M1; ADR-0035). Done: public is unchanged. |
| D8 | type number (45) |
integer, float, decimal (Ash features §2.1); no number |
Align: replace number with integer and float. decimal is not in the roadmap. Done: integer and float; number is gone. |
| D9 | type enum with values (47, 348); calculate.type excludes it (57, 459) |
an atom with constraints [one_of: [...]] (Ash features §12), or an Ash.Type.Enum module with values/0 (Ash features §2.1) |
Align: type="atom" with constraints={ one_of: [...] }. MX can express it: a contract attribute may omit its type (contracts.ts:351 does), and analyze reads the object literal’s Babel node. Whether the untyped attribute may keep literalOnly for an object literal is not checked; if not, drop literalOnly for constraints and check it in analyze. The calculate.type list (R9) then excludes atom. Done: type="atom" with constraints={ one_of: [...] }; values and enum are gone. An untyped contract attribute keeps literalOnly for an object literal: the negative fixture literal-only-constraints shows an identifier is rejected and every positive fixture with constraints parses (Appendix B). analyze reads the object literal’s Babel node, rejects a constraints that is not an object literal, has no one_of or has an unknown constraint, and rejects one_of that is not a list of non-blank, non-repeated strings, at least one. |
| D10 | belongs-to="author" resource="user" (366) |
belongs_to name, destination, the destination being a module (Ash features §12) |
Align: Ash’s name for the second positional is destination (G9, Ash 3.34.0, checked 2026-10-04). Done: destination= on belongs-to and has-many; resource= is gone. |
| D11 | has_one absent (358-362); roadmap M7 adds it |
has_one (Ash features §3) |
Align: add has-one, same shape as has-many, in M7. |
| D12 | foreign key: roadmap M7 says a belongs-to adds its foreign-key attribute (the fixture’s authorId) |
belongs_to :representative creates representative_id (Ash features §12, get-started 561-567) |
Not vocabulary: the generated name is a value (Section 0). Ash’s rule is <name>_id; the roadmap uses authorId. Which one Mesh generates is settled when M7 is designed. |
| D13 | defaults=[...] is a child tag of actions (378, 385-389) |
an option of the actions section (Ash features §1.2) |
Align: an attribute of actions; remove the defaults tag. Done: defaults is an attribute of actions; the defaults tag is gone. |
| D14 | no accept on destroy (404) |
accept on create, update and destroy (Ash features §4.1) |
Align: add accept to destroy. Done: accept on destroy, checked like the others. |
| D15 | validate allowed in update and destroy only (399, 405, 419); none in create (393) or read (411) |
validate nested in create, read, update, destroy and generic (Ash features §1.2) |
Align: allow validate in create and read. The review of PR #1 records no reason for the omission. Done: validate allowed in create and read. |
| D16 | change, validate, filter, authorize-if take an arrow function with destructured parameters, ({ post, actor }) => ... (78, 415-416, 419, 424, 447); the roadmap converts arrow functions (roadmap M4) |
change/validate take a built-in call (24 changes, 24 validations, Ash features §4.3-4.4), a module, or {Module, opts} (Ash features §12); filter, calculations and policy checks take expr(...) (Ash features §5.1, §6.1) |
Align in form where the research documents the helper: built-in calls with Ash’s snake_case names as JavaScript identifiers (set_attribute(...)). MX can express it: a call is accepted by function attributes (MX project notes, contract-extensions §5, table, last row). The form of a translatable expression (arrow function with declared parameters, or an expr(...) call around one) is designed in M4 (D37); an arrow function stays the form of code that is not a built-in (Ash’s custom change module, Ash features §2.5). Because roadmap M4 and M5 are written around arrow functions, Section 7 keeps arrow functions until then. Roadmap edits: Section 4.3. |
| D17 | sort=["-insertedAt"] as a child of read (411, 427-431) |
no sort entity on a read action (Ash features §1.2) |
Open: G1. Checked (G1): Ash sorts a read through prepare build(sort: ...), with - for descending as in Mesh’s strings; see row 75. The sort tag stays until M3/M5 decides the form. |
| D18 | pagination named only as “new vocabulary” (roadmap M3) | nested pagination with 9 options (Ash features §4.11) |
Align: a pagination child of read with Ash’s option names; M3 implements offset and keyset. |
| D19 | preparations (roadmap M5): no name given | nested prepare on read (Ash features §1.2, §4.5) |
Align: a prepare child of read. |
| D20 | arguments (roadmap M5): no shape given | argument name, type with options (Ash features §4.2) |
Align: an argument child of every action. |
| D21 | roadmap M5: an action with an opaque change, or a change or validation that reads the stored record, is not atomic and must say Ash’s require_atomic? set to false |
require_atomic?, default true, update and destroy (Ash features §4.9) |
Already aligned in the roadmap; spelled require-atomic=false (Section 0). The proposal put require-atomic=false on publish, which validates post.title; the contracts add the attribute in M5, and the fixture of Section 7 does not carry it yet (it is M5 vocabulary). |
| D22 | policy takes action or action-type as string attributes (440-441) |
the policy’s condition is a check call, action_type(:read), action(:name), “a check or list of checks” (Ash features §6.1, §6.3) |
Align: policy=action_type("read"), a list for several (call names are JavaScript identifiers, Section 0). MX can express it: the default attribute may be untyped and take a call (MX project notes, contract-extensions §5; contracts.ts:351 for an untyped attribute); analyze reads the call. Done: policy=action_type("read"), policy=action("publish"), or a list. analyze accepts the two checks on main (row 91), each with one string argument; an unknown check, a non-call, a call with another argument count or type, and an empty list are errors. |
| D23 | exactly one of action, action-type; both is an error (analyzePolicy, 233-244) |
conditions combine as a list (Ash features §6.1) | Align: the “not both” rule goes; a list of checks replaces it (D22). Done: the “not both” rule is gone; a list of checks replaces it. |
| D24 | authorize-if required, at least one, in every policy (443) |
a policy’s entities are four check forms (Ash features §6.1); whether a policy may have zero checks is not in the research (G8) | Align at M8: “at least one check of any of the four kinds” as an analyze rule. Until then keep. Checked (G8, Ash 3.34.0, checked 2026-10-04): in Ash a policy may have no condition (it then always applies) but needs at least one check of the four kinds; the source error is “Policies must have at least one check.” (src/lib/ash/policy/policy.ex). So the M8 rule “at least one check of any kind” is Ash’s, and Mesh’s required condition is a deviation to revisit in M8 (Ash’s condition is optional). |
| D25 | forbid-if (roadmap M8) |
forbid_if (Ash features §6.1) |
The name follows the rule. authorize-unless and forbid-unless are not in the roadmap. |
| D26 | action-type limited to create, read, update, destroy (ACTION_TYPES, 40) |
five action types including generic action (Ash features §4.1) |
Align when generic actions arrive (not planned, roadmap section 6). |
| D27 | calculate="excerpt" type="string" with a child tag value holding the body (455-467; fixture lines 36-39) |
calculate :name, :type, expr(...) in one line (Ash features §5.1) |
Open: G2. Checked (G2): Ash’s third positional is calculation, optional, and takes expr(...), a module, {module, opts} or a function; no multi-line expression form exists. Alignment follows in M7 with the child tag value’s replacement; not done in this task. |
| D28 | count with attribute relationship (475) |
count name, relationship_path (Ash features §5.2; the positional’s name is inferred, Section 2) |
Align: rename the attribute to relationship-path. Done: relationship-path. |
| D29 | only count (471-472); roadmap M7 says “count and the other aggregates” |
9 kinds (Ash features §5.2) | Align: M7 names the kinds it implements from Ash’s list, with field on all but exists. |
| D30 | table on resource (317) |
table in the data layer’s section (rows 3-4) |
Exception X1. (domain is aligned: row 1.) |
| D31 | attributes required in a resource (320); no primary-key rule |
ValidatePrimaryKey and VerifyPrimaryKeyPresent verifiers exist (Ash DSL and extensions §2.7); when the key is required is not in the research |
Align: a build verifier “a primary key is present” in M1 (roadmap M2 selects by key). |
| D32 | a has-one target gets an implicit unique index on the foreign key, with no new vocabulary; declared identities stay after v1 (roadmap M7; ADR-0045, Proposed) |
Ash does not enforce it; it truncates silently (research synthesis §6, item 7). identity name, keys is Ash’s way to declare uniqueness (Ash features §9) |
Recorded deviation from Ash, in ADR-0045. It is an emitted-schema rule, not vocabulary, and it is ruling-neutral, so it is not an operator exception. It adds a guarantee Ash lacks. |
| D33 | camelCase names the author chose in the fixture (commentCount, insertedAt, authorId; post.mx:25, 17, 42) |
snake_case atoms (inserted_at, representative_id; Ash features §12) |
Not vocabulary: names an author chooses are values and stay as written (Section 0). The fixture in Section 7 keeps its camelCase. |
| D34 | policies are in ext-policies, “first-party, on by default”; deny by default arrives with it (roadmap section 3, M8; ADR-0036) |
the authorizer is opt-in per resource: “a resource has policy authorization only if it declares authorizers: [Ash.Policy.Authorizer]” (Ash features §6.8) |
Exception X2. |
| D35 | hooks: roadmap M5 names no tag | hooks are changes: change before_action(fn) (Ash features §4.6) |
Align: change=before_action(...) (rows 71-72; the call name is a JavaScript identifier, Section 0). MX can express it (a call, MX project notes, contract-extensions §5). Checked (G6): before_action and after_action run inside the transaction, before_transaction before it, after_transaction after it ends (committed or rolled back); see row 71. |
| D36 | the contract has only message on validate (420) |
where, on, only_when_valid?, before_action?, always_atomic? on resource-level validate (Ash features §9); action-level options not in the research |
Align when the roadmap needs them (always-atomic and only-when-valid bear on the M5 protocol); G7. Checked (G7): the action-level validate options are where, only_when_valid?, message, description, before_action?, always_atomic? (no on); the action-level change options are where, only_when_valid?, description, always_atomic?. Not added to the contracts; M5 adds what the protocol needs. |
| D37 | expression references go through a declared parameter (post.state, actor.id) |
bare attribute names inside expr(...) and the templates ^actor(:id), ^arg(:name), ^context(:key), ^ref(...) (Ash features §5.5, §6.1) |
Recorded deviation, kept (the scope rule of roadmap M4 and ADR-0010): JavaScript has no implicit record scope, and roadmap M4 allows a translatable expression only its declared parameters and registered functions (a free variable is a build error). The templates have no JavaScript spelling; they map to declared parameters (rows 105-107), exact form designed in M4. Not aligned. |
| D38 | the M4 registry is Mesh’s own and small | 15 operators, 39 functions (Ash features §5.3-5.4); call names inside expr(...) are not in the research |
Align: each registry entry takes an Ash name written as a JavaScript identifier; G3. Checked (G3): the call names are listed in row 103. |
4.2 Rules the developer inferred or review required
Each row is a rule in contracts.ts that is not a name. “Ash” is what the research says; many rules are not in the research at all. “Origin” cites PR #1 (https://github.com/svallory/mesh/pull/1), whose description records the developer’s list “Inferred by me” and rounds 1 to 4 of its review; it does not record who proposed each review finding.
| # | Rule and origin | Mesh now | Ash | Alignment |
|---|---|---|---|---|
| R1 | values required for enum, and rejected for any other type. Origin: “required” from MX project notes, contract-extensions §10; the reverse is the developer’s inference |
analyzeAttribute, 178-186 |
constraints depend on the type (Ash features §2.4); whether an inapplicable one is an error: not in the research |
Replaced by D9: constraints allowed only for types that accept them. Done: analyzeAttribute checks constraints by type. |
| R2 | policy exactly one of action, action-type. Origin: “one of” in MX project notes, contract-extensions §10; “exactly one” is the developer’s |
233-244 | see D23 | Align (D23). Done: D23. |
| R3 | closed type list of six and closed action list of four. Origin: the developer (“the notes say ‘fixed list’ but give no list”) | 43-50, 40 | 31 short names plus custom types (Ash features §2.1) | Align (D8, D26). Done: the type list (D8). The action list stays four (D26). |
| R4 | authorize-if required in policy; child value required in calculate. Origin: the developer |
443, 461 | not in the research | Keep for now; D24 and G2 revisit them. |
| R5 | table optional on resource. Origin: the developer, from MX project notes, contract-extensions / the MX data-target note |
317 | not in the research | Keep; X1. |
| R6 | accept allowed on update; change and validate repeatable on update. Origin: the developer |
398-399 | accept on update (Ash features §4.1) |
Already consistent. |
| R7 | every contract closed: closed() fills attributes, attributeTags, children (leaf tags take no children, no attribute tags). Origin: review of PR #1, round 1 correction and round 2 items 1, 3, 4 |
62-64; header 18-21 | not in the research | Keep: an MX-contract rule with no Ash counterpart; M6 composes contracts for extensions (ADR-0021). |
| R8 | every non-function attribute is literalOnly, so resource=post is an error. Origin: review of PR #1, round 2 item 2 |
18-23, 66-77 | not in the research | Keep: MX’s static tree cannot evaluate an identifier. |
| R9 | calculate.type excludes enum. Origin: review of PR #1, round 2 item 5 |
57, 459 | not in the research | Keep; follows D9. Done: calculate.type excludes atom. |
| R10 | destroy contract with change and validate. Origin: review of PR #1, round 2 item 6 |
402-407 | destroy is an Ash action type (Ash features §4.1) | Align (adds accept, D14). Done: destroy has accept. |
| R11 | one resource per file, not in the contracts (MX has no root cardinality); enforced by the model. Origin: review of PR #1, round 2 item 7, round 3 item 2 |
header 34-36 | “one module per resource” (research synthesis §1, first paragraph) | Already consistent. |
| R12 | defaults items must be action types, no repeats, no blanks, not empty. Origin: rounds 2 (item 8), 3 (items 3, 5), 4 (item 2) |
288-307, 267-276 | defaults names action types (Ash features §1.2) |
Align the type check; keep the rest (not in the research). Done: the defaults type check is unchanged; it reads the actions attribute. |
| R13 | list items: no blank, no repeats, in one_of (was values), defaults, accept, sort; sort=[], defaults=[] and one_of: [] rejected, accept=[] accepted |
247-264, 266-276, 188-190 | accept [] is valid in Ash (Ash features §12, get-started 317-335); the rest not in the research |
Keep. |
| R14 | default: a string, number or boolean literal; negative numbers read; null, arrays, objects rejected; checked against the type; an atom default must be in one_of. Origin: round 2 item 9, round 3 item 1 |
106-124, 193-224 | default is “Value set on create” (Ash features §2.2); whether it can be a function: not in the research (G5) |
Keep; the enum check follows D9. Checked (G5): Ash allows a function (zero-arity), an MFA tuple or a literal for default and update_default; Mesh keeps literals. |
| R15 | names may not be empty or whitespace; empty sections allowed; validate message may not be empty. Origin: round 2 item 10, round 3 item 4, round 4 item 3 |
146-159, 32-36, 421 | not in the research | Keep. |
| R16 | unknown tags rejected at any depth (unknownTags: "reject"). Origin: PR #2 (https://github.com/svallory/mesh/pull/2), MX e65707a0 (MX project notes, updates) |
tests | not in the research | Keep. |
4.3 Roadmap text that changes with the alignment
These are edits to roadmap, listed so none is forgotten. The first item was made in the alignment pull request; the others are not made here.
- Done in the alignment pull request: the roadmap’s section 1 item 6 and M1’s “Vocabulary alignment first” bullet now say the alignment is done, and the M1 model bullet quotes the aligned names (seven types,
allow-nil,constraints,create-timestamp,update-timestamp, thedefaultsattribute). The “M1, model” item below is kept as the record of what changed. - M1, alignment. The alignment is the first part of M1; its acceptance test (“every row marked ‘on main’ has a contract and a fixture that match it”) is checked against Section 3 with the naming rule of Section 0.
- M1, model. Replace “attributes of the six types” with the aligned type list (D8, D9);
requiredbecomesallow-nil(D6);timestampsbecomescreate-timestampandupdate-timestamp(D5); “the four action kinds withacceptanddefaults” becomesdefaultsas an attribute ofactions(D13) withaccepton destroy as well (D14); add the primary-key verifier (D31); the not-implemented list uses the aligned names; names an author chooses stay as written (D33); the registry/contract drift test (ADR-0037) covers the new type list. - M3. “New vocabulary” for pagination becomes the
paginationchild (D18);sortwaits on G1. - M4. The expression form is designed here: arrow functions with declared parameters stay (D37), built-in calls take Ash’s snake_case names as JavaScript identifiers (D16), the first registry takes Ash’s names (D38);
validatein create and read adds positions to classify (D15). - M5. Hooks are
change=before_action(...)and the other three, as JavaScript calls (D35), with read-side hooks asprepare(rows 71-72);prepareandargumentnamed (D19, D20);before_transactionis not scheduled; validation options (D36) if the protocol needs them.require-atomicis already aligned. - M7.
has-one(D11); the generated foreign-key name is settled (D12); aggregate kinds (D29) andrelationship-path(D28); the unique-index rule stays (D32). - M8.
forbid-if(D25); policy conditions are check calls (D22, D23); the rule “at least one check of any kind” (D24); if the operator rules against X2, roadmap section 3 (“on by default”) and ADR-0036 change. - Risk 9 (“New vocabulary arrives in M3, M5, M7 and M8”): the answer to “one way to do each thing” is now: copy Ash.
5. Exceptions for the operator
Two deviations remain after applying the test of the ruling to each. A deviation is an exception only if MX cannot express the Ash form, or if it conflicts with a decision record.
| # | Deviation | Why it does not align | Options | Recommendation |
|---|---|---|---|---|
| X1 | table is an attribute of resource; in Ash it sits in the data layer’s own section (postgres do table "users" end, Ash DSL and extensions §3.3 and §4) and works because AshPostgres.DataLayer is registered as an extension of that resource. |
Conflicts with ADR-0001: a data adapter is replaceable and a resource file does not name its adapter (the project configuration does, roadmap M1). A section named after an adapter has no place in the file. The table name still has to live somewhere. | (a) keep table on resource; (b) move it to the adapter’s configuration, keyed by resource name; © drop it and derive the name from the resource name |
(a). Both v1 SQL adapters are built on Drizzle (ADR-0014) and use the same table name; (b) and © can follow after v1 if a second kind of adapter needs otherwise. |
| X2 | Policies are on by default through ext-policies; in Ash the policy authorizer is opt-in per resource (Ash features §6.8). |
Conflicts with ADR-0036 and roadmap section 3 (“first-party, on by default”). ADR-0036 is a working decision the operator may overrule; it is not an operator ruling. | (a) keep on by default with deny by default (ADR-0036); (b) opt-in per project through the configuration that enables extensions (M6); © opt-in per resource with a tag | (a). With opt-in, a resource that forgets the declaration is silently open, which breaks the conservative-defaults principle (roadmap section 2, principle 3). Ash’s own reason for opt-in (a data layer that does not use policies) does not apply while policies are the only authorization. |
Exceptions removed by the test: names with _ and ? (answered by the MX maintainers and decided in Section 0); enumerated types (D9), hooks (D35) and policy conditions (D22), which MX can express; camelCase (D33), which is not a conflict with any ruling; has_one uniqueness (D32), a recorded deviation in ADR-0045 rather than a vocabulary exception.
6. Ash’s form: the lookups, checked
These were gaps in the research. Each was looked up in Ash’s documentation and source and the answer was then fact-checked. All facts below are Ash 3.34.0, checked 2026-10-04; the documentation is https://hexdocs.pm/ash/ (dsl-ash-resource.html, dsl-ash-policy-authorizer.html, Ash.Query.html, Ash.Changeset.html) and the source is https://github.com/ash-project/ash (main). Only checked facts are recorded; an unchecked sub-claim stays listed as open. “Applied” says whether the fact changed the contracts; vocabulary of a later milestone is recorded and the code is left alone.
| # | Fact | Source | Where it lands | Applied |
|---|---|---|---|---|
| G1 | build is a pass-through to Ash.Query.build/2; its options are filter, filter_input, sort, sort_input, default_sort, distinct_sort, limit, offset, load, strict_load, select, ensure_selected, aggregate, calculate, distinct, context. Sort strings: none or + ascending, ++ ascending nulls first, - descending, -- descending nulls last; comma separated or a list. Example: prepare build(sort: [song_rank: :desc], limit: 10) |
https://hexdocs.pm/ash/Ash.Query.html; lib/ash/resource/preparation/builtins.ex, build.ex |
row 75, D17; M3 | no: later milestone |
| G2 | calculate name, type, calculation \\ nil: the third positional is calculation, optional, and takes expr(...), a module, {module, opts} or a function of the records and the context. The multi-line form is a do block holding options |
https://hexdocs.pm/ash/dsl-ash-resource.html | row 81, D27; M7 | no: calculate is not restructured here |
| G3 | The 39 registered functions are module names; the call names inside expr(...) are listed in row 103 (is_nil, string_length, if, - for Minus, …). Built-in changes, validations and checks are DSL calls, not functions in expr(...). Open: the validation and check builtins were not fetched, so how Mesh spells them is not settled |
lib/ash/filter/filter.ex (@functions), lib/ash/query/function/*.ex (use Ash.Query.Function, name: ...), lib/ash/resource/change/builtins.ex |
row 103, D16, D38; M4 | no: later milestone |
| G4 | belongs_to: allow_nil? true, destination_attribute id, source_attribute <name>_id. has_one/has_many: source_attribute id; destination_attribute has no default value and is guessed from the last segment of the source module’s name plus _id. allow_nil? exists on has_one (true) and not on has_many. Also on all three: public? false, writable? true, filterable? true, sortable? true |
https://hexdocs.pm/ash/dsl-ash-resource.html; lib/ash/resource/transformers/has_destination_field.ex, relationships/{belongs_to,has_one,has_many}.ex |
rows 41-44; M7 | no: no on-main option contradicts them (the contracts have none of these options) |
| G5 | default and update_default have type (-> any) | mfa | any: a zero-arity function, an MFA tuple or a literal. Open: that {MyModule, :my_func, []} appears as an example on the page |
https://hexdocs.pm/ash/dsl-ash-resource.html | rows 23-24, R14 | no: Mesh keeps literal defaults |
| G6 | Inside the transaction: before_action (after validations and changes, before the data layer action), after_action (success only), around_action. Outside: before_transaction (before it starts), after_transaction (after it ends, committed or rolled back; receives {:ok, record} or {:error, reason}) |
https://hexdocs.pm/ash/Ash.Changeset.html; lib/ash/resource/change/builtins.ex |
row 71, D35; M5 | no: later milestone |
| G7 | Action-level validate: where, only_when_valid?, message, description, before_action?, always_atomic?. Action-level change: where, only_when_valid?, description, always_atomic?. on exists only on the resource-level validate and change |
https://hexdocs.pm/ash/dsl-ash-resource.html (actions.create.validate, actions.create.change) |
row 68, D36; M5 | no: later milestone |
| G8 | A policy needs at least one check of authorize_if, forbid_if, authorize_unless, forbid_unless (error “Policies must have at least one check.”); its condition is optional (policy condition \\ nil), and with none it always applies |
lib/ash/policy/policy.ex (transform/1), lib/ash/policy/authorizer/authorizer.ex; https://hexdocs.pm/ash/dsl-ash-policy-authorizer.html |
row 90, D24; M8 | no: Mesh’s required condition stays until M8 |
| G9 | The positional names are in Section 2. In particular belongs_to name, destination, has_many name, destination, create name, attribute name, type, count name, relationship_path |
https://hexdocs.pm/ash/dsl-ash-resource.html | Section 2, rows 37-38, 86, D10 | yes: resource= became destination= on belongs-to and has-many |
| model | uuid_primary_key: public? true, writable? false, primary_key? true, allow_nil? false (not accepted as an option), type uuid. create_timestamp and update_timestamp: writable? false, allow_nil? false, public? false (not overridden), primary_key? false |
https://hexdocs.pm/ash/dsl-ash-resource.html; lib/ash/resource/dsl.ex |
rows 15, 18, 19 | no change needed: the contracts take only a name for each |
The fact-check said the first lookups’ quoted sentences for G1, G2, G3, G5, G6, G7 and G8 were not literal text of Ash’s documentation; none of those sentences is used here.
7. The fixture after alignment
This is post.mx as it is on main after the alignment: packages/compiler/test/fixtures/post.mx, and the identical examples/blog/post.mx. It carries every alignment of Section 4 that is on main and the naming rule of Section 0 (tag names and attribute names only). Expressions keep arrow functions with declared parameters until M4 is designed (D16, D37); the author-chosen names (authorId, insertedAt, commentCount) are values and stay as in the file before the alignment (D33). Concise syntax.
resource="post" table="posts" domain="blog"
attributes
uuid-primary-key="id"
attribute="title" type="string" allow-nil=false public
attribute="body" type="string" public
attribute="state" type="atom" constraints={ one_of: ["draft", "published"] } default="draft"
create-timestamp="insertedAt"
update-timestamp="updatedAt"
relationships
belongs-to="author" destination="user"
has-many="comments" destination="comment"
actions defaults=["read", "destroy"]
create="create" accept=["title", "body"]
change=({ post, actor }) => { post.authorId = actor.id }
update="publish"
change=({ post }) => { post.state = "published" }
validate=({ post }) => post.title.length > 0 message="title required"
read="published"
filter=({ post }) => post.state === "published"
sort=["-insertedAt"]
policies
policy=action_type("read")
authorize-if=({ post }) => post.state === "published"
authorize-if=({ post, actor }) => post.authorId === actor.id
policy=action("publish")
authorize-if=({ post, actor }) => post.authorId === actor.id
calculations
calculate="excerpt" type="string"
value({ post }) {
return post.body.slice(0, 200)
}
aggregates
count="commentCount" relationship-path="comments"
One difference from the proposal this section first held. The proposal put require-atomic=false on publish (D21). require-atomic is vocabulary for M5, not on main, so the fixture keeps the pre-alignment form of publish (no such attribute); the contracts reject the attribute until M5 adds it. action_type("read") and action("publish") are Ash’s check calls action_type(:read) and action(:publish). They sit inside an expression, so they are JavaScript identifiers and keep Ash’s snake_case (Section 0). Differences from the file before the alignment (Appendix A): allow-nil, create-timestamp and update-timestamp; type="atom" with constraints; defaults as an attribute of actions; the policy condition as a check call; relationship-path; destination on the two relationship tags.
Appendix A. The vocabulary on main, after the alignment
Everything here is read from contracts.ts as it is now (26 tags). Line numbers are not given: the file is the authority, and the tests in packages/compiler/test/contracts.test.ts pin each row. Helpers: str() is a string attribute with literalOnly; strings() is an array of strings, literalOnly; flag() is a boolean, literalOnly; code() is a function attribute, required; name() is the default attribute value, a required literal string. literalOnly means the authored value must be a literal, not an identifier (header comment of the file). It accepts an object or array literal, which is what constraints={ one_of: [...] } relies on. The exceptions to literalOnly are the function attributes and the policy condition, which is a call. closed() makes every contract list its attributes, attributeTags and children explicitly; an empty record means none. Child cardinality: {} is optional and at most once; { repeatable: true } any number; { required: true } exactly once; both flags at least once. Analyze helpers: nonEmpty(...) rejects an empty or whitespace string; nonEmptyList rejects []; listItems/checkItems reject blank or repeated items.
| Tag | Parents | Attributes (type, required, literal-only) | Children (cardinality) | Analyze |
|---|---|---|---|---|
resource |
#root |
value string, required, literal; table string, literal; domain string, literal |
attributes required once; relationships, actions, policies, calculations, aggregates optional once |
nonEmpty("value", "table", "domain") |
attributes |
resource |
none | uuid-primary-key, create-timestamp, update-timestamp optional once; attribute optional, repeatable |
none |
uuid-primary-key |
attributes |
value string, required, literal (name()) |
none | nonEmpty("value") |
attribute |
attributes |
value string required; type string required, enum of ATTRIBUTE_TYPES (string, integer, float, boolean, atom, uuid, datetime); constraints untyped, literal; allow-nil boolean, literal; public boolean, literal; default untyped, literal |
none | nonEmpty("value") and analyzeAttribute: constraints only for atom and required for it, an object literal naming one_of and nothing else, one_of a non-empty list of non-blank, non-repeated strings; default a string, number or boolean literal that fits the type (an integer for integer, one of one_of for atom) |
create-timestamp, update-timestamp |
attributes |
value string, required, literal (name()) |
none | nonEmpty("value") |
relationships |
resource |
none | belongs-to repeatable; has-many repeatable |
none |
belongs-to, has-many |
relationships |
value required; destination string, required, literal |
none | nonEmpty("value", "destination") |
actions |
resource |
defaults array of string, literal |
create, update, read, destroy repeatable |
nonEmptyList("defaults"); analyzeDefaults: each item one of create, read, update, destroy (ACTION_TYPES), no blank or repeated items |
create, update, destroy |
actions |
value required; accept array of string, literal |
change repeatable; validate repeatable |
nonEmpty("value"); listItems("accept", "accept") |
read |
actions |
value required |
filter optional once; sort optional once; validate repeatable |
nonEmpty("value") |
change |
create, update, destroy |
value function, required |
none | none |
validate |
create, update, destroy, read |
value function, required; message string, literal |
none | nonEmpty("message") |
filter |
read |
value function, required |
none | none |
sort |
read |
value array of string, required, literal |
none | nonEmptyList("sort"); listItems("sort") |
policies |
resource |
none | policy repeatable |
none |
policy |
policies |
value untyped, required (a check call or a list of them) |
authorize-if repeatable, required (at least one) |
analyzePolicy: each check is a call to action or action_type with exactly one string argument; action_type takes one of ACTION_TYPES; action is not empty; a list is not empty |
authorize-if |
policy |
value function, required |
none | none |
calculations |
resource |
none | calculate repeatable |
none |
calculate |
calculations |
value string, required; type string, required, enum of CALCULATION_TYPES (the attribute types without atom) |
value required once |
nonEmpty("value") |
value |
calculate |
value function, required |
none | none |
aggregates |
resource |
none | count repeatable |
none |
count |
aggregates |
value required; relationship-path string, required, literal |
none | nonEmpty("value", "relationship-path") |
Rules that are not in any tag: exactly one resource per file, and “an empty file” are not in the contracts; MX has no root cardinality, so Mesh’s model stage enforces them (header comment of the file; PR #1 review round 3, item 2, with two tests that pin MX accepting both).
The fixture
The fixture is Section 7: packages/compiler/test/fixtures/post.mx, identical to examples/blog/post.mx. It parses with zero diagnostics. Its first version was a copy of MX’s own fixture; the alignment replaced that form (required, type="enum" values=[...], timestamps, defaults as a child, policy action-type="read", relationship="comments", resource="user" and resource="comment" on the relationship tags).
How the expressions in this file are treated: every expression is converted to one expression tree when it can be (translatable) and is otherwise kept as TypeScript (opaque). filter must be translatable. change, validate and calculations are classified, and the class is recorded in the model; the file’s publish change and validation are both translatable (roadmap M4 and M5; ADR-0010, ADR-0017).
Appendix B. What this page did not verify
- Checked by the alignment (no longer open): an untyped contract attribute keeps
literalOnlyfor an object literal (D9). An identifier is rejected (literal-only-constraints) and an object literal is accepted (every fixture withconstraints). - The two values
@mesh/modelrecords where this page was silent are now checked (Section 6, row “model”):uuid-primary-keyis allow-nil false and public true;create-timestampandupdate-timestampare allow-nil false and public false. They match what the model records. - The two sub-claims Section 6 keeps open: how Mesh spells the validation and check builtins (G3), and the
{MyModule, :my_func, []}example of G5. - Where adapters and extensions are enabled for a project in the Ash sense (rows 3 and 5): the roadmap places it in the project configuration, M1 and M6.
- Who proposed each finding in rounds 1 to 4 of the review of PR #1: the PR records what changed, not the author of each finding.