ADR-020: Declarative Initial and Terminal Syntax
Context
ADR-017 introduced initial(State) = true syntax to explicitly declare which state is the starting state of a decider, mirroring the existing terminal(State) = true pattern. The symmetry between the two predicate forms was intentional in ADR-017. Three implementation problems have emerged that make the predicate form the wrong choice.
Problem 1: = false is semantic noise
The grammar permits initial(Pending) = false and terminal(Active) = false. These declarations carry no information — the absence of a marker is already the default. Validation must inspect every InitialDecl and TerminalDecl member and filter to those where value === 'true'. The = false form is valid syntax that the validator silently discards. A developer writing it believes they are communicating something; they are not.
Problem 2: The predicate form contradicts the enforced constraint
A boolean function form — initial(State) = true | false — suggests per-state configurability. The validation rule InitialStateRequired enforces exactly one = true declaration across all InitialDecl members. The syntax promises a degree of freedom the semantics denies. A grammar that accepts initial(A) = true and initial(B) = true but then rejects it at validation is a leaky abstraction: the grammar is too permissive relative to the actual semantic contract.
Problem 3: Predicate style is inconsistent with all other decider declarations
Every other structural property of a decider uses keyword: items notation:
commands: Register, Deactivateevents: Registered, Deactivatedstate: Unregistered | Active(email: Email) | Inactive(reason: Reason)The initial() and terminal() predicates use a completely different syntactic form. A developer learning the DSL must learn two different patterns for what are structurally the same kind of declaration: the decider’s configuration.
Decision
Replace initial(State) = true and terminal(State) = true with declarative keyword: reference syntax placed in the decider’s configuration header alongside commands:, events:, and state:.
Before (ADR-017):
decider User { commands: Register, Deactivate events: Registered, Deactivated state: Unregistered | Active(email: Email) | Inactive(reason: Reason)
initial(Unregistered) = true
decide(Register, Unregistered) -> [Registered { userId, email, name }] decide(Register, Active) -> already "registered" decide(Register, Inactive) -> forbidden "reactivation requires a separate command"
evolve(Unregistered, Registered) -> Active { email: event.email, name: event.name }
decide(Deactivate, Active) -> [Deactivated { userId, reason }] decide(Deactivate, Unregistered) -> impossible "no active account to deactivate" decide(Deactivate, Inactive) -> already "deactivated"
evolve(Active, Deactivated) -> Inactive { reason: event.reason }
terminal(Inactive) = true}After (ADR-020):
decider User { commands: Register, Deactivate events: Registered, Deactivated state: Unregistered | Active(email: Email) | Inactive(reason: Reason) initial: Unregistered terminal: Inactive
decide(Register, Unregistered) -> [Registered { userId, email, name }] decide(Register, Active) -> already "registered" decide(Register, Inactive) -> forbidden "reactivation requires a separate command"
evolve(Unregistered, Registered) -> Active { email: event.email, name: event.name }
decide(Deactivate, Active) -> [Deactivated { userId, reason }] decide(Deactivate, Unregistered) -> impossible "no active account to deactivate" decide(Deactivate, Inactive) -> already "deactivated"
evolve(Active, Deactivated) -> Inactive { reason: event.reason }}initial: accepts a single [StateDecl:ID] reference. terminal: accepts a comma-separated list of [StateDecl:ID] references. Both move from the body of the decider (where they appeared as DeciderMember alternatives) to fixed positions in the decider’s configuration header.
initial: is mandatory. The parser rejects a decider without it. terminal: is optional — not every decider has absorbing states.
Design rationale
Initial and terminal are decider configuration — structural properties of the state machine topology. They describe which states are the entry point and exit points of the lifecycle. This is the same kind of information as commands:, events:, and state:: it defines what the decider is, not what it does. Behavioral constructs (decide, evolve) belong in the body. Configuration belongs in the header.
The “exactly one initial” constraint moves from validation to grammar. The Decider rule’s initial: field is a single cross-reference, not a list. The parser rejects two initial: keywords before validation runs. This removes the InitialStateRequired validation rule entirely — the constraint is now enforced at parse time.
Terminal remains a list because a decider may have multiple absorbing states (e.g., terminal: Closed, Rejected).
Grammar changes
Before:
DeciderMember: Decision | Evolution | TerminalDecl | InitialDecl;
InitialDecl: 'initial' '(' state=[StateDecl:ID] ')' '=' value=BooleanValue;
TerminalDecl: 'terminal' '(' state=[StateDecl:ID] ')' '=' value=BooleanValue;
BooleanValue returns string: 'true' | 'false';After:
Decider: 'decider' name=ID '{' 'commands:' commands+=[Command:ID] (',' commands+=[Command:ID])* 'events:' events+=[Event:ID] (',' events+=[Event:ID])* 'state:' states+=StateDecl ('|' states+=StateDecl)* 'initial:' initial=[StateDecl:ID] ('terminal:' terminals+=[StateDecl:ID] (',' terminals+=[StateDecl:ID])*)? members+=DeciderMember* '}';
DeciderMember: Decision | Evolution;InitialDecl, TerminalDecl, and BooleanValue productions are removed. initial and terminals become typed properties on the Decider rule. DeciderMember is simplified to behavioral constructs only.
Validation rules that change
Removed:
InitialStateRequired— the grammar’sinitial:field (non-optional in the rule) enforces exactly one initial state at parse time. No validation rule is needed.- Boolean value filtering (
member.value === 'true') — eliminated throughout the codebase.
Retained (adapted to new AST shape):
| Rule | Before | After |
|---|---|---|
InitialStateFieldless | Filters isInitialDecl(m) && m.value === 'true' members | Reads decider.initial cross-reference directly |
InitialTerminalExclusive | Compares InitialDecl and TerminalDecl member sets | Compares decider.initial against decider.terminals list |
DecideTargetsTerminalState | Filters isTerminalDecl(m) && m.value === 'true' members | Reads decider.terminals list |
AllStatesTerminal | Filters isTerminalDecl(m) && m.value === 'true' members | Reads decider.terminals list |
New validation rules: None. The grammar change simplifies; it does not add semantic rules.
Impact on exhaustiveness and totality
exhaustiveness.ts and totality.ts both compute nonTerminalStates by subtracting the terminal set from the full state set. Currently they filter isTerminalDecl(member) && member.value === 'true'. After this change they read decider.terminals directly — a single property access replacing a predicated member scan.
Alternatives Considered
Keep predicate style, remove = false support
Accept only initial(State) = true and terminal(State) = true at the grammar level; make = false a parse error. This eliminates the noise problem but preserves the inconsistency problem: predicate form remains syntactically unlike commands:, events:, and state:. The exactly-one-initial constraint remains a validation rule rather than a grammar property.
Rejected. This is a half-measure that solves one of three problems.
Inline sigils on the state union
state: *Unregistered | Active(email: Email) | ~Inactive(reason: Reason)The * sigil marks the initial state; ~ marks terminal states. No new keywords. The state union line carries complete lifecycle topology.
Rejected. Sigils are cryptic to readers unfamiliar with the convention. The meaning of * and ~ is not self-evident; it must be documented and memorised. The approach conflates type declaration (what states exist, what fields they carry) with lifecycle semantics (which state is the entry point). These are separate concerns that should have separate declaration sites. The flat keyword: pattern is sufficient and requires no new symbol vocabulary.
Separate configuration block within the decider
decider User { commands: Register, Deactivate events: Registered, Deactivated state: Unregistered | Active(email: Email) | Inactive(reason: Reason)
config { initial: Unregistered terminal: Inactive }
decide(...) -> ...}A nested config block groups structural declarations and separates them visually from behavioral constructs.
Rejected. This introduces a new grammar construct (config { ... }) for two properties. The flat header approach — adding initial: and terminal: as fixed fields on Decider, in the same position as commands:, events:, and state: — achieves the same grouping without a new syntactic category. Over-engineering for two fields.
Affected Files
This ADR requires changes to every layer of the compiler pipeline. The complete impact matrix follows.
packages/language/ — Grammar and validation
| File | Change |
|---|---|
src/weltenwanderer.langium | Remove InitialDecl, TerminalDecl, BooleanValue productions. Add initial: and terminals: to Decider rule. Simplify DeciderMember to Decision | Evolution. |
src/generated/ast.ts | Auto-generated. Decider gains initial and terminals properties. InitialDecl, TerminalDecl types removed. |
src/index.ts | Remove TerminalDecl type export and isTerminalDecl / isInitialDecl guard exports. |
src/validation/initial-state.ts | Rewrite: remove isInitialDecl filtering; read decider.initial reference. Remove checkInitialStateRequired (grammar-enforced). |
src/validation/terminal-states.ts | Rewrite: remove isTerminalDecl filtering; read decider.terminals list. |
src/validation/exhaustiveness.ts | Simplify terminal state collection: decider.terminals instead of member filtering. |
src/validation/totality.ts | Same simplification as exhaustiveness.ts. |
src/validation/weltenwanderer-validator.ts | Remove registration of checkInitialStateRequired. Update remaining rule registrations to use adapted signatures. |
test/parsing/initial-state.test.ts | Rewrite for new syntax: initial: X instead of initial(X) = true. |
test/parsing/terminal-state.test.ts | Rewrite for new syntax: terminal: X and terminal: X, Y. |
test/validation/initial-state.test.ts | Remove tests for = false noise and multiple initial(X) = true. Retain and adapt fieldless and exclusive tests. |
test/validation/terminal-states.property.test.ts | Update property-based tests: remove = false declarations; use terminal list syntax. |
test/arbitraries/source.ts | Rewrite arbitrary generators: emit initial: X and terminal: X, Y instead of predicate form. Remove BooleanValue arbitrary. |
test/arbitraries/decider.ts | Update AST-level arbitraries: replace InitialDecl / TerminalDecl member construction with initial / terminals properties on Decider. |
packages/generator-emmett/ — Code generation
| File | Change |
|---|---|
src/generators/decider.ts | Replace isInitialDecl member filtering with decider.initial.ref. Replace isTerminalDecl filtering with decider.terminals iteration. |
test/arbitraries/codegen.ts | Update initialStateName construction: set initial reference on Decider instead of creating an InitialDecl member. Update terminalTrueStates / terminalFalseStates to terminals list. |
test/e2e/ | Update any .ddd fixtures to new syntax. |
packages/generator-mermaid/ — Diagram generation
| File | Change |
|---|---|
src/diagrams/state-diagram.ts | Replace states[0].name initial convention with decider.initial.ref?.name. Replace isTerminalDecl filtering with decider.terminals iteration. Remove TerminalDecl import. |
packages/cli/ — Command-line interface
| File | Change |
|---|---|
test/validate.test.ts | Update .ddd inline fixtures from terminal(X) = true to terminal: X. Add initial: X to all test deciders. |
examples/
| File | Change |
|---|---|
examples/minimal/registration.ddd | Change initial(Unregistered) = true to initial: Unregistered. Change terminal(Inactive) = true to terminal: Inactive. |
specs/ — Allium specifications
| File | Change |
|---|---|
specs/core.allium | Add is_initial: Boolean to State entity. Add derived initial_state = states.find(s => s.is_initial) to Decider entity. |
packages/language/specs/validation-terminal-states.allium | Update rule descriptions to reference decider.terminals instead of TerminalDecl member filtering. |
packages/generator-emmett/specs/codegen-terminal-states.allium | Update to reference decider.terminals property. |
website/ — Documentation
| File | Change |
|---|---|
src/content/docs/language/decider.md | Update syntax block and terminal section. Add initial state section. |
src/content/docs/language/state.md | Update terminal marking syntax. Add initial state marking. |
src/content/docs/language/validation/index.md | Update check summary table. Add initial state validation entries. |
src/content/docs/diagrams.md | Update initial state rendering (no longer convention-based). |
src/content/docs/project/adr/ADR-017-explicit-initial-state.md | Update frontmatter: supersededBy: "ADR-020". |
src/data/roadmap.yaml | Add initial-terminal-syntax feature entry or update terminal-state-validation. Add ADR-020 reference. |
Consequences
Positive
- Grammar enforces exactly-one initial at parse time.
InitialStateRequiredis eliminated as a validation rule — parser rejects the absence ofinitial:before validation runs. - Eliminates
= falsenoise and theBooleanValueproduction. The grammar no longer accepts meaningless declarations. - Consistent syntax across all decider declarations. The
keyword: itemspattern applies uniformly tocommands:,events:,state:,initial:, andterminal:. - Validation code simplifies. All terminal and initial state lookups become direct property accesses on
Decider(decider.initial,decider.terminals) instead of predicated member scans withisTerminalDecl/isInitialDeclguards. - Reduces AST type count.
InitialDecl,TerminalDecl, andBooleanValueare removed from the generated AST. - Mermaid state diagrams use the verified
decider.initialreference instead of thestates[0]positional convention. The convention broke silently on union reordering; the reference does not.
Negative
- Breaking change to all
.dddfiles. Every file containinginitial(X) = trueorterminal(X) = truemust be updated. The migration is mechanical and scriptable:sed -E 's/initial\(([^)]+)\) = true/initial: \1/g'andsed -E 's/terminal\(([^)]+)\) = true/terminal: \1/g'cover the common single-terminal case. - Supersedes ADR-017, which was accepted on the same date. ADR-017 is immediately superseded before any implementation ships, which means the
initial(State) = truesyntax never reaches a released version of the compiler. - Grammar ordering becomes load-bearing. The
Deciderrule requiresinitial:afterstate:and beforemembers. The parser enforces this order; misplaced declarations produce a parse error rather than a validation error.