Skip to content

DDD design DSL (.ddd)

A Sculptor-style domain-driven-design layer over .mox domain models. The domain model defines the data (entities, features, constraints); the .ddd design defines the application architecture on top of it: the application, its modules, the application services with their use-case operations, and per-class design decisions (stereotype, flags, repository, state machine, delegation targets). Parsed by phero-syntax (parse_ddd, the chumsky parser) at compile time, lowered and validated by phero-driver (compile_ddd_str) against the imported domains, and emitted as the versioned phero_ir::ddd::DddModel artifact (the phero-ir crate).

The .ddd DSL lives alongside the .mox domain model as a complementary input: a design never declares classes or features — it references the classes the imported domains declare and records what the application makes of them. Downstream consumers ingest the artifact exactly like the ActorModel policy artifact; there is no in-tree backend yet.

The normative contract — entry points, resolution semantics, the validation rules, the aggregate-derivation rule, and the capabilities contract — is documented on phero_driver::compile_ddd_str; this page is the language reference.


DddModel is a standalone, versioned wire artifact following the phero-ir wire format contract (see also the phero_ir::ddd module docs):

  • Versioned independently of the domain-model marker via DDD_MODEL_FORMAT_VERSION (= 1), the ActorModel precedent. DddModel::from_json rejects absent/wrong versions with IrError::UnsupportedFormatVersion (an absent version reports as 0).
  • All struct field names serialize as camelCase (formatVersion, returnType, optimisticLocking, …). Unit-only enums serialize as bare strings: stereotypes lowercase ("entity" | "value" | "dto"), built-in repository operations camelCase ("findById", "findAll", "findByExample", "findByKeys", "save", "delete").
  • Signatures reuse the domain IR’s TypeRef (adjacent tagging: {"type": "class", "value": {...}}) and OperationParam ({"name": ..., "type": ...}) — there are no parallel wire-level signature types.
  • Unknown fields are ignored on deserialize; every field added after v1 must keep the #[serde(default)] + skip discipline. The pherolang#176/#180 additions ride that rule: an operation’s transition binding (TransitionRef) and its resolved guard paths (conditionPaths, ConditionPath rows with adjacently tagged scopes) are absent from every artifact that does not declare them — body-less and machine-less models stay byte-identical, DDD_MODEL_FORMAT_VERSION stays 1. The pherolang#179 additions follow the same discipline: a design’s childOperations (ordinary ServiceOperation rows declared by extend blocks) and an operation’s childScope (ChildRef { feature, class, action } — the root’s child feature, the child class’s full name, the Delegation.target convention, and the validated action "add" | "remove" | "save") appear only when authored.
  • Executable operation bodies (issue #178) ride the same additive fields: a service or repository operation’s bodies map holds the checked body under the "stmt" target — the canonical JSON of phero_expr::stmt::StmtBody (an opaque string: phero-ir never depends on phero-expr, the Operation.bodies/"rune" discipline) — and a service body written name(...) => <Repository> { ... } also carries bodyReceiver (the primary repository bound as self). A repository body binds its own design’s repository, so it records no receiver. Body-less operations omit both keys.
  • A repository operation carries exactly one of a built-in op ("builtin") or a declared signature (returnType/params).

Minimal artifact: an empty model serializes as exactly {"formatVersion":2}.

let compilation = phero_driver::compile_ddd_str("library.ddd", source, &domains, &phero_driver::DomainImports::default());
let json = compilation.model.unwrap().to_json_pretty()?; // versioned artifact
let model = phero_ir::ddd::DddModel::from_json(&json)?; // version-gated reload

  1. Sculptor semantic identity — every Sculptor design concept has a direct DSL equivalent (see the concept-mapping table below). No lossy mapping.
  2. Validated against the real domain — design class references and operation signatures type-check against the imported .mox models; a typo’d class is a compile error, not a silent generation mismatch.
  3. Resolution is validation-only — the artifact keeps every name exactly as authored; consumers resolve against the domain model.
  4. Loosely coupled dimensions — capability references tie the design dimension to the actor-policy dimension by name only, never by construction.

The normative EBNF for the .ddd surface lives exactly once, in the parser module docs under .ddd design sources (crates/phero-syntax/src/parser/mod.rs) — the single authority for ddd_file, application_decl, module_decl, service_decl, design_decl, repository_decl, machine_decl, search_decl, and extend_decl; this page keeps only the language-level rules. The .ddd keyword tables in crates/phero-syntax/src/ddd.rs are the keyword authority the parser and the formatter share.

Lexical and structural rules:

  • Every .ddd word (application, base, module, service, extend, inject, entity, value, dto, abstract, the flags, repository, and the built-in operation names) is a contextual identifier, not a keyword: it is special only in the grammar position it is parsed in, remains usable as a class or operation name elsewhere, and can be escaped with ^ (the id/readonly precedent).

  • import declarations must precede the application declaration; there is exactly one application per file, and base must be its first member when present.

  • A /// doc comment run directly above a service or search declaration becomes its description in the artifact. Contiguous /// lines join with newlines — the same semantic as the .mox doc collection (docs/LANGUAGE.md).

  • Delegated operations (name => target.operation) copy the target’s signature conceptually and declare none of their own; a delegated op may be named save (contextual keywords). Declared service operations may carry a capability a, b clause, an invokes a.b, c clause (the decision services the operation invokes), and an emits A, B clause; delegated service operations may carry all three clauses too (they are service operations), plus the transition binding described below, while declared repository operations take neither (the wire artifact’s repository operations carry no capabilities, invocations, emissions, or transition bindings, so accepting one would silently drop data). A service operation’s clauses are fixed in the canonical order capability? invokes? emits? transition? when? — the order the formatter emits, so a source writing when before emits (or transition anywhere but between emits and when) is a syntax error, not a formatting opportunity. A service body may also declare decision dependencies with inject decision <qualified-name>; — the second inject form, alongside the plain inject <name>; dependency form (decision is contextual: special only right after inject, ^decision escapes; invokes is special only in the clause position, ^invokes escapes).

  • Transition binding (issue #176) — a delegated operation may carry transition <Trigger>, the explicit declaration of the machine-bound transition the delegation fires (replacing the consumer’s per-state inference):

    service PayRunService {
    inject PayRunRepository;
    submit => PayRunRepository.save transition Submit;
    reverse => PayRunRepository.save transition PayRunStatus.Reverse;
    }

    The machine is the one bound to the delegation target’s repository design; the trigger must be one that machine declares — an explicit via name or a bare arrow’s derived name (the lowerCamel of the target state), never a name that is both (that binding is ambiguous and refused). The qualified spelling names the machine by its enum type name — PayRunStatus.Reverse, not a machine name, because Phero Lang machine declarations are field-named (machine status: PayRunStatus) so a machine-name qualifier does not exist as authored. A module carrying more than one machine-bearing design requires the qualifier (E0352); a binding on a declared operation, or on a delegation that is not a repository save, is refused (E0351); an unknown trigger is E0350 with the declared trigger vocabulary (did-you-mean included). The artifact carries the binding as authored — TransitionRef { trigger, machine } on the operation (issue #143’s lowering contract; see “The lowering contract” below). A binding that resolves also pins the machine’s class as the operation’s own-record condition scope — the multi-machine guard shape of issue #180.

  • A service or repository operation may carry a leading protected modifier (Sculptor’s visibility): the operation stays off the public interface. protected is contextual — in leading position it is the modifier, anywhere else an ordinary name (^protected escapes).

  • Child-scoped operations (issue #179) — a module-level extend <Class> { ... } block declares operations that act on the aggregate root’s contained children (the root-side counterpart of a child aggregate’s own lifecycle):

    extend PayRun {
    boolean addLine(PayRunLineDraft line) => lines.add;
    boolean removeLine(String lineId) => lines.remove;
    boolean saveLine(PayRunLineDraft line) => lines.save when (line.quantity > 0);
    }

    Each child operation is protected? signature "=>" <feature>.<action> when? ; — the signature is the service-operation production (its return type is required, as for a declared service operation), and the dotted pair is exactly two names: the root class’s child feature and the action. The actions are the closed set add, remove, and save; add/remove require a many-valued feature, while save (insert or replace) is legal for a single-valued child too. A class may carry several blocks — they merge in source order — and child-operation names must be unique per extend target. extend is a contextual word like the rest, and the block stays in the module holding the root’s design (the same-module coupling rule searches follow).

  • Executable operation bodies (issue #178) — a service operation may replace its signature-only body with a closed procedural statement block, naming its primary repository after =>:

    submit(PayRunLineDraft draft) capability SubmitPayRun => PayRunRepository {
    let run = self.findById("current") else error NotFound("PayRun", "current");
    guard run.total >= 0 else error Conflict("total");
    let units = run.lines.map(line => line.quantity).sum();
    run.total = units;
    let saved = self.save(run);
    let audit = PayRunAuditRepository.record("submit");
    }

    The optional leading return type makes the operation value-returning (absent is void); the capability/invokes/emits/transition/when clauses keep their canonical order before the =>. The => name is one repository designed in the same module and injected by the service — it binds as self, and every other injected repository binds by its bare dependency name. A declared repository operation takes the same block after its optional when guard (Type? name(params) when? { ... }) and binds its own design’s repository as self; a built-in repository operation takes no body (a named syntax error). The statement subset is closed — let, let … else error …, assignment to a local’s feature path, guard … else error …, if/else, and return — and every other statement is a parse error naming the construct. The raise vocabulary is NotFound (two arguments) and Conflict (one argument); a value-returning body must return on every successful path.

  • The repository built-ins are findById, findAll, findByExample, findByKeys, save, and delete — the consumer knows their signatures. A body’s receiver-call surface synthesizes only findById (an Option<E>), findAll (a List<E>), save (E), and delete (boolean); findByExample/findByKeys are outside the closed subset and are refused as unknown operations.

  • Multiplicity annotations on return types and parameters (String[], Book[0,3]) lower into the artifact’s cardinality slots (returnMultiplicity / parameter multiplicity).

  • A search projection’s members may appear in any order (the formatter canonicalizes them); repeated text/filters/sort/document/ capability clauses merge, and repeats of the single members (entity, ranking, analyzer, pagination) are idempotent — the first declaration wins.

  • A document entry’s expression is captured raw (tokens up to the ;, nested brackets and string literals respected) and stored verbatim in the artifact; parsing and type-checking it is the driver’s job (rule 11). The expression language is the Tier-2 neutral expression language with the entity’s features as self — string concatenation via + is rule R9 of docs/EXPRESSIONS.md.


Names resolve with the .mox cross-package rules against the union namespace of the imported domains (the same rule multi-file .mox compiles follow). A bare single-segment name must be unique across all domain packages; when several packages declare it the error is “ambiguous type X; qualify as p.X``` with the matching packages as help. A qualified pkg.Namemust match exactly one package. When the design declaresbase, that package becomes the preferred resolution scope: a bare name the base declares resolves there before the unique-across-packages lookup runs. base` itself must name an existing domain package.

Resolution is validation-only: the artifact keeps every name exactly as authored (unqualified where written qualified and vice versa), and consumers resolve against the domain model.


  1. base — an unknown base package is an error naming it.
  2. Uniqueness (application-wide unless noted) — module names; service names; design class references (one design per class per application, keyed by the resolved class); repository names; operation names within one service; operation names within one repository.
  3. Stereotype targets — every design’s class must resolve to a class in the domain model (entity, value, and dto designs all target classes).
  4. Flag/stereotype compatibility — scaffold, auditable, and optimisticLocking are entity-only; nonPersistent is value/dto-only; cache is legal on any design. auditable and optimisticLocking default on for entity designs (Sculptor’s polarity): the artifact lowers them to true unless the design opts out with the negated form (!auditable, !optimisticLocking). Negating a flag that defaults off (!scaffold, !nonPersistent, !cache) is rejected wherever written.
  5. Repository placement — only entity designs may declare a repository.
  6. Aggregate boundary — see the derivation rule below; an entity design derived as a non-root may not declare a repository.
  7. Signature type-check — declared service and repository operation signatures type-check against the domain model: the phero-syntax primitives resolve everywhere, every other reference resolves through the bare/qualified rules above (any declared kind — class, enum, datatype, interface, vocabulary). A multiplicity annotation on a return type or parameter lowers into the artifact’s cardinality slots.
  8. Delegations — the target must resolve to a service, a repository, or a search declared anywhere in the application, and the operation must name an operation on it (repository built-ins included; a search exposes only the virtual operation search). Service → repository and service → search delegation across different modules is the module-coupling error (“interaction between a Service in one Module and a Repository/Search in another Module is not allowed; go via a Service”); service → service across modules is allowed.
  9. inject — every dependency name must resolve to a service, a repository, or a search of the application.
  10. Capabilities — only compile_ddd_str_with_actors validates: each declared capability name must exist in the union of the actor model’s blocks’ capabilities. Plain compiles record the names unvalidated — the design dimension is loosely coupled to the policy dimension by design.
  11. Searches — a search’s name is unique application-wide. Its entity line is required, resolves like a design target, and must bind to an entity design of the same module (the repository coupling rule; non-root entities may be searched — a projection over a contained entity is legitimate). Text fields must name a direct feature of the entity (inherited features included) that is text-like: the string primitives, enums, datatypes — the string-family default the constraint rules use — and vocabularies whose key facet is a string primitive; boosts are at least 1 and analyzers non-empty. Filters and sorts name direct features that are not class references. Document field names are unique per search, and every document expression is parsed and type-checked with the entity as self (any value type is legal; the consumer decides the rendered form). Pagination bounds satisfy 1 ≤ limit ≤ max.
  12. Value contract — a value design is an immutable value object (Sculptor’s ValueObject default, with no opt-out): every stored feature of the resolved class (own and inherited, derived features excluded) must carry the domain model’s readonly modifier. A changeable class designs as entity or dto instead.
  13. Aggregate reference constraint — an outside class may hold a cross reference only to an aggregate’s root: for every cross-reference (refers) feature whose target sits under a containment chain (a non-root aggregate member), the referring class must share the target’s containment root. Computed over the whole union like the aggregate-derivation rule, and skipped when the verdict would be a guess (the target or source sits under two distinct roots, or under a containment cycle).
  14. scaffold — a scaffolded entity design must declare a repository: scaffold stands for Sculptor’s generated CRUD operations (which also imply the service), and the artifact stays validation-only — nothing is synthesized — so the author declares the repository the scaffolding would hang off. The service remains the author’s to declare.
  15. dto-with-ops warning — a dto design whose class carries ops warns once per op, naming the design and the op: a dto is a transfer record, and behavior on it is almost always a modeling mistake (see the layering heuristic under “Domain operations vs service operations”). Warning-severity: the artifact is unaffected, and the check runs only against a fully lowered union.
  16. Typed status fields — an entity design whose class carries a feature named status or state typed as a bare String is an error: a status-like field is a lifecycle, and the bare-String shape is the silent no-machine case. Declare an enum in the domain model and a machine '<field>: <Enum>' on the design. Value objects and dtos are exempt (the check is entity semantics, the machine rule’s scope), and enum-typed fields stay legal — an enum without a machine is a legitimate modeling choice (the canonical fixture’s MediaStatus status field is the shape).
  17. Decision links — only compile_ddd_str_with_decisions validates: every decision-service reference (an operation’s invokes name, a service’s inject decision name) must resolve by its last segment to a decision service declared in the union of the provided .dmn models’ services (the names pool across every provided model, the rule-10 shape), else the reference errors E0250 unknown decision service '<name>' invoked by operation '<op>' of service '<service>' (injections: E0251 unknown decision service '<name>' injected by service '<service>') — spanned on the reference, with the did-you-mean closest declared service when one is a plausible typo, and the exhaustive list otherwise. Plain compiles (and any empty decisions bucket) record the names unvalidated — the design dimension is loosely coupled to the decision dimension by design. Every import "<path>.dmn" the design declares must be provided under that path when a non-empty decisions bucket is supplied, else imported decision model "<path>" was not provided; .dmn imports are not domains and never lower as .mox sources — a plain compile (pherolang#154).
  18. Version pins — an invokes entry or an inject decision line may declare version compatible or version "X.Y.Z" (an exact semver). The pin narrows rule 17’s resolution against the models’ declared version metadata: an exact pin resolves to the same-name service at that version (zero matches is E0252, naming the versions available; two or more candidates declaring the same version is E0253, ambiguous); compatible resolves to the highest declared version in the highest major present (a model whose version is absent or not strict X.Y.Z cannot satisfy a pin — E0254 when none can). Unpinned references keep the rule-17 behavior; the compatibility contract itself is the decision model’s (same major = compatible — see the .dmn surface’s Versioning section). ignores them outright.
  19. Child operations (extend) — the target class must resolve to a domain class with an entity design in the same module that is an aggregate root (a repository-bearing entity — rule 6’s root predicate); an unresolved class, a missing design, and a design without a repository are all E0358 unknown extend target. The child feature must exist on the target class (the extends chain included) and be a containment or cross reference (E0355); the action must be one of add, remove, save (E0357), with add/remove requiring a many-valued feature (E0356). A child operation’s when guard is typed with the operation’s declared parameters in scope and the child feature’s target class as self — line.quantity reads a parameter, a bare quantity reads the child’s feature, and the root’s own features are out of scope. A resolved guard lowers verbatim as condition and carries no conditionPaths (the resolved-path lowering belongs to service operations). The lowered operation rides the target design’s childOperations with its childScope.
  20. Executable operation bodies — a body is typed against the operation’s declared parameters and output (absent for a void operation) and the repository receivers described above. The statement subset is closed; every expression slot is phero-expr source checked under the same typing rules as a when guard. The failure contract is hard: an unknown feature or operation, a type mismatch, a non-boolean guard or if condition, an unknown raise variant or arity, and a value-returning path that does not return are all E0360 (invalid service body in operation '<op>': <slot>: <detail>, repository bodies worded the same way). An unresolved => receiver is E0359 (unknown body receiver '<name>' on operation '<op>' of service '<s>') — the name must be a repository designed in the operation’s module that the service injects, written as a single (unqualified) repository name. The checked body lowers to bodies["stmt"]; nothing is attached when any diagnostic fired. Unlike the sigil-lowered Rune bodies’ generate-time deferral, the .ddd compiler never attaches an unchecked body. NotFound and Conflict are the reserved raise variants.

An entity design may carry one lifecycle state machine — a machine <field>: <Enum> block, written after the optional repository block (the formatter’s canonical order; one per design). machine, initial, terminal, and via are contextual words like every .ddd word. The normative grammar (machine_decl, machine_member, transition, guard_clause) lives in the parser module docs (crates/phero-syntax/src/parser/mod.rs); the canonical fixture’s machine gives the shape:

machine status: PayRunStatus {
initial Draft;
Draft -> Calculating via Calculate;
Calculating -> AwaitingApproval via Submit;
AwaitingApproval -> Approved via Approve when (total > 0);
Approved -> Processing via Schedule;
Processing -> Posted via Post;
Draft -> Reversed;
terminal Reversed;
terminal Posted;
}

The driver validates the machine before it lowers (lower_design_machine in crates/phero-driver/src/ddd.rs; behavior pinned by crates/phero-driver/tests/ddd_machines.rs):

  • Only an entity design carries a machine — a value object is immutable and a dto is a transfer record; neither moves through states.
  • The machine’s type must resolve to an enum, and the bound feature (<field>) must exist on the designed class and carry exactly that enum type.
  • Closed over the enum — every state name (the initial, a terminal, and every transition endpoint) must be a literal of that enum: a typo’d endpoint is an error naming the enum and its declared literals, never a new state. The enum’s literal list is the single vocabulary of the lifecycle.
  • Guards are predicates: a when condition on a transition — and on any service or repository operation — must type as boolean (the actor, deploy, and cmmn conditions’ rule).
  • Exactly one initial state. Duplicate terminal declarations and duplicate identical edges are rejected.
  • Determinism — one trigger name may leave a state for at most one target (the statechart event rule).
  • Terminal states take no outgoing transitions; every non-terminal state takes at least one (no dead ends — declare it terminal or add an outgoing edge).
  • Reachability — every state is reachable from the initial state (breadth-first over the edges).
  • Edge guards and operation when conditions type-check through the shared expression language (phero-expr) with the designed class as the implicit self — and guard literals against the machine field must be machine states: only the declared states bind as names, so a member of the bound enum that the machine never declares is a type error (it can never hold on this machine).

Condition binding. Machine states bind as enum-typed names — guards read status == AwaitingApproval, never status == "AwaitingApproval". The string literal is a type error under the same-type equality rule (R2 of docs/EXPRESSIONS.md); the anti-magic-strings stance is deliberate. The enum-literal namespace (rule L3 of docs/EXPRESSIONS.md) is the general typed spelling beyond a machine’s own bound states: status == PayRunStatus.Draft names any declared member of the field’s enum as a typed value, and a string literal that names no declared member is the closed-set membership error rather than the generic mismatch. Edge guards bind only the machine’s own states (a foreign machine’s states are unbound — cross-entity state references are a type error). Comparisons against other features are not machine-checked: their name operands resolve as features.

Condition scoping (issue #180) — a service operation’s when guard is not single-entity. Three scoping regimes, by the module’s machine count:

  • One machine-bearing design (the original shape): that design’s class is the guard’s implicit self; bare feature names and machine states resolve against it. A guard with no injected path lowers no conditionPaths — the resolution is derivable from the artifact’s machine alone.
  • No machine-bearing design: an op-level guard has no own-record scope at all and is refused (does not have exactly one machine-bearing design), unless a resolved transition binding pins the scope (the joint shape with issue #176 above).
  • Several machine-bearing designs (multi-machine): there is no implicit self — bare names must resolve uniquely:
    • machine states bind when exactly one machine of the module declares the name; a state two machines declare never binds, and referencing it unqualified is the E0354 ambiguous-machine-scope error with the qualify as .“ help — the enum-literal spelling (PayRunStatus.Draft) resolves it;
    • each machine’s bound field is addressable two ways: by its authored field name when that name is unique across the module’s machines, and always by the enum-derived spelling — the lowerCamel of the machine’s enum type name (machine status: PayRunStatus → payRunStatus). A field name two machines bind (status on both designs) is the E0354 error unqualified, with the spellings in the help;
    • a qualified path (TimecardRepository.status) resolves against the service’s injected repositories — the first segment must match an injection name exactly as declared (case-sensitive; a near-miss is the E0353 unknown-condition-path error with the did-you-mean), the repository must be designed in the same module, and the remaining segments are fields of the repository’s class (E0353 names the class and suggests when a segment is no such field). A path that resolves but mistypes is ordinary typing: the same-type equality error, spanned on the condition.

Resolved condition paths. The artifact carries the guard’s resolution as conditionPaths on the operation — ConditionPath { scope, field, fieldType } rows, scope adjacently tagged {"type":"ownRecord"} or {"type":"injected","value":{"repository":…,"class":…}} (the repository is the injection name as declared, the class the repository design’s class; field is the dotted remainder, fieldType the field’s declared type). A row lowers exactly when the path’s resolution is not derivable from the artifact alone: injected paths always (the repository→class binding is design knowledge the domain model does not carry), and own-record paths in a multi-machine module (where the owning machine is not a priori derivable). The condition source string stays verbatim; consumers build on the resolved form (issue #143’s lowering contract) instead of re-parsing guards.

Artifact shape. The machine lowers to machine on the design (a phero_ir::ddd::StateMachine): the bound field, the enum TypeRef, the initial state, the terminals, and the transitions — the via trigger (None for a bare arrow) and the guard as the verbatim source text between the parentheses (the when-condition rule). The state set is derived, never serialized as a separate list: StateMachine::states() is the initial state, then the terminals, then the remaining transition endpoints in first-appearance order; terminalness is membership in terminals.

xstate-ir lowering. phero_driver::machines::lower_design_machines maps every designed machine onto the restingstate toolchain’s language-neutral xstate_ir::Machine (rev-pinned as xstate-ir in the workspace Cargo.toml; the sigil precedent). The mapping is total and lossless for the v1 surface: id is the design class name; context is one field (the bound feature, typed by the enum’s qualified name, default the initial state); states follow StateMachine::states() with terminals as final nodes and the rest atomic; transitions attach to their source in artifact order, and the guard maps to a named guard whose name is the condition’s source text. A bare arrow’s event is derived: the lowerCamel of the target state (Posted → posted, AwaitingApproval → awaitingApproval). The committed goldens pin both forms — tests/conformance/ddd/payrun.ddd.json (the design artifact) and tests/conformance/ddd/payrun.xstate.json (the lowered machine); regenerate with PHERO_UPDATE_FIXTURES=1 cargo test -p phero-driver --test ddd_machine_golden.

Derived events. Every designed machine derives the state_changed event contract (entity + field — phero_driver::machines::derived_events), which is what makes the push plane’s state_changed vocabulary reachable for a plain CRUD model once its lifecycle is modeled.

The aggregate boundary derives from the domain model’s containment graph, standing in for Sculptor’s belongsTo/!aggregateRoot markers: a class B is contained when some class A owns a containment feature (contains) whose type resolves to B, transitively. An entity design whose class is contained by another stereotyped entity’s class is a non-root — it may not declare a repository. The closure is computed only when every imported domain lowered (a failed domain may hide containments, and a wrong non-root verdict is worse than none).

Mox class ops and .ddd service operations are deliberately different layers, and the split is the layering heuristic:

  • Behavior confined to one aggregate stays a class op. It lives in the domain layer, is owned by the aggregate’s class, and generates as an instance method regardless of the class’s stereotype.
  • Behavior spanning aggregates, needing a transaction, or sitting at an entry point is the cue for a .ddd service operation. The service operation delegates down to the aggregate-owned ops; it never re-implements them.
  • Authorization attaches at the service-operation/capability boundary (the .actor dimension). A domain op is not an entry point, so ops never take capability clauses — guarding a domain method would bypass the service boundary the policy dimension is defined over.

A dto-designed class carrying ops is therefore flagged by validation rule 15 (a warning per op): transfer records carry data between layers, and behavior declared on them is almost always behavior that belongs on the aggregate’s class or on a service. The escape hatch is unchanged: a body-less op stays the abstract hook implemented by hand in target code (the “Gap classes” divergence below).

Child-scoped operations (extend, rule 18) are the root-side slice of the same heuristic: boolean addLine(PayRunLineDraft line) => lines.add; declares, on the root’s design, an operation that reaches the aggregate’s contained child through the root — no outside cross reference to a non-root member (rule 13) is needed. The child’s own behavior stays a domain op on the child class; the extend operation is the root-owned entry point over it, and the aggregate boundary stays intact.

Phero Lang aligns with codegraph#590: public service operations lower to capability-gated routes downstream — the generator emits one route per public operation, guarded by the operation’s declared capabilities, with server-side when/never_both enforcement. Phero Lang’s role is to make the surface the route is built from declarative, which issues #176 and #180 complete:

  • the declared transition (TransitionRef) is the lowering contract for the machine-bound transition a delegated save fires — the consumer lowers the declared trigger verbatim (state patch = the trigger’s target state, conflicts via the machine guard) and pins it in its hurl/openapi contracts, instead of guessing from state names;
  • the resolved condition paths (conditionPaths) are the lowering contract for the guard: injected-scope reads go through the injected repository on the handler’s already-loaded context, own-record reads through the bound entity — a closed subset the refusal of which names the construct;
  • the checked statement body (bodies["stmt"], issue #178) is the lowering contract for the operation’s behavior: the consumer decodes the StmtBody, maps self/bare-name receivers through bodyReceiver and the service’s injections, and lowers the typed expressions with the shared R1–R9 semantics. No in-tree backend emits .ddd bodies yet; the artifact is the contract, and the compiler never attaches an unchecked one.

Until an in-tree .ddd lowering exists, compiles therefore add no “lowers to nothing” diagnostics: the artifact is the contract, and the consumer owns the route emission. A protected operation stays legal but off the public interface — a capability or transition carried only by protected operations reads as dead to the coverage pass, whose warning-severity lint (grantedCapabilityUncarried, the pherolang#128 asymmetry) is the existing dead-trigger WARN; no new diagnostic bridges the gap.

A service operation’s capability clause names actor capabilities. Only the library entry point compile_ddd_str_with_actors validates the names against a caller-provided actor-model slice; capability names pool across every model, so a host passes all of the application’s .actor artifacts. An empty slice is the plain compile_ddd_str behavior (the records-unvalidated loose-mapping contract). Since pherolang#128 the CLI project pipeline and the LSP pass the sibling artifacts, so the check is a compile-time language error — spanned on the capability clause, with the unknownCapability code carrying the did-you-mean closest declared name (a case-variant match wins outright; otherwise the nearest name within an edit budget). Nothing in the design artifact constructs policy: authorization remains the .actor dimension’s job (see the actor-policy section of the language reference).

Both directions of the design↔actor wiring resolve at compile time (pherolang#128): the CLI project pipeline and the LSP resolve every capability clause against the pooled actor artifacts (existence, rule 10 above) and every grant against the pooled design artifacts (coverage — compile_actors_str_with_designs, the actor engine’s coverage pass diagnosing capability 'X' is granted but no operation or search carries it spanned on the grant entry, code grantedCapabilityUncarried). The union integrity pass (phero_driver::check_integrity, register row V12) keeps both findings as its lint channel (warning severity) for hosts that gather artifacts without compiling — the compiler’s diagnostics are the errors; pherolang check <dir> prints the lint only for files phase 1 checked clean. Checking a design or policy alone stays per-surface and silent.

Capability nouns on aggregate members (pherolang#144)

Section titled “Capability nouns on aggregate members (pherolang#144)”

A capability is granted on a class (capability X on <Class>), and the class’s place in the aggregate machinery decides whether that noun is a sound grant. With designs provided, NounClassification (crates/phero-driver/src/ddd/graph.rs) — a pure table over the union model and the designs, exactly rule 6’s predicate — classifies every resolved design class:

verdict class a grant on it
Value a value design — no aggregate to escape clean
Root an entity with a repository that no stereotyped entity contains clean
NonRoot an entity with a repository that another stereotyped entity’s class contains (transitively) warning
none no design, a dto, or a repository-less entity silent

The warning is capability '<X>' is granted on '<Class>', a non-root aggregate member (plus of '<Root>' and a “grant the capability on the aggregate root instead” help when the containment chain tops out at exactly one class), spanned on the class reference. Granting on a non-root member hangs generation bindings (codegraph#596), so the warning points at the root; an ambiguous chain names no root. compile_actor_file builds the table only when designs are provided and every imported domain lowered (union_model, the rule-6 discipline: a domain that failed to lower may hide containment, and a wrong verdict is worse than none — its diagnostics already block the artifact); an empty design slice, and the None table it passes through lower_actors, is the exact plain compile_actors_str behavior, byte for byte. The .ddd inline actors block path always passes no table: actor lowering and the design registries never co-occur there (domains lower through the shared per-file union pipeline before designs exist, and compile_ddd_str_with_actors receives pre-lowered ActorModels).

A service operation’s emits clause names the events the operation produces — the production-side counterpart of the .evt layer’s contracts, channels, and subscriptions (a fragment, in the position the grammar fixes for it — after the capability clause, before the when guard):

service PayRunService {
boolean approve() capability ApprovePayRun emits PayRunStateChanged when (status == AwaitingApproval);
}

Like capability names, emitted event names are loosely coupled: the names lower into the artifact exactly as authored, and a plain compile_ddd_str never validates them — the .ddd compile has no .evt bucket. Both directions of the design↔event wiring are enforced by the union integrity pass (phero_driver::check_integrity, register row V12):

  • Emission existence — every emitted event name on every service operation must name an event declared in some provided .evt contract: operation 'approve' of service 'PayRunService' emits unknown event 'PayRunStateChanged', help listing the declared event names.
  • Emission coverage — every declared event must be emitted by at least one service operation across the designs: event 'E' is declared but no operation emits it. Channel publishes entries and subscription events [...] lists are consumption-side wiring — they route events to consumers and never satisfy production.

Checking a design alone (or a contract alone) stays per-surface and silent: an emits clause in a cluster without .evt artifacts is not an error, and a contract in a cluster without designs is not either — the checks fire only when the counterpart surface is present in the checked union (pherolang check <dir>).

A service declares which decision services it relies on without knowing how the decisions are computed — the .ddd side of the .dmn decision surface (issue pherolang#78). Two forms, in the positions the grammar fixes:

service LoanService {
inject decision underwriting.RiskScore;
boolean approve() capability ApproveLoan invokes RiskScore, FraudScreen when (status == AwaitingApproval);
}

An operation’s invokes clause names the decision services the operation invokes; inject decision <qualified-name>; names a decision service the whole service injects. Like capability and emits names, decision references are loosely coupled at the wire layer: they lower into the artifact as their last segment exactly as the resolution universe spells them, and a plain compile_ddd_str never validates them. Only the library entry point compile_ddd_str_with_decisions validates the links (rule 17 above) against a caller-provided (path, DmnModel) bucket — the same shape the .uc import buckets use. Decision services pool across every provided model, so a host gathers the sibling .dmn artifacts the way it pools actors. An empty bucket is the plain behavior.

The .dmn dimension stays the authority: it declares the decision services (with their inputs, outputs, and encapsulated decisions); the design only records the dependency edges. Imports of .dmn files are not domains — the design’s import "<path>.dmn" declarations resolve the decision bucket at compile time (rule 17’s provision check) and lower nothing into the domain union.


pherolang check library.ddd # validate design + imported domains
pherolang ir library.ddd -o library.ddd.json # emit the DddModel artifact
pherolang fmt --check library.ddd # canonical formatting gate
pherolang artifact check library.ddd.json # validate the serialized artifact
  • Import paths resolve relative to the .ddd file’s directory; each imported .mox is read from disk and compiled through the ordinary domain pipeline. A missing import file is a clean error naming the resolved path. An imported domain may itself declare import schema and import sigil — the CLI provides that content too, so designs target lowered JSON-Schema and Rune types like any other class (see Importing Rune models).
  • Diagnostics come from every file involved and render grouped per file, the way .actor compiles render them.
  • pherolang ir on a .ddd file emits only the design artifact; the domain IR is emitted by running ir on the .mox files themselves.
  • pherolang fmt formats .ddd files with the canonical layout rules: imports hoisted into a tight first section (each ending in ;), then the application block; base first, design flags canonicalized to scaffold auditable optimisticLocking nonPersistent cache, search members canonicalized to entity text filters sort document ranking analyzer pagination capability (clause contents keep their source order; document expressions are re-emitted verbatim), and every operation/inject line ending in ;. An extend <Class> header expands like a service/design header, and each child operation is one canonical line — boolean addLine(PayRunLineDraft line) => lines.add;, with an optional leading protected and the trailing when (...) in the service-operation order.
  • Directory batch mode (pherolang check <dir>) picks .ddd files up like every other surface: each design in the directory compiles independently, imports resolved relative to it.

Sculptor Phero Lang .ddd
Entity entity design
ValueObject value design
BasicType .mox datatype (referenced in signatures, never designed)
Repository repository block on an entity design
Service (+ injected => delegation) service with inject lines and name => target.operation operations
scaffold / auditable / optimisticLocking / !aggregateRoot-style negations design flags (entity-only; auditable/optimisticLocking default on for entities, !flag opts out)
belongsTo / !aggregateRoot derived from the domain model’s containment graph (see aggregate derivation)
Module module label grouping services and designs
child-scoped aggregate operations extend <Root> { ... } block (issue #179)
DTO dto design
findByQuery / findByCondition finders search projection (intent only: text fields with boosts, filters, sorts, ranking, pagination, document projections) — the backend chooses the engine (Postgres FTS, Tantivy, OpenSearch, …)
generated GET /things/search?q= endpoints consumer concern — the artifact is backend-independent

Sculptor concepts Phero Lang intentionally does not carry, and why:

  • Composite natural keys — expressible already: several id features on one class lower with isId, and the composite key is that set; the Tier-1 backends give the group its meaning (docs/LANGUAGE.md keeps id “carried; semantics land with Tier 1”).
  • Collection size constraints (size, notEmpty) — expressible as the multiplicity annotation (String[1..10] tags), which the JSON Schema backend already lowers to minItems/maxItems; a constraint-block spelling would duplicate it.
  • Ordered collections (orderby / orderColumn, Set/List/Bag) — persistence-engine metadata (JPA @OrderBy/@OrderColumn); Phero Lang features keep declaration order, and a backend that needs a different ordering derives it from its own configuration, not from the artifact.
  • Fetch / cascade — JPA persistence knobs with aggregate-derived defaults in Sculptor’s generator; the artifact is backend-independent, so persistence behavior belongs to the backend that owns the storage.
  • Date validation (past / future) and custom validators — runtime validation semantics; Phero Lang’s constraint system is deliberately schema-only (the unique precedent), and there is no runtime validator hook to hang them on.
  • Gap classes — Sculptor’s generated-base/handwritten-subclass split is a generation strategy; Phero Lang generates whole files, and a body-less op stays an abstract hook implemented by hand in target code — the same separation without a second class.
  • Traits — a mixin composition feature of Sculptor’s Java generation; no Phero Lang surface carries it yet. If a need lands, it belongs in the .mox domain model (interface extends covers the signature-sharing half today).
  • Composite value types (BasicType) — Sculptor’s BasicType is a multi-attribute embeddable (Money{currency, amount}); Phero Lang datatypes wrap a single platform type opaquely. Model the composite as a class designed value (rule 12 then requires its features readonly); a true embeddable datatype awaits a deliberate .mox language extension.
  • Multi-target arrows (A -> B, C) — deliberately excluded from the machine surface: an unguarded external choice is not a deterministic machine; write one edge per transition.
  • Transition entry/exit actions and timers — v1 transitions carry only trigger, guard, and target; domains.toml approval chains and timers are downstream concerns.
  • .actor guards binding machine states — the actor dimension cannot yet reference a design’s machine states cross-file (a fast-follow); today only .ddd-internal conditions bind them.

tests/conformance/ddd/library.ddd, the canonical fixture, designs a library application over tests/conformance/ddd/library.mox. The fence below is the fixture verbatim, its import repointed so it compiles from this document (the docs fences are compiled by CI):

import "../tests/conformance/ddd/library.mox";
application Library {
base nz.example.library
module media {
/// Coordinates lending of physical media across branches.
service MediaService {
inject PhysicalMediaRepository;
inject MovieRepository;
inject PersonService;
inject MediaSearch;
boolean borrow(String mediaId, Person borrower) capability BorrowMedia;
PhysicalMedia registerMedia(MediaStatus status);
protected save => PhysicalMediaRepository.save;
registerBorrower => PersonService.register;
findMedia => MediaSearch.search;
}
entity Library scaffold cache repository LibraryRepository {
findById;
findAll;
save;
delete;
boolean renameLibrary(String name);
}
entity Book
abstract entity Media
entity Chapter auditable
entity Movie repository MovieRepository {
findById;
findAll;
save;
delete;
Movie findByTitle(String title);
}
entity PhysicalMedia scaffold repository PhysicalMediaRepository {
findById;
findAll;
save;
delete;
PhysicalMedia findByStatus(MediaStatus status);
}
/// Full-text search over the movie catalogue.
search MediaSearch {
entity Movie
text {
title boost 3
synopsis analyzer "english"
}
filters {
genre
}
sort {
title
}
document {
headline = title;
blurb = title + " - " + synopsis;
}
ranking bm25
analyzer "english"
pagination {
limit 20
max 100
cursor
}
capability SearchMedia, SearchMediaAdmin
}
}
module person {
/// Keeps the member register current.
service PersonService {
inject PersonRepository;
register => PersonRepository.save;
findByName => PersonRepository.findByName;
}
entity Person repository PersonRepository {
findById;
findAll;
findByExample;
findByKeys;
save;
delete;
protected Person findByName(String fullName);
}
search MemberDirectory {
entity Person
text {
fullName boost 2
}
filters {
joinedOn
}
sort {
fullName
joinedOn
}
document {
displayName = fullName;
}
ranking tfIdf
pagination {
limit 10
}
}
}
}

Notes on the example: Chapter declares no repository because it is contained by Book (a non-root aggregate, rule 6); Book and Media design the inheritance pair the domain declares; MediaService.save delegates to a repository of its own module while registerBorrower delegates across modules to PersonService (service → service, allowed).


tests/conformance/ddd/ holds the canonical fixture set: library.mox (the domain), library.ddd (the design, formatter-canonical), and library.ddd.json (the committed artifact). The driver golden test byte-compares the compiled artifact, the CLI fmt gate enforces the canonical formatting, and pherolang artifact check classifies the artifact as a DDD design. Regenerate goldens with PHERO_UPDATE_FIXTURES=1 cargo test --workspace.

The machine-bearing pair payrun.mox/payrun.ddd (formatter-canonical) commits two goldens: payrun.ddd.json (the design artifact) and payrun.xstate.json (the lowered xstate-ir machine), byte-compared by crates/phero-driver/tests/ddd_machine_golden.rs; the machine validation rules are pinned by crates/phero-driver/tests/ddd_machines.rs.

The multi-machine pair timesheet.mox/timesheet.ddd (formatter-canonical) is the transition-binding and condition-scoping fixture (issues pherolang#176/#180): two machine-bearing designs in one module, qualified transition bindings, an injected-repository SoD guard, and the enum -derived own-record spelling. Its committed golden timesheet.ddd.json carries the transition and conditionPaths fields; the acceptance rules are pinned by crates/phero-driver/tests/ddd_transitions.rs and crates/phero-driver/tests/ddd_conditions.rs.

The child-operation pair childops.mox/childops.ddd (formatter-canonical) is the pherolang#179 fixture: a PayRun aggregate root with a contained PayRunLine[] lines collection and a single-valued PayRunNote child, a dto PayRunLineDraft parameter type, and an extend PayRun block with add/remove/save child operations (one guarded by a parameter field and one by a child feature). Its committed golden childops.ddd.json carries the design’s childOperations with each operation’s childScope; the acceptance rules are pinned by crates/phero-driver/tests/ddd_child_ops.rs.

The executable-body pair bodies.mox/bodies.ddd (formatter-canonical) is the pherolang#178 fixture: a void service body over the full statement subset (let-else, guard, assignment, sum/size/any/all reductions, self.findById/self.save, and a bare-name injected-repository call), a value-returning if/else body with capability/when clauses, a repository-operation body guarded by when, and an injected secondary repository. Its committed golden bodies.ddd.json carries each operation’s bodies "stmt" target and the service bodies’ bodyReceiver; the acceptance and failure rules are pinned by crates/phero-driver/tests/ddd_bodies.rs.

The examples/ side ships support.ddd (entity/value/dto designs, searches, capabilities validated against support.actor through compile_ddd_str_with_actors) and orders.ddd (the aggregate-derivation rule over ecommerce.mox), byte-pinned against examples/artifacts/ by crates/phero-driver/tests/examples_artifacts.rs. Note that examples/library/library.mox itself is not designable: its Writer.books cross reference escapes the Library aggregate (rule 13).