Skip to content

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, Deactivate
events: Registered, Deactivated
state: 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’s initial: 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):

RuleBeforeAfter
InitialStateFieldlessFilters isInitialDecl(m) && m.value === 'true' membersReads decider.initial cross-reference directly
InitialTerminalExclusiveCompares InitialDecl and TerminalDecl member setsCompares decider.initial against decider.terminals list
DecideTargetsTerminalStateFilters isTerminalDecl(m) && m.value === 'true' membersReads decider.terminals list
AllStatesTerminalFilters isTerminalDecl(m) && m.value === 'true' membersReads 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

FileChange
src/weltenwanderer.langiumRemove InitialDecl, TerminalDecl, BooleanValue productions. Add initial: and terminals: to Decider rule. Simplify DeciderMember to Decision | Evolution.
src/generated/ast.tsAuto-generated. Decider gains initial and terminals properties. InitialDecl, TerminalDecl types removed.
src/index.tsRemove TerminalDecl type export and isTerminalDecl / isInitialDecl guard exports.
src/validation/initial-state.tsRewrite: remove isInitialDecl filtering; read decider.initial reference. Remove checkInitialStateRequired (grammar-enforced).
src/validation/terminal-states.tsRewrite: remove isTerminalDecl filtering; read decider.terminals list.
src/validation/exhaustiveness.tsSimplify terminal state collection: decider.terminals instead of member filtering.
src/validation/totality.tsSame simplification as exhaustiveness.ts.
src/validation/weltenwanderer-validator.tsRemove registration of checkInitialStateRequired. Update remaining rule registrations to use adapted signatures.
test/parsing/initial-state.test.tsRewrite for new syntax: initial: X instead of initial(X) = true.
test/parsing/terminal-state.test.tsRewrite for new syntax: terminal: X and terminal: X, Y.
test/validation/initial-state.test.tsRemove tests for = false noise and multiple initial(X) = true. Retain and adapt fieldless and exclusive tests.
test/validation/terminal-states.property.test.tsUpdate property-based tests: remove = false declarations; use terminal list syntax.
test/arbitraries/source.tsRewrite arbitrary generators: emit initial: X and terminal: X, Y instead of predicate form. Remove BooleanValue arbitrary.
test/arbitraries/decider.tsUpdate AST-level arbitraries: replace InitialDecl / TerminalDecl member construction with initial / terminals properties on Decider.

packages/generator-emmett/ — Code generation

FileChange
src/generators/decider.tsReplace isInitialDecl member filtering with decider.initial.ref. Replace isTerminalDecl filtering with decider.terminals iteration.
test/arbitraries/codegen.tsUpdate 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

FileChange
src/diagrams/state-diagram.tsReplace 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

FileChange
test/validate.test.tsUpdate .ddd inline fixtures from terminal(X) = true to terminal: X. Add initial: X to all test deciders.

examples/

FileChange
examples/minimal/registration.dddChange initial(Unregistered) = true to initial: Unregistered. Change terminal(Inactive) = true to terminal: Inactive.

specs/ — Allium specifications

FileChange
specs/core.alliumAdd 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.alliumUpdate rule descriptions to reference decider.terminals instead of TerminalDecl member filtering.
packages/generator-emmett/specs/codegen-terminal-states.alliumUpdate to reference decider.terminals property.

website/ — Documentation

FileChange
src/content/docs/language/decider.mdUpdate syntax block and terminal section. Add initial state section.
src/content/docs/language/state.mdUpdate terminal marking syntax. Add initial state marking.
src/content/docs/language/validation/index.mdUpdate check summary table. Add initial state validation entries.
src/content/docs/diagrams.mdUpdate initial state rendering (no longer convention-based).
src/content/docs/project/adr/ADR-017-explicit-initial-state.mdUpdate frontmatter: supersededBy: "ADR-020".
src/data/roadmap.yamlAdd initial-terminal-syntax feature entry or update terminal-state-validation. Add ADR-020 reference.

Consequences

Positive

  • Grammar enforces exactly-one initial at parse time. InitialStateRequired is eliminated as a validation rule — parser rejects the absence of initial: before validation runs.
  • Eliminates = false noise and the BooleanValue production. The grammar no longer accepts meaningless declarations.
  • Consistent syntax across all decider declarations. The keyword: items pattern applies uniformly to commands:, events:, state:, initial:, and terminal:.
  • Validation code simplifies. All terminal and initial state lookups become direct property accesses on Decider (decider.initial, decider.terminals) instead of predicated member scans with isTerminalDecl / isInitialDecl guards.
  • Reduces AST type count. InitialDecl, TerminalDecl, and BooleanValue are removed from the generated AST.
  • Mermaid state diagrams use the verified decider.initial reference instead of the states[0] positional convention. The convention broke silently on union reordering; the reference does not.

Negative

  • Breaking change to all .ddd files. Every file containing initial(X) = true or terminal(X) = true must be updated. The migration is mechanical and scriptable: sed -E 's/initial\(([^)]+)\) = true/initial: \1/g' and sed -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) = true syntax never reaches a released version of the compiler.
  • Grammar ordering becomes load-bearing. The Decider rule requires initial: after state: and before members. The parser enforces this order; misplaced declarations produce a parse error rather than a validation error.