Skip to content

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: Empty

Binding 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

RuleBeforeAfter
InitialStateFieldlessReject any initial state with fieldsRemoved — replaced by InitialStateBindingsComplete
InitialStateBindingsComplete (new)N/AWHEN initial state has fields: all fields must have bindings. WHEN initial state has no fields: no bindings allowed.
InitialBindingTypeCheck (new)N/AEach binding’s literal type must match the field’s declared base type.
InitialBindingValueTypeRejected (new)N/AWHEN a field’s type is a value type (branded), reject with actionable message explaining that value type constructors are not yet supported.
InitialTerminalExclusiveUnchangedUnchanged — 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 }): ProcessorState

Rejected. 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

FileChange
src/weltenwanderer.langiumAdd InitialStateRef, InitialBinding, InitialLiteral rules. Change initial field type from [StateDecl:ID] to InitialStateRef.
src/generated/ast.tsAuto-generated. New InitialStateRef, InitialBinding, InitialLiteral types.
src/validation/initial-state.tsReplace checkInitialStateFieldless with checkInitialStateBindingsComplete, checkInitialBindingTypeCheck, checkInitialBindingValueTypeRejected.
src/validation/weltenwanderer-validator.tsUpdate registration: remove checkInitialStateFieldless, add three new checks.
test/parsing/initial-state.test.tsAdd tests for inline and block binding syntax.
test/validation/initial-state.test.tsReplace fieldless tests with binding completeness and type check tests.

packages/generator-emmett/ — Code generation

FileChange
src/generators/decider.tsUpdate generateInitialStateFunction to emit field values from bindings.
TestsAdd snapshot tests for initial state with bindings.

specs/ — Allium specifications

FileChange
specs/core.alliumAdd InitialBinding and InitialLiteral value types. Add initial_bindings to State entity.
packages/language/specs/validation-initial-state.alliumNew file: specify InitialStateBindingsComplete, InitialBindingTypeCheck, InitialBindingValueTypeRejected rules.

website/ — Documentation

FileChange
src/data/roadmap.yamlAdd value-type-factories to deferred section.
Documentation pagesUpdate 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 InitialBindingValueTypeRejected validation rule provides an actionable error message pointing to this limitation.
  • The initial property on Decider changes type from Reference<StateDecl> to InitialStateRef. All code reading decider.initial must update to decider.initial.state. This affects validation, exhaustiveness, totality, and code generation modules.