ADR-021: Initial State Property Bindings
Context
ADR-020 established initial: StateName as the declarative initial state syntax. The checkInitialStateFieldless validation rule enforces that the referenced state has zero fields. This prevents modeling domains where the decider starts in a state that carries configuration data or default values.
Emmett’s initialState() function returns a full state object. If the state type has fields, those fields need values. The current workaround is to model a separate fieldless state (e.g., Uninitialized) and transition to the configured state via a setup command. This adds ceremony with no domain value.
Example: a rate limiter that starts with Config(maxRetries: Int, timeout: Int) currently requires a separate Pending state and a Configure command to reach Config. The domain expert does not think in terms of an uninitialized rate limiter — it starts configured.
Decision
Extend the initial: clause grammar to accept optional field bindings. Fieldless initial states remain valid (initial: Empty), preserving backward compatibility.
Syntax
Two binding forms scale from few fields to many:
Inline (1-3 fields):
initial: Config(maxRetries = 3, timeout = 30)Block (many fields):
initial: Config { maxRetries = 3 timeout = 30}Fieldless (unchanged):
initial: EmptyBinding constraints
- Bindings accept primitive literals only:
INT,FLOAT,STRING,BOOLEAN. - Value type constructors (branded types like
Email("x")) are explicitly deferred. No factory functions exist in the type-aliases generator. See Alternatives Considered below. - All fields must be bound. Partial initialization is not supported — there are no implicit defaults.
- Type checking validates each literal’s type against the field’s declared base type.
Grammar changes
Before (ADR-020):
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* '}';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=InitialStateRef ('terminal:' terminals+=[StateDecl:ID] (',' terminals+=[StateDecl:ID])*)? members+=DeciderMember* '}';
InitialStateRef: state=[StateDecl:ID] ( '(' bindings+=InitialBinding (',' bindings+=InitialBinding)* ')' | '{' bindings+=InitialBinding+ '}' )?;
InitialBinding: field=ID '=' value=InitialLiteral;
InitialLiteral: {IntLiteral} value=INT | {FloatLiteral} value=FLOAT | {StringLiteral} value=STRING | {BooleanLiteral} value=BooleanValue;Three new productions: InitialStateRef, InitialBinding, InitialLiteral. The initial field on Decider changes type from [StateDecl:ID] (cross-reference) to InitialStateRef (composite rule with embedded cross-reference).
Validation rule changes
| Rule | Before | After |
|---|---|---|
InitialStateFieldless | Reject any initial state with fields | Removed — replaced by InitialStateBindingsComplete |
InitialStateBindingsComplete (new) | N/A | WHEN initial state has fields: all fields must have bindings. WHEN initial state has no fields: no bindings allowed. |
InitialBindingTypeCheck (new) | N/A | Each binding’s literal type must match the field’s declared base type. |
InitialBindingValueTypeRejected (new) | N/A | WHEN a field’s type is a value type (branded), reject with actionable message explaining that value type constructors are not yet supported. |
InitialTerminalExclusive | Unchanged | Unchanged — reads initial.state reference instead of initial directly. |
Generated code impact
Fieldless (unchanged):
export function initialState(): ProcessorState { return { type: 'Empty' };}With bindings:
export function initialState(): ProcessorState { return { type: 'Config', maxRetries: 3, timeout: 30 };}The generator reads InitialStateRef.bindings and emits each field-value pair as a property in the state object literal. If no bindings exist, the output is identical to the current fieldless case.
Alternatives Considered
Default values on StateDecl
state: Config(maxRetries: Int = 3, timeout: Int = 30)Rejected. This conflates type declaration with initialization semantics. Defaults would apply everywhere the state is constructed (evolve clauses), not just the initial state. The StateDecl describes what fields a state has; the initial clause describes what values the decider starts with. These are separate concerns.
Runtime parameters on initialState()
export function initialState(config: { maxRetries: number }): ProcessorStateRejected. This moves values outside the DSL, making them unverifiable at compile time. The compiler cannot check type correctness or completeness of values provided at runtime. Violates the project’s core position: domain language is specification.
Value type constructors in literals
initial: Settings(email = Email("admin@example.com"))Deferred. Branded types (value types) currently have no factory functions in the generated code. The type-aliases generator emits type Email = string & { readonly __brand: unique symbol } — there is no Email(raw: string) constructor to call. Adding factory function generation is a separate concern tracked as a deferred roadmap item (value-type-factories).
Affected Files
packages/language/ — Grammar and validation
| File | Change |
|---|---|
src/weltenwanderer.langium | Add InitialStateRef, InitialBinding, InitialLiteral rules. Change initial field type from [StateDecl:ID] to InitialStateRef. |
src/generated/ast.ts | Auto-generated. New InitialStateRef, InitialBinding, InitialLiteral types. |
src/validation/initial-state.ts | Replace checkInitialStateFieldless with checkInitialStateBindingsComplete, checkInitialBindingTypeCheck, checkInitialBindingValueTypeRejected. |
src/validation/weltenwanderer-validator.ts | Update registration: remove checkInitialStateFieldless, add three new checks. |
test/parsing/initial-state.test.ts | Add tests for inline and block binding syntax. |
test/validation/initial-state.test.ts | Replace fieldless tests with binding completeness and type check tests. |
packages/generator-emmett/ — Code generation
| File | Change |
|---|---|
src/generators/decider.ts | Update generateInitialStateFunction to emit field values from bindings. |
| Tests | Add snapshot tests for initial state with bindings. |
specs/ — Allium specifications
| File | Change |
|---|---|
specs/core.allium | Add InitialBinding and InitialLiteral value types. Add initial_bindings to State entity. |
packages/language/specs/validation-initial-state.allium | New file: specify InitialStateBindingsComplete, InitialBindingTypeCheck, InitialBindingValueTypeRejected rules. |
website/ — Documentation
| File | Change |
|---|---|
src/data/roadmap.yaml | Add value-type-factories to deferred section. |
| Documentation pages | Update decider reference, initial state docs. |
Consequences
Positive
- Eliminates the ceremony of modeling artificial fieldless initial states. Domain experts describe the initial configuration directly.
- Compile-time verification of initial values: type-checked and completeness-checked. No runtime surprises.
- Backward compatible. Fieldless initial states (
initial: Empty) continue to work unchanged. - Generated
initialState()produces correctly typed state objects with field values. - Dual syntax (inline parenthesized, block braced) scales from 1-2 fields to many without readability loss.
Negative
- Increases grammar complexity by three new productions (
InitialStateRef,InitialBinding,InitialLiteral). - Primitive-only limitation may surprise users expecting value type constructors. The
InitialBindingValueTypeRejectedvalidation rule provides an actionable error message pointing to this limitation. - The
initialproperty onDeciderchanges type fromReference<StateDecl>toInitialStateRef. All code readingdecider.initialmust update todecider.initial.state. This affects validation, exhaustiveness, totality, and code generation modules.