@typeonce/effect-machine 0.19.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1578 +1,400 @@
1
1
  # Effect Machine agent guide
2
2
 
3
- This is the model-facing reference for the currently published
4
- `@typeonce/effect-machine` API. Prefer these patterns over reconstructing the API
5
- from its internal implementation.
3
+ Use this guide to model a statechart with `@typeonce/effect-machine`. It covers
4
+ the decisions that shape the machine. Use the API reference for method
5
+ signatures, history states, and choice states.
6
6
 
7
- ## Design priorities
7
+ Read [Effect Atom and React patterns](./effect-atom-react.md) when React needs
8
+ to consume a machine. Keep React ownership and atom lookup out of the machine
9
+ model.
8
10
 
9
- Prefer, in order: compile-time type safety, explicit and opinionated semantics,
10
- readable and concise machine models, and alignment with Effect core. Convenience
11
- must come from builders and inference rather than ambiguous omissions or weaker
12
- contracts. The package is pre-1.0, so improve or remove an existing API when a
13
- clearer long-term design replaces it; do not preserve an inferior design with
14
- aliases by default.
11
+ ## Create a machine
15
12
 
16
- Keep the core machine model local. Before adding a public name or capability,
17
- check Effect's existing modules and especially Cluster. Distributed identity,
18
- placement, discovery, transport, routing, delivery, sharding, and remote
19
- lifecycle belong to Cluster; expose integration through an explicit adapter
20
- instead of creating a similar local abstraction with different semantics.
21
-
22
- ## Public imports
13
+ Define schemas first, then states, events, the machine definition, and its
14
+ handlers. Export the state descriptor, public event descriptor, and implemented
15
+ machine. Tests, runtimes, and adapters can then use the same model.
23
16
 
24
17
  ```ts
25
18
  import { Machine } from "@typeonce/effect-machine"
26
- import { AtomMachine } from "@typeonce/effect-machine/reactivity"
27
- import { ClusterMachine } from "@typeonce/effect-machine/cluster"
28
- ```
29
-
30
- Do not import the published package as `effect/unstable/machine`. The package is
31
- currently coupled to the exact Effect peer version listed in its `package.json`.
32
-
33
- ## Definition order
34
-
35
- Use this order so inference has all schemas available when handlers are
36
- declared:
37
-
38
- 1. Domain schemas used by state, and by event fields when they are shared.
39
- 2. `Machine.states`, using a tagged state union and `.cases` when state
40
- schemas need to be reused.
41
- 3. `Machine.events`, `Machine.internalEvents`, `Machine.emittedEvents`, and any
42
- protocol passed to `Machine.parent` or `Machine.optionalParent`; pass
43
- `Schema.TaggedUnion({...})` or tagged classes directly.
44
- 4. `Machine.make({...}).handle({...})`.
45
- 5. Child descriptors, then runtime, Atom, or Cluster adapters.
46
-
47
- `Machine.make` returns a reusable definition. Each `handle` call creates one
48
- independent machine implementation and the result does not expose `handle`
49
- again. Put one implementation's complete behavior in a single handler tree;
50
- call `handle` again on the original definition for a separate production,
51
- testing, or simulation variant.
19
+ import { Schema } from "effect"
52
20
 
53
- `Schema.TaggedUnion` avoids one class declaration per case:
54
-
55
- ```ts
56
- const State = Schema.TaggedUnion({
57
- Saving: { draft: Draft },
58
- Failed: { message: Schema.String }
21
+ const CounterState = Schema.TaggedUnion({
22
+ Running: { count: Schema.Number }
59
23
  })
60
24
 
61
- const States = Machine.states({
25
+ export const CounterStates = Machine.states({
62
26
  Idle: {},
63
- Saving: State.cases.Saving,
64
- Failed: State.cases.Failed
27
+ Running: CounterState.cases.Running
65
28
  })
66
- export const Event = Machine.events(
67
- Schema.TaggedUnion({
68
- Save: {}
69
- })
70
- )
71
- export const Internal = Machine.internalEvents(
29
+
30
+ export const CounterEvents = Machine.events(
72
31
  Schema.TaggedUnion({
73
- Saved: { id: Schema.String },
74
- SaveFailed: { message: Schema.String }
32
+ Start: {},
33
+ Increment: {},
34
+ Stop: {}
75
35
  })
76
36
  )
77
- ```
78
-
79
- Pass these descriptors to `Machine.make`; the event descriptor is the public
80
- handle, so do not introduce a tagged-union binding used only by an event helper.
81
- Construct new state values through the target or initial
82
- builder's `.from(...)` method. Both event constructors and state `.from(...)`
83
- defer schema construction until planning, so validation failures remain typed
84
- machine errors. Use
85
- `Schema.TaggedClass` when a case needs class methods or nominal class identity;
86
- the deferred constructors preserve that identity after decoding.
87
-
88
- ## Hard invariants
89
-
90
- - `Machine.make({ initial })` expects a function, including for `Schema.Void`
91
- input.
92
- - State, emit, input, and output schemas validate their runtime boundaries.
93
- Event schemas provide decoders, but the local public/internal distinction is
94
- a TypeScript boundary; Cluster additionally validates public commands at its
95
- transport boundary.
96
- - Return snapshots or typed target-builder results from transitions. Do not
97
- return raw decoded state values.
98
- - Transition and lifecycle callbacks are synchronous. Put asynchronous work in
99
- an invoked Effect, logic process, or child machine and handle its lifecycle
100
- with `onDone`, `onFailure`, and `onSnapshot`.
101
- - Put data on the narrowest state where it is valid. Put data shared by sibling
102
- phases on their compound parent.
103
- - Declare finality only in the state definition. Do not put `type: "final"` in
104
- a handler.
105
- - Every declared output schema needs a matching handler implementation before
106
- planning or execution.
107
- - Handler `ancestors` keys are full dotted paths.
108
- - Invoke lifetimes follow state entry and exit, not the spelling of the target
109
- builder.
110
- - Handle every typed invoked Effect failure with `onFailure`. Defects and
111
- interruption terminate the owning machine.
112
- - Reuse an exported child descriptor for inline invocation, `sendTo`, and child
113
- lookup. Independently constructed descriptors are equivalent only when both
114
- their id and machine identity match.
115
- - `events` is the public machine-input protocol. `internalEvents` contains
116
- machine-local raised events. `parent: Machine.parent(events)` requires an
117
- owner, while `parent: Machine.optionalParent(events)` permits a root and
118
- exposes an optional owner. `emittedEvents` describes outward ephemeral
119
- notifications and is never delivered implicitly to a parent.
120
- - Event tags in `events` and `internalEvents` must be disjoint.
121
- - Event tags must also be unique within each protocol list.
122
-
123
- ## Canonical API choices
124
-
125
- Choose one helper from the intent, and reach for the lower-level form only when
126
- its extra control is required:
127
-
128
- - Bind a shared Atom runtime once with `AtomMachine.bind(runtime)`, then use the
129
- returned `make` or `resume`. Use `AtomMachine.make(machine)` and
130
- `AtomMachine.resume(machine, snapshot)` for service-free machines.
131
- - Use the state-local `invoke: (from) => ...` selector: `from.effect` for
132
- one-shot work, `from.stream` for repeated externally produced values,
133
- `from.timer` for a timer, `from.logic` for reusable process logic, and
134
- `from.child` for a complete child statechart. Its chain preserves owner state
135
- and source channels across lifecycle handlers. Inside `.handle(...)`, `self`
136
- and any declared `parent` use the owning definition's exact protocols.
137
- - Use `Machine.child(id, machine)` for a complete statechart descriptor and
138
- `Machine.childAddress<Event>(id)` for a low-level process address. A logic
139
- invocation is addressable only when `from.logic` receives that address
140
- explicitly.
141
- - Use the callback's `enqueue` argument for `raise`, `emit`, `sendTo`, and
142
- `stop`. These operations record closed machine commands and do not run Effects.
143
-
144
- ## Atomic, compound, parallel, and history states
145
-
146
- ### Topology is a validity boundary
147
-
148
- Design the state tree so invalid domain situations cannot be constructed. A
149
- parallel node is not merely a convenient grouping of related concepts: it
150
- declares the Cartesian product of its regions. Every combination must be
151
- meaningful in snapshots, explicit targets, decoding, and resume.
152
-
153
- If a handler reads a sibling region to decide whether entering its target is
154
- legal, treat that as a topology smell and try a compound hierarchy first. Do
155
- not move the same invariant into a disabled UI control, redundant event field,
156
- or invoked-service failure. Cross-region reads remain useful for coordinating
157
- genuinely independent regions and for projecting snapshots into views.
158
-
159
- Place an invoked Effect or resource-dependent state beneath the state that
160
- guarantees the resource exists. Exiting the owner should structurally exit and
161
- interrupt all dependent work.
162
-
163
- ### Inline topology by default; extract only repeated states
164
-
165
- Prefer writing the complete topology inline in `Machine.states`. A one-off
166
- compound or parallel area is easier to understand in place, and extracting it
167
- does not improve its types. Use `Machine.state` only when the same active state
168
- definition is mounted more than once. Tagged schemas are already reusable and
169
- do not need `Machine.state`.
170
37
 
171
- ```ts
172
- type TeamSlot = 1 | 2 | 3 | 4 | 5 | 6
173
-
174
- const TradingSlot = Machine.state({
175
- initial: "Idle",
176
- states: {
177
- Idle: {},
178
- InSession: State.cases.InSession,
179
- Applying: State.cases.Applying
180
- }
181
- })
182
-
183
- const States = Machine.states({
184
- root: {
185
- type: "parallel",
186
- states: {
187
- trading: {
188
- type: "parallel",
189
- states: {
190
- slot1: TradingSlot,
191
- slot2: TradingSlot,
192
- slot3: TradingSlot,
193
- slot4: TradingSlot,
194
- slot5: TradingSlot,
195
- slot6: TradingSlot
196
- }
197
- },
198
- // Other explicit regions stay visible here.
38
+ export const CounterMachine = Machine.make({
39
+ id: "Counter",
40
+ states: CounterStates.states,
41
+ events: CounterEvents,
42
+ initial: (to) => to.Idle()
43
+ }).handle({
44
+ Idle: {
45
+ on: {
46
+ Start: (to) => to.full.Running().resolve(({ target }) => target.from({ count: 0 }))
199
47
  }
200
- }
201
- })
202
- ```
203
-
204
- `Machine.state` accepts one active atomic, compound, or parallel node. It
205
- checks child keys and the compound `initial` at the reusable definition. It is
206
- not a second model builder, does not define handlers, and does not accept
207
- history or choice nodes as roots. `Machine.states` remains the complete model
208
- boundary and captures every mount independently.
209
-
210
- For a finite family of paths, bind the template to that definition instead of
211
- maintaining a parallel string table:
212
-
213
- ```ts
214
- const inSessionPath = <const Slot extends TeamSlot>(slot: Slot) =>
215
- States.path(`root.trading.slot${slot}.InSession`)
216
-
217
- States.matches(snapshot, inSessionPath(slot))
218
- AtomMachine.matches(machineAtom, inSessionPath(slot))
219
- ```
220
-
221
- `States.path` is a compile-time identity helper. It accepts a literal or a
222
- finite template-literal union only when every member is an active path in this
223
- tree. Renaming a slot or child therefore breaks the path helper at its
224
- definition rather than leaving a stale catalog.
225
-
226
- Use the definition-bound snapshot type when a query genuinely needs the full
227
- machine snapshot:
228
-
229
- ```ts
230
- const offeredIfSlot = (
231
- snapshot: Machine.Snapshot<typeof States>,
232
- slot: TeamSlot
233
- ) =>
234
- !States.matches(snapshot, inSessionPath(slot))
235
- ```
236
-
237
- Do not derive this type with `Parameters<typeof States.get>[0]`; that depends
238
- on overload order and does not express ownership by the state definition.
239
-
240
- The same extractor accepts a machine when that is the object exported at the
241
- consumer boundary. Use `Value` for a decoded schema-backed state payload and
242
- `SnapshotAt` for the snapshot rooted at one active path:
243
-
244
- ```ts
245
- type Complete = Machine.Snapshot<typeof machine>
246
- type Session = Machine.Value<typeof States, "root.trading.InSession">
247
- type Trading = Machine.SnapshotAt<typeof machine, "root.trading">
248
- ```
249
-
250
- `Value` accepts only paths that own a schema, matching `States.get`.
251
- `SnapshotAt` also accepts structural paths, matching `States.getSnapshot`.
252
- Both reject stale or misspelled paths. Prefer these definition- or
253
- machine-bound forms over `.cases.Case.Type`, `typeof States.states`, or
254
- composing `Machine.Machine.States` with raw-tree path extractors.
255
-
256
- An active state does not need a schema unless it owns data. Omit `schema` for
257
- control-only atomic, compound, parallel, and final states. In particular, use
258
- `{}` instead of an empty tagged-union case or tagged class:
259
-
260
- ```ts
261
- const States = Machine.states({
262
- Idle: {},
263
- Form: {
264
- initial: "Editing",
265
- states: {
266
- Editing: {},
267
- Saving: State.cases.Saving
48
+ },
49
+ Running: {
50
+ on: {
51
+ Increment: (to) => to.full.Running().resolve(({ state, target }) => target.from({ count: state.count + 1 })),
52
+ Stop: (to) => to.full.Idle()
268
53
  }
269
54
  }
270
55
  })
271
-
272
- initial: (to) => to.Form.initial.resolve(({ target }) => target((form) => form.Editing.from()))
273
56
  ```
274
57
 
275
- Schema-less states have the same control semantics as schema-backed states:
276
- they are active, targetable, matchable, receive lifecycle handlers, and appear
277
- in snapshots. They do not have a state value:
58
+ Each step has one job:
278
59
 
279
- ```ts
280
- Idle: {
281
- on: {
282
- Start: (to) => to.full.Form.initial
283
- }
284
- }
285
-
286
- States.matches(snapshot, "Form") // allowed
287
- States.getSnapshot(snapshot, "Form") // allowed
288
- States.get(snapshot, "Form") // type error: no value schema
289
- ```
60
+ - `Machine.states` declares the state tree and the data owned by each state.
61
+ - `Machine.events` declares the public messages the machine accepts and returns
62
+ typed event constructors.
63
+ - `Machine.make` joins the state tree, event protocol, input, and initial state.
64
+ - `.handle` implements the behavior of every active state and returns the
65
+ machine to export.
290
66
 
291
- For a schema-less path, builders expose only `.from(...)`; the direct callable
292
- form is reserved for already-decoded schema values. Structural ancestors are
293
- also omitted from `ancestors`; an immediate structural containing state is
294
- typed as `undefined`. Add `schema` when a state begins to own data or needs runtime
295
- validation and persistence for that data.
67
+ Chain `.handle` from `Machine.make`. Do not store the intermediate definition
68
+ when the module exports one machine implementation.
296
69
 
297
- Declare data-bearing states together with the named
298
- `const State = Schema.TaggedUnion(...)` pattern, then reference `State.cases`
299
- from the topology. This keeps the state value protocol visible and reusable.
300
- Use a named `Schema.TaggedClass` instead when a standalone state benefits from
301
- class identity. Do not bury one-off tagged schema declarations inside
302
- `Machine.states`.
70
+ State builders construct the next snapshot. Use `.from(...)` when a state owns
71
+ data. The machine validates that input through the state schema while it plans
72
+ the transition.
303
73
 
304
- Use an atomic state when no child phase can be active beneath it.
74
+ The examples below show one modeling decision at a time. They omit unchanged
75
+ state and event declarations already shown above.
305
76
 
306
- Use a compound state when exactly one child phase is active. It must declare an
307
- `initial` child:
77
+ Start the implemented machine at the application boundary and send events
78
+ through the exported descriptor:
308
79
 
309
80
  ```ts
310
- const FormState = Schema.TaggedUnion({ Saving: { draft: Schema.String } })
311
-
312
- const FormStates = Machine.states({
313
- Form: {
314
- initial: "Editing",
315
- states: {
316
- Editing: {},
317
- Saving: FormState.cases.Saving
318
- }
319
- }
320
- })
321
- ```
81
+ import { Effect } from "effect"
322
82
 
323
- Use a parallel state when every direct region is active:
83
+ const program = Effect.gen(function*() {
84
+ const counter = yield* Machine.start(CounterMachine)
324
85
 
325
- ```ts
326
- const ParallelStates = Machine.states({
327
- Screen: {
328
- type: "parallel",
329
- states: {
330
- network: {
331
- initial: "Online",
332
- states: {
333
- Online: {},
334
- Offline: {}
335
- }
336
- },
337
- panel: {
338
- initial: "Closed",
339
- states: {
340
- Closed: {},
341
- Open: {}
342
- }
343
- }
344
- }
345
- }
86
+ yield* counter.send(CounterEvents.Start())
87
+ yield* counter.send(CounterEvents.Increment())
346
88
  })
347
89
  ```
348
90
 
349
- Every parallel region needs an active state in initial and full snapshot
350
- builders. The same rule applies when a local or branch target enters an
351
- inactive nested parallel state.
91
+ ## Make impossible states unrepresentable
352
92
 
353
- This is also a semantic product: `Online + Closed`, `Online + Open`,
354
- `Offline + Closed`, and `Offline + Open` are all valid configurations in the
355
- example above. If even one combination must be prevented for correctness, use
356
- a compound hierarchy or redesign the regions.
93
+ A finite state describes how the machine behaves now. State data holds values
94
+ needed while that mode is active.
357
95
 
358
- Use `type: "final"` for a terminal leaf in `Machine.states`. A final
359
- child completes its compound parent. Put `onDone` on that completed parent,
360
- never on the final leaf. The definition owns the output schema and the handler
361
- computes its value:
96
+ Do not model mutually exclusive modes with separate flags such as `loading`,
97
+ `data`, and `error`. Those fields permit combinations such as loading with both
98
+ data and an error. Put the modes in the state tree instead:
362
99
 
363
100
  ```ts
364
- const States = Machine.states({
365
- Done: {
366
- schema: State.cases.Done,
367
- type: "final",
368
- output: Schema.String
369
- }
101
+ const RequestState = Schema.TaggedUnion({
102
+ Ready: { value: Schema.String },
103
+ Failed: { message: Schema.String }
370
104
  })
371
105
 
372
- const machine = Machine.make({
373
- states: States.states,
374
- events: Machine.events(),
375
- initial: (to) => to.Done()
376
- }).handle({
377
- Done: {
378
- output: () => "done"
379
- }
106
+ const RequestStates = Machine.states({
107
+ Idle: {},
108
+ Loading: {},
109
+ Ready: RequestState.cases.Ready,
110
+ Failed: RequestState.cases.Failed
380
111
  })
381
112
  ```
382
113
 
383
- Do not repeat `type: "final"` in `handle`. Execution APIs reject a machine
384
- until every declared output schema has an implementation.
385
-
386
- Enter a compound or parallel state through its declared initial configuration
387
- with `.initial`. This is available on top-level state methods under
388
- `target.full` and compatible nested state methods under `target.local` and
389
- `target.branch`; atomic and final state methods do not expose it:
390
-
391
- ```ts
392
- Open: (to) => to.full.opened.initial.resolve(({ target }) => target.from({ teamId: "team-1" }))
393
- ```
394
-
395
- Return the `.initial` transition directly when the selected state owns no data
396
- and the transition has no commands to enqueue:
397
-
398
- ```ts
399
- Close: (to) => to.full.closed.initial
400
- ```
401
-
402
- Do not manually reconstruct the declared initial descendants at ordinary entry
403
- transitions. Reserve explicit descendant builders for deliberately non-default
404
- configurations and for replacing an already-active parallel root with one
405
- complete canonical configuration. `Machine.make({ initial })` still constructs
406
- the first complete snapshot through its initial selector; that selector
407
- statically restricts a compound node to its declared initial child.
408
-
409
- The definition-time `.initial` property is a topology value. The exact
410
- resolver `target` is still a callable runtime builder.
411
-
412
- The selected state's own value is passed directly to `initial(value)` or
413
- constructed inside planning with `initial.from(input)`. A structural selected
414
- state uses `initial()`.
415
-
416
- When a declared initial child owns a schema, its parent implements
417
- `initialize`. The context's `builder` is already bound to that child, so it
418
- cannot accidentally select a state that differs from the definition:
419
-
420
- ```ts
421
- opened: {
422
- initialize: ({ state, builder }) =>
423
- builder.from({ requestId: state.requestId })
424
- }
425
- ```
426
-
427
- A parallel initializer supplies every schema-valued direct region with a
428
- fluent completion builder. Structural regions are omitted:
114
+ The machine can now be `Loading`, `Ready`, or `Failed`. It cannot construct a
115
+ snapshot that represents two of those modes at once.
429
116
 
430
- ```ts
431
- dashboard: {
432
- initialize: ({ builder }) =>
433
- builder.filters.from({ query: "" }).results.from({ page: 1 })
434
- }
435
- ```
117
+ Use this test when deciding between a state and a field: if the value changes
118
+ which events the machine should handle, which work runs, or how the machine
119
+ behaves, model it as a state. Otherwise, keep it as data on the state that owns
120
+ it.
436
121
 
437
- Default entry then continues recursively. Nested compound and parallel owners
438
- provide their own `initialize` implementations. Missing implementations and
439
- incomplete parallel builders are reported at `handle(...)`. Builder `.from`
440
- inputs are decoded by the machine, so schema failures remain typed machine
441
- failures. An explicit snapshot target that manually selects all children does
442
- not use `initialize`.
122
+ ## Put data on the lowest state that owns it
443
123
 
444
- Declare a history pseudo-state below the active parent whose configuration it
445
- should remember. It has no schema, is excluded from active state identifiers,
446
- and is addressed only through `target.history`:
124
+ State data should exist only while it is valid. Put it on the lowest node whose
125
+ active subtree needs it. If several sibling states need the same data, their
126
+ compound parent owns it.
447
127
 
448
128
  ```ts
449
- const States = Machine.states({
450
- checkout: {
451
- schema: Checkout,
452
- initial: "shipping",
453
- states: {
454
- shipping: Shipping,
455
- payment: {
456
- schema: Payment,
457
- initial: "cardEntry",
458
- states: {
459
- cardEntry: CardEntry,
460
- verifying: Verifying
461
- }
462
- },
463
- recent: { type: "history" },
464
- exact: { type: "history", history: "deep" }
465
- }
129
+ const DocumentState = Schema.TaggedUnion({
130
+ Open: {
131
+ documentId: Schema.String,
132
+ draft: Schema.String
466
133
  },
467
- support: Support
468
- })
469
- ```
470
-
471
- Every history node needs a source-independent default for the first use. The
472
- default is a complete root snapshot containing the history owner:
473
-
474
- ```ts
475
- checkout: {
476
- history: {
477
- recent: { default: () => initialCheckoutSnapshot },
478
- exact: { default: () => initialCheckoutSnapshot }
134
+ SaveFailed: {
135
+ message: Schema.String
479
136
  }
480
- }
481
- ```
482
-
483
- Target it without a value:
484
-
485
- ```ts
486
- Resume: (to) => to.history.checkout.exact
487
- ```
488
-
489
- Each declared history leaf is a topology value and can be returned directly
490
- when no resolver work is needed.
491
-
492
- Deep history restores the complete remembered subtree and its decoded values.
493
- Shallow history restores only parent and direct-child values. If the remembered
494
- child is compound, its configured initial child needs a freshly constructed
495
- value, so implement `initialize` only on paths required by shallow history:
496
-
497
- ```ts
498
- payment: {
499
- initialize: ({ state, builder }) =>
500
- builder.from({ cardNumber: `attempt-${state.attempt}` })
501
- }
502
- ```
503
-
504
- A nested default must include every ancestor above its owner and every region
505
- of any parallel ancestor. The containing branch is checked statically, so an
506
- unrelated root, a sibling compound branch, a direct-owner-only nested snapshot,
507
- or an incomplete parallel configuration is rejected. A canonical nested
508
- default looks like:
137
+ })
509
138
 
510
- ```ts
511
- Workspace: {
512
- history: {
513
- resume: {
514
- default: ({ target }) =>
515
- target.App.from({ workspaceId: "default" }, (app) =>
516
- app.Workspace.from((workspace) => workspace.Editing.from()))
139
+ const DocumentStates = Machine.states({
140
+ Closed: {},
141
+ Open: {
142
+ // Editing, Saving, and SaveFailed all need the document and draft.
143
+ schema: DocumentState.cases.Open,
144
+ initial: "Editing",
145
+ states: {
146
+ Editing: {},
147
+ Saving: {},
148
+ // Only this state owns an error message.
149
+ SaveFailed: DocumentState.cases.SaveFailed
517
150
  }
518
151
  }
519
- }
520
- ```
521
-
522
- On first use from an inactive root, this complete configuration is entered. If
523
- a parallel ancestor is already active, unaffected active regions are retained.
524
- Once a history record exists, shallow or deep recorded restoration wins over
525
- the default.
526
-
527
- The machine's readiness type tracks missing defaults and shallow initializers.
528
- History is an overwriteable register, not a stack: restoration does not consume
529
- it, and the next parent exit replaces it. Entry actions and invokes run again;
530
- prior effects, machine instances, and timers are not rewound.
531
-
532
- ## Choosing a target
533
-
534
- | Builder | Use it when | What it preserves |
535
- | ---------------- | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
536
- | `target.local` | The destination is inside the nearest compound scope containing the source | The compound value, active ancestors, and unrelated parallel regions |
537
- | `target.branch` | The destination is elsewhere under the active top-level root | Omitted current ancestor values and parallel regions |
538
- | `target.full` | The destination may be under any top-level root | Nothing is inferred for a newly selected root; build its complete active snapshot |
539
- | `target.history` | The destination is a declared history pseudo-state | Its parent's remembered configuration, or a source-independent complete default containing that owner before the first capture |
540
-
541
- Definition-time instructions that only identify topology are values:
542
- `to.none`, `to.full.Flow.initial`, `to.history.Flow.recent`, and
543
- `to.local.with`. State and choice destinations remain calls, such as
544
- `to.full.Running()` and `to.local.Routing()`, because those calls select the
545
- node. Resolver-time builders also remain callable because they construct and,
546
- for named branches, brand runtime evidence such as `select.unchanged()`.
547
-
548
- Use `to.local.with` when a descendant transition updates the nearest
549
- schema-backed compound value while retaining that same compound scope:
550
-
551
- ```ts
552
- Play: (to) =>
553
- to.local.with.resolve(({ containingState, target }) =>
554
- target.from({ ...containingState, playing: true }, (flow) => flow.Playing.from()))
555
- ```
556
-
557
- Entering an inactive parallel state through `target.local` or `target.branch`
558
- requires a complete callback with one selection per region. A parallel state
559
- that is already active remains partially addressable through `target.branch`;
560
- unmentioned active regions are preserved.
561
-
562
- These describe configuration construction, not automatic process restart.
563
- Machine planning compares active paths and derives the actual exit and entry
564
- sets. A `target.full` result with the same active paths can update values without
565
- exiting shared states. To force the source to exit and enter again:
566
-
567
- ```ts
568
- Refresh: (to) =>
569
- to.full.Ready().resolve(({ state, target }) => target.from({ value: state.value }), { reenter: true })
570
- ```
571
-
572
- When no resolver is needed and the selected builder supports zero-argument
573
- construction, return the selected target directly. This applies the same
574
- default construction as `target.from()`; the compiler rejects the shorthand
575
- when state data or nested configuration is required. Append `.reenter()` only
576
- when restart semantics are intentional:
577
-
578
- ```ts
579
- Finish: (to) => to.full.Done()
580
- Restart: (to) => to.none.reenter()
581
- ```
582
-
583
- Do not use `target.full` merely because it is easiest to discover. Prefer the
584
- narrowest builder that expresses the intended configuration change.
585
-
586
- Every state builder method has two construction forms:
587
-
588
- ```ts
589
- target(decodedReady)
590
- target.from({ value: event.value })
591
- ```
592
-
593
- The direct call accepts the schema's decoded `Type`. `.from` accepts its
594
- `~type.make.in`, so callers do not need to invoke a TaggedUnion case's `make`
595
- or instantiate a TaggedClass. The machine resolves `.from` with
596
- `schema.makeEffect` during planning. Constructor defaults and class identity
597
- are retained; refinement failures use `MachineSchemaDecodeError` at the state
598
- boundary rather than throwing synchronously. This applies recursively to
599
- initial, full, local, branch, compound, parallel, final, and `local.with`
600
- builders.
601
-
602
- If `{}` satisfies the schema's constructor input, omit it:
603
-
604
- ```ts
605
- target.from()
606
- target.from((flow) => flow.Idle.from())
607
- ```
608
-
609
- This shorthand also applies to schemas whose constructor fields are all
610
- optional or defaulted. It does not make required fields optional. Compound and
611
- parallel builders still require a callback selecting their active child or
612
- every active region. Omitted input is normalized to `{}` and still passes
613
- through `schema.makeEffect`, including refinements.
614
-
615
- ## Reading state and structural ancestors
616
-
617
- `Machine.states` returns typed helpers:
618
-
619
- ```ts
620
- States.get(snapshot, "Route.Ready")
621
- States.getWithParents(snapshot, "Route.Ready.Editing")
622
- States.getSnapshot(snapshot, "Route.Ready")
623
- States.matches(snapshot, "Route.Ready.Saving")
624
- ```
625
-
626
- Snapshots returned by `getSnapshot` can be queried again with `get`,
627
- `getSnapshot`, or `matches`. Paths remain absolute and are restricted to the
628
- extracted snapshot and its descendants:
629
-
630
- ```ts
631
- const ready = Option.getOrThrow(States.getSnapshot(snapshot, "Route.Ready"))
632
- States.matches(ready, "Route.Ready.Saving")
633
- ```
634
-
635
- All paths are checked against the definition. `get` and `getWithParents` accept
636
- only schema-backed paths; use `matches` or `getSnapshot` for any active path.
637
- `context.containingState` is the immediate typed state value (`undefined` at a
638
- root or when that state is schema-less). `context.ancestors` contains only
639
- valued structural ancestors. This is separate from `context.parent`, which is
640
- present only when declared by the machine. `Machine.parent` makes it a required
641
- owning-machine target; `Machine.optionalParent` makes it a target or
642
- `undefined`. Use full state paths when another ancestor value is needed:
643
-
644
- ```ts
645
- ancestors["Route.Ready"]
646
- ancestors["Route.Ready.Editing"]
647
- ```
648
-
649
- Do not guess short properties such as `ancestors.Ready`.
650
-
651
- ### Inspecting the full transition configuration
652
-
653
- Event, `always`, and `onDone` transition contexts include a fully typed
654
- `snapshot`. It is the complete logical snapshot at the beginning of that
655
- microstep, before any selected transition is applied:
656
-
657
- ```ts
658
- BufferReady: (to) =>
659
- to.branches({
660
- online: { target: to.local.Playing() },
661
- unchanged: { target: to.none }
662
- }).resolve(({ snapshot, select }) =>
663
- States.matches(snapshot, "Player.Network.Online")
664
- ? select.online.from()
665
- : select.unchanged()
666
- ```
667
-
668
- Use the existing `States.matches`, `States.get`, `States.getWithParents`, and
669
- `States.getSnapshot` helpers for cross-region reads. Parallel transitions
670
- selected in one microstep receive the same capture. Synchronous handlers use
671
- that captured value and cannot consult live runtime state later.
672
-
673
- Before using a cross-region read to permit or reject a target, verify that all
674
- combinations of the parallel regions are valid. If the check excludes an
675
- invalid combination, move the invariant into a compound hierarchy. Observer,
676
- view, diagnostic, and test queries do not have this concern.
677
-
678
- Do not expect `snapshot` in entry, exit, invoke, initializer, history-default,
679
- or choice contexts. Choice is an important soundness boundary: a startup or
680
- chained choice can run without a complete stable configuration containing the
681
- pseudo-source, so the API does not fabricate a partial `Machine.Snapshot`.
682
-
683
- ### State annotations
684
-
685
- Attach active-state metadata through Effect Schema:
686
-
687
- ```ts
688
- const Saving = State.cases.Saving.annotate({
689
- title: "Saving document",
690
- description: "Persisting local changes to the server",
691
- documentation: "https://docs.example.test/saving"
692
152
  })
693
153
  ```
694
154
 
695
- `Machine.stateNodes(machine)` returns the resolved annotation map. Choice and
696
- history definitions may declare an `annotations` object containing only
697
- `title`, `description`, and `documentation`. These values are descriptive;
698
- they cannot change behavior, identity, or targeting. Visualization may show a
699
- title, while the structural path remains authoritative.
700
-
701
- When sibling state payloads share fields, destructure away the source
702
- discriminator and construct the destination through its target builder:
703
-
704
- ```ts
705
- Submit: (to) =>
706
- to.local.Saving().resolve(({ state, target }) => {
707
- const { _tag: _, ...fields } = state
708
- return target.from({ ...fields, attempt: 1 })
709
- })
710
- ```
711
-
712
- The target schema remains responsible for defaults, transforms, refinements,
713
- and class identity. Prefer moving broadly shared data to the compound parent
714
- rather than copying it through every phase.
155
+ Do not copy `documentId` and `draft` into every child. Copies can disagree after
156
+ a transition. Do not move `message` to `Open` either. That would allow an error
157
+ message while `Editing` or `Saving` is active.
715
158
 
716
- ## Planning, actions, raised events, and emissions
159
+ ## Put shared behavior on the lowest common ancestor
717
160
 
718
- A transition declares every possible branch and resolves the selected target
719
- synchronously:
161
+ Hierarchy owns behavior as well as data. Define a transition on the lowest
162
+ compound state whose children share it.
720
163
 
721
164
  ```ts
722
- Submit: (to) =>
723
- to.branches({
724
- valid: { target: to.local.Saving() },
725
- invalid: { target: to.none }
726
- }).resolve(({ state, select }) => state.valid
727
- ? select.valid.from({ draft: state.draft })
728
- : select.invalid()
729
- ```
730
-
731
- Every installed event, `always`, `onDone`, choice, and invoke lifecycle handler
732
- receives a bound `to` selector. A direct transition selects one target and calls
733
- its `resolve` method. A branching transition calls `to.branches` with every
734
- possible target, then uses ordinary TypeScript control flow in `resolve` to return one
735
- typed `select` builder. Branch keys are stable testing and inspection identities;
736
- an optional `title` controls presentation and otherwise defaults to the key.
737
- Selecting a branch whose target is `to.none` handles the transition without a
738
- destination while retaining queued commands, raised events, and emitted events.
739
-
740
- Set `declinable: true` only when the resolver may decide that its transition is
741
- not enabled. The flag adds a typed `decline()` capability to that resolver and
742
- permits its opaque result:
743
-
744
- ```ts
745
- Submit: (to) =>
746
- to.branches({
747
- accepted: { target: to.local.Saving() },
748
- consumed: { target: to.none }
749
- }).resolve(({ event, select, decline }) => {
750
- if (!belongsToThisState(event)) return decline()
751
- return event.consume ? select.consumed() : select.accepted.from()
752
- }, { declinable: true })
753
- ```
754
-
755
- Declining discards that resolver's enqueue buffer and resumes hierarchical
756
- event or eventless selection at the next eligible ancestor. If no candidate
757
- accepts, the trigger is unhandled. This is deliberately different from
758
- `to.none`, which consumes the trigger. `decline()` is absent and its result is
759
- rejected unless the literal flag is present. Choice and initial routing remain
760
- total and cannot decline. Static inspection exposes the distinction through
761
- `TransitionDefinition.acceptance` without executing resolver code. Completion
762
- and invocation outcomes have no ancestor candidate; declining one ignores that
763
- lifecycle occurrence and leaves the current configuration active.
764
-
765
- The `branches` callback runs once when handlers are installed. Its record uses
766
- the deterministic ECMAScript property order for presentation and `branchIndex`;
767
- array-index and symbol keys are rejected. Treat the string key as semantic:
768
- reordering named properties may change their display index, but visualizers,
769
- coverage, and trace verification identify each branch by its key.
770
-
771
- `reenter: true` remains meaningful with `to.none`: the source exits and
772
- enters again while its logical configuration is retained.
773
-
774
- Closed statechart and machine operations use `enqueue`:
775
-
776
- ```ts
777
- Submit: (to) =>
778
- to.local.Saving().resolve(({ target }, enqueue) => {
779
- enqueue.emit(Emissions.SaveRequested())
780
- return target.from()
165
+ const DocumentEvents = Machine.events(
166
+ Schema.TaggedUnion({
167
+ Close: {}
781
168
  })
782
- ```
783
-
784
- Declare emission constructors separately from machine inputs:
785
-
786
- ```ts
787
- const Emissions = Machine.emittedEvents(SaveRequested, AuditRecorded)
788
-
789
- const definition = Machine.make({
790
- events: Commands,
791
- internalEvents: InternalEvents,
792
- emittedEvents: Emissions,
793
- // ...
794
- })
795
- ```
796
-
797
- `enqueue.raise(...)` is a same-macrostep input to self. `enqueue.sendTo(...)`
798
- targets a machine mailbox and is processed later. `enqueue.emit(...)` is neither:
799
- it publishes a one-off outward notification. Observe it with
800
- `ref.emissions`, a hot non-replayed `Stream` that completes with the machine.
801
- `ref.changes` is stateful and begins with the current lifecycle snapshot.
802
- Use `Machine.prepare(machine)` to obtain `changes` and `emissions` before
803
- initialization. Subscribe to the desired stream and then evaluate
804
- `prepared.start`. `Machine.start(machine)` remains the one-step convenience
805
- when startup observation is unnecessary. Emissions are still never retained or
806
- replayed; state remains the representation for facts that must be retained.
807
-
808
- ```ts
809
- const prepared = yield* Machine.prepare(machine)
810
- yield* prepared.emissions.pipe(
811
- Stream.runForEach(handleEmission),
812
- Effect.forkScoped({ startImmediately: true })
813
- )
814
- const ref = yield* prepared.start
815
- ```
816
-
817
- `prepared.inspection` is a third, operational stream. It covers the root and
818
- its complete local ownership tree rather than one machine protocol. Subscribe
819
- before `prepared.start` when creation and initialization records matter:
820
-
821
- ```ts
822
- const prepared = yield* Machine.prepare(machine)
823
- yield* prepared.inspection.pipe(
824
- Stream.runForEach((event) => Console.log(event.sequence, event.subject.id, event._tag)),
825
- Effect.forkScoped({ startImmediately: true })
826
169
  )
827
- const ref = yield* prepared.start
828
- ```
829
-
830
- `Machine.Inspection.Event` is a closed union:
831
-
832
- - `Created`, `Initialized`, and `StartFailed` describe process startup;
833
- - `EventSent` records accepted mailbox delivery and `EventProcessed` records
834
- the committed macrostep, including retained transitions, raised events,
835
- emissions, commands, and entry/exit paths for each microstep;
836
- - `StateChanged` describes direct updates made by generic `Logic`;
837
- - `Emitted` records actual outward notification publication;
838
- - `ActivityStarted` and `ActivityStopped` describe Effect and timer invokes;
839
- - `Terminated` carries the final `done`, `error`, or `stopped` snapshot.
840
-
841
- Every record has a root-local `sequence`, `rootSessionId`, and `subject`.
842
- `deliveryId` correlates acceptance with processing; `macrostepId` correlates
843
- work caused by one statechart input. `source` is present for sends originating
844
- inside the inspected tree. `origin` distinguishes a root, state-owned invoke,
845
- and explicit spawn. Child machine and generic process protocols are erased to
846
- `unknown` because one stream can contain unrelated types.
847
-
848
- Inspection is hot, non-replayed, never fails, and completes with the prepared
849
- root. It is not a replacement for `changes`, which retains the latest lifecycle
850
- snapshot, or `emissions`, which remains the typed domain-notification channel.
851
- Invalid decoded inputs or emissions still fail the owning machine through its
852
- typed `MachineSchemaDecodeError`; inspection never turns validation into a
853
- throw or a stream failure.
854
-
855
- Session ids are deterministic and unique only inside one prepared local tree
856
- (`machine:0`, `machine:1`, ...). Do not persist them as globally unique actor
857
- ids. Distributed identity, placement, delivery, and request correlation belong
858
- to Effect Cluster and its entity, runner, shard, and request identifiers. A
859
- Cluster adapter may translate local inspection records into telemetry, but the
860
- core machine stream does not claim cross-node identity or ordering.
861
-
862
- For child-to-parent input, export a public builder protocol and reuse it at both
863
- composition boundaries:
864
170
 
865
- ```ts
866
- export const ParentEvents = Machine.events(ChildFinished)
867
-
868
- const child = Machine.make({
869
- events: ChildEvents,
870
- parent: Machine.parent(ParentEvents),
871
- // ...
171
+ const DocumentMachine = Machine.make({
172
+ states: DocumentStates.states,
173
+ events: DocumentEvents,
174
+ initial: (to) => to.Closed()
872
175
  }).handle({
873
- Working: {
176
+ Closed: {},
177
+ Open: {
874
178
  on: {
875
- Finish: (to) =>
876
- to.none.resolve(({ parent }, enqueue) => {
877
- enqueue.sendTo(parent, ParentEvents.ChildFinished())
878
- })
179
+ // All Open children close the document in the same way.
180
+ Close: (to) => to.full.Closed()
181
+ },
182
+ states: {
183
+ Editing: {},
184
+ Saving: {},
185
+ SaveFailed: {}
879
186
  }
880
187
  }
881
188
  })
882
-
883
- const parent = Machine.make({
884
- events: Machine.events(ParentCommands, ParentEvents),
885
- // ...
886
- })
887
- ```
888
-
889
- Invoking the child under a parent that lacks any required parent event is a
890
- type error. Within child handlers, `parent` accepts only that protocol and is
891
- not optional. Root APIs reject the machine. Use
892
- `Machine.optionalParent(ParentEvents)` instead when the same definition must
893
- also run as a root; then `parent` is optional. With no declaration, callbacks
894
- have no `parent` property. `self` accepts the machine's public inputs. Both
895
- targets are minimal `MachineTarget<Event>` values. Neither machine target is a
896
- structural state value; use
897
- `containingState` and `ancestors` for statechart ancestry.
898
-
899
- Atom-backed machines retain the same transient semantics. Use
900
- `AtomMachine.emissions(machineAtom)` for a root and
901
- `AtomMachine.childEmissions(childAtom)` for the currently active child. Both
902
- return streams requiring the corresponding `AtomRegistry`; emissions are not
903
- stored as atom state.
904
-
905
- Use `AtomMachine.inspection(machineAtom)` for root-scoped operational records.
906
- It installs the subscription before a fresh bridge starts, so initialization,
907
- owned children, and activities are visible without storing inspection records
908
- in atom state.
909
-
910
- For asynchronous validation or persistence, invoke an Effect or child machine
911
- from the state and handle its typed success or failure event in a later
912
- transition. This keeps `(state, event) => [nextState, commands]` synchronous.
913
-
914
- Plans have a discriminated completion result:
915
-
916
- ```ts
917
- const planned = yield * Machine.plan(machine, state, event)
918
- if (planned.done) {
919
- planned.output // schema-derived structural terminal union
920
- }
921
189
  ```
922
190
 
923
- When `done` is false, `output` is `undefined`. `MachineRef.join` and invoked
924
- child `onDone.output` use the same structural terminal union rather than adding
925
- an unconditional optional value. Output-less structural terminal paths
926
- contribute `undefined`; active atomic roots do not. Handler behavior can make
927
- the type conservative—for example, a root `onDone` transition can move away
928
- before that root becomes the machine's terminal result.
191
+ The machine checks the deepest active state first, then its ancestors. Put a
192
+ handler on a child when that state needs different behavior. Keep the shared
193
+ case on the parent instead of repeating it in every child.
929
194
 
930
- `raise` queues an event for the same machine's current macrostep. `emit` queues
931
- an event for the parent. Both operations validate their schemas.
195
+ ## Treat events as the domain protocol
932
196
 
933
- ## Public and internal event protocols
934
-
935
- `events` defines the protocol callers can send. `internalEvents` augments the
936
- union handled inside the statechart:
197
+ An event tells the machine what was requested or what happened. Name events
198
+ after domain actions and outcomes. Do not expose state setters such as
199
+ `SetLoading` or `SetError`.
937
200
 
938
201
  ```ts
939
- const Events = Machine.events(
940
- Schema.TaggedUnion({
941
- Save: {}
942
- })
943
- )
944
- const InternalEvents = Machine.internalEvents(
202
+ export const CheckoutEvents = Machine.events(
945
203
  Schema.TaggedUnion({
946
- Saved: { id: Schema.String },
947
- SaveFailed: { message: Schema.String }
204
+ Submit: {},
205
+ Cancel: {}
948
206
  })
949
207
  )
950
208
 
951
- const definition = Machine.make({
952
- states: States.states,
953
- events: Events,
954
- internalEvents: InternalEvents,
955
- initial: (to) => to.Idle()
209
+ const CheckoutMachine = Machine.make({
210
+ states: CheckoutStates.states,
211
+ events: CheckoutEvents,
212
+ initial: (to) => to.Editing()
213
+ }).handle({
214
+ Editing: {
215
+ on: {
216
+ Submit: (to) => to.full.Submitting()
217
+ }
218
+ },
219
+ Submitting: {
220
+ on: {
221
+ // Cancel has meaning while work is in progress.
222
+ Cancel: (to) => to.full.Editing()
223
+ }
224
+ },
225
+ Complete: {}
956
226
  })
957
227
  ```
958
228
 
959
- Use the protocol-bound constructors at every machine delivery boundary:
960
-
961
- ```ts
962
- yield* ref.send(Events.Save())
963
- enqueue.raise(InternalEvents.Saved({ id: "entry-1" }))
964
- ```
965
-
966
- `Machine.events` exposes only public constructors;
967
- `Machine.internalEvents` exposes only machine-local constructors. Both flatten
968
- configured tagged unions and preserve tagged classes, finite discriminator
969
- unions, required inputs, and constructor defaults. A constructor returns an
970
- opaque instruction whose `_tag` is available for activity metadata. Its decoded
971
- fields are intentionally unavailable until the owning machine processes it.
972
-
973
- Invalid constructor input fails `Machine.plan` or the running machine with
974
- `MachineSchemaDecodeError`; creating the instruction itself never performs
975
- schema validation. APIs that explicitly retain decoded events, such as manual
976
- model-testing scenarios or transport messages, can receive complete event
977
- objects directly.
229
+ The sender requests `Submit`. The machine decides whether `Submit` has a
230
+ transition in the current state. The sender does not choose `Submitting`.
978
231
 
979
- An open discriminator such as `_tag: Schema.String` cannot produce named
980
- constructors because its tag set is not finite. The schema still participates
981
- in the protocol; pass a complete event object at the delivery boundary.
232
+ Carry facts that the machine cannot read from its current snapshot in the event
233
+ payload. Do not copy current state into an event to help a handler reconstruct
234
+ what the machine already knows.
982
235
 
983
- Use the exported utility types when another API must preserve the boundary:
984
-
985
- ```ts
986
- type PublicEvent = Machine.Machine.InputEvent<typeof definition>
987
- type AnyHandledEvent = Machine.Machine.Event<typeof definition>
988
- type StartupInput = Machine.Machine.Input<typeof definition>
989
- type StartupInputSchema = Machine.Machine.InputSchema<typeof definition>
990
- ```
236
+ ## Use parallel states only for independent modes
991
237
 
992
- `Input` is the decoded value accepted at startup. It is `never` for a machine
993
- whose input schema is `Schema.Void`; use `InputSchema` only when an API needs
994
- the schema object itself.
995
-
996
- `MachineRef.send`, `machineAtom.send`, and `Machine.plan` accept decoded public
997
- events or constructions returned by `Machine.events`. Transition handlers
998
- receive only decoded events. Raised events additionally accept constructions
999
- from `Machine.internalEvents`; outward notifications accept constructions from
1000
- `Machine.emittedEvents`. The
1001
- local planner and runtime intentionally share the complete decoder to support
1002
- those internal deliveries, so JavaScript or `any` can bypass the local public
1003
- distinction.
1004
- Cluster RPC payloads are additionally decoded against the public `events`
1005
- schemas at the transport boundary. Never repeat an `_tag` within a list or
1006
- across both configuration lists.
1007
-
1008
- Do not extract `enqueue`, target builders, transition contexts, command or
1009
- inspection unions, or event-construction `ReturnType`s into application helper
1010
- APIs. Keep commands inside transition resolvers, where the owning state,
1011
- protocols, references, and capabilities are inferred. Likewise, do not add
1012
- Atom `State` or `Event` aliases: selectors infer from their bridge, while
1013
- consumer props use `Snapshot`, `Value`, or `InputEvent` from the exported state
1014
- definition or machine.
1015
-
1016
- ## Recoverable state-scoped work
1017
-
1018
- Use `from.effect` for one-shot work. Lifecycle callbacks receive the typed
1019
- Effect channels and can transition directly:
238
+ A compound state activates one direct child. A parallel state activates one
239
+ child in every region. A parallel model therefore accepts the full product of
240
+ those regions.
1020
241
 
1021
242
  ```ts
1022
- machine.handle({
1023
- Saving: {
1024
- invoke: (from) =>
1025
- from.effect("save", () => SaveService.save(draft))
1026
- .onDone((to) => to.full.Saved().resolve(({ output, target }) => target.from({ entry: output })))
1027
- .onFailure((to) =>
1028
- to.full.SaveFailed().resolve(({ error, target }) => target.from({ message: error.message })))
243
+ const ScreenStates = Machine.states({
244
+ Screen: {
245
+ type: "parallel",
246
+ states: {
247
+ connection: {
248
+ initial: "Online",
249
+ states: {
250
+ Online: {},
251
+ Offline: {}
252
+ }
253
+ },
254
+ panel: {
255
+ initial: "Closed",
256
+ states: {
257
+ Closed: {},
258
+ Open: {}
259
+ }
260
+ }
261
+ }
1029
262
  }
1030
263
  })
1031
264
  ```
1032
265
 
1033
- The owning state scopes the child. Owner-driven interruption on state exit is
1034
- normal cancellation and stale output is ignored. An Effect that defects or
1035
- self-interrupts fails the parent. `onDone` is required when the output is not
1036
- `never`; `onFailure` is required when the typed error is not `never`. Handlers
1037
- are forbidden when their channel is `never`.
1038
-
1039
- The source may also be a function of the owning state's entry context when it
1040
- needs `state`, `containingState`, `ancestors`, or the entry `event`. Source construction
1041
- errors, defects, and interruption are machine failures rather than a second
1042
- phase in `onFailure`.
1043
-
1044
- Use a Stream invocation for repeated values that are not themselves machine
1045
- events. `onElement` maps each value into an owner transition, while `onDone`
1046
- handles normal Stream completion and `onFailure` handles the typed Stream error:
266
+ This model permits all four combinations: online with a closed panel, online
267
+ with an open panel, offline with a closed panel, and offline with an open panel.
1047
268
 
1048
- ```ts
1049
- machine.handle({
1050
- Listening: {
1051
- invoke: (from) =>
1052
- from.stream("broadcast-channel", () => messages)
1053
- .onElement((to) =>
1054
- to.none.resolve(({ element }, enqueue) => {
1055
- enqueue.raise(Events.MessageReceived({ message: element }))
1056
- }))
1057
- .onDone((to) => to.none)
1058
- .onFailure((to) => to.full.Disconnected().resolve(({ error, target }) => target.from({ error })))
1059
- }
1060
- })
1061
- ```
1062
-
1063
- Element delivery is owner-scoped and backpressured: the Stream pulls again only
1064
- after the selected parent macrostep commits. Exiting or reentering the owner
1065
- interrupts the Stream and runs its finalizers. A later entry starts a fresh
1066
- Stream. Stream defects and self-interruption fail the owning machine.
269
+ If one combination would break a domain rule, do not repair it with a UI check
270
+ or repeated cross-region conditions. Change the topology. A compound hierarchy
271
+ can place a mode only under the parent where it is valid.
1067
272
 
1068
- Use `to.none` when a transition keeps the current configuration. Call
1069
- `to.none.resolve(...)` when it also enqueues commands; a block resolver may
1070
- omit its return because it is contextually typed to return `undefined`.
273
+ ## Let states own running work
1071
274
 
1072
- When a source function reads `state`, `containingState`, `ancestors`, or the
1073
- entry `event`, `from.effect` infers that owner context and the returned Effect's
1074
- output, error, and service channels together. No return annotation is needed:
275
+ Put asynchronous work on the state whose meaning requires that work. The
276
+ machine starts the work when it enters the state and interrupts it when it exits.
277
+ Handle expected success and failure as transitions.
1075
278
 
1076
279
  ```ts
1077
- machine.handle({
1078
- Loading: {
1079
- invoke: (from) =>
1080
- from.effect("load", ({ state }) => LoadService.load(state.userId))
1081
- .onDone((to) => to.full.Loaded().resolve(({ output, target }) => target.from({ user: output })))
1082
- .onFailure((to) => to.full.LoadFailed().resolve(({ error, target }) => target.from({ error })))
1083
- }
280
+ const LoadState = Schema.TaggedUnion({
281
+ Loading: { documentId: Schema.String },
282
+ Ready: { content: Schema.String },
283
+ Failed: { message: Schema.String }
1084
284
  })
1085
- ```
1086
285
 
1087
- Inside `.handle(...)`, the constructor receives the owning machine's public
1088
- input and declared parent protocol contextually. Sources and lifecycle handlers
1089
- can send through `self` and `parent` without naming the definition:
286
+ const LoadStates = Machine.states({
287
+ Idle: {},
288
+ Loading: LoadState.cases.Loading,
289
+ Ready: LoadState.cases.Ready,
290
+ Failed: LoadState.cases.Failed
291
+ })
1090
292
 
1091
- ```ts
1092
- const machine = Machine.make({
1093
- events: Commands,
1094
- internalEvents: InternalEvents,
1095
- parent: Machine.parent(ParentEvents),
1096
- // ...
293
+ const LoadMachine = Machine.make({
294
+ states: LoadStates.states,
295
+ events: Machine.events(),
296
+ initial: (to) => to.Idle()
1097
297
  }).handle({
1098
- Saving: {
298
+ Idle: {},
299
+ Loading: {
1099
300
  invoke: (from) =>
1100
- from.effect("notify-parent", () => saveDocument)
1101
- .onDone((to) =>
1102
- to.none.resolve(({ parent, self }, enqueue) => {
1103
- enqueue.sendTo(self, Commands.Save())
1104
- enqueue.sendTo(parent, ParentEvents.ChildFinished({ id: "job-1" }))
1105
- }))
1106
- .onFailure((to) => to.none)
1107
- }
301
+ from
302
+ .effect("load-document", ({ state }) => loadDocument(state.documentId))
303
+ .onDone((to) => to.full.Ready().resolve(({ output, target }) => target.from({ content: output })))
304
+ .onFailure((to) => to.full.Failed().resolve(({ error, target }) => target.from({ message: String(error) })))
305
+ },
306
+ Ready: {},
307
+ Failed: {}
1108
308
  })
1109
309
  ```
1110
310
 
1111
- The computation, logic, or child descriptor may be named separately. The
1112
- invocation chain remains inline because it is bound to its owning state and
1113
- machine protocols. Return an array of completed chains when a state owns more
1114
- than one activity.
311
+ `loadDocument` may require Effect services. Those requirements remain on the
312
+ implemented machine type, so the runtime must provide them when it starts the
313
+ machine.
1115
314
 
1116
- A cancellable timer uses its dedicated source selector:
315
+ Do not start a promise inside a transition callback. A transition has no
316
+ lifetime in which to own that work. A state does.
1117
317
 
1118
- ```ts
1119
- machine.handle({
1120
- Waiting: {
1121
- invoke: (from) =>
1122
- from.timer("clear-status", "3 seconds")
1123
- .onDone((to) => to.full.Clear())
1124
- }
1125
- })
1126
- ```
318
+ ## Keep transition decisions synchronous
1127
319
 
1128
- The timer starts on state entry and is interrupted on exit. Its `onDone` is
1129
- always required. An Effect containing `Effect.sleep(...)` has the same scoped
1130
- cancellation behavior, but `from.timer` records timer intent and exposes a
1131
- static duration through `Machine.activityDefinitions`. Effect sources are
1132
- always factories evaluated when their state is entered. For reusable process
1133
- logic, pass a state-local lifecycle id plus `{ logic, address }` to
1134
- `from.logic`. TypeScript checks the address protocol against the logic event
1135
- protocol. Lifecycle ids and addresses serve different purposes and must both
1136
- be explicit.
1137
-
1138
- ## Invoked child statecharts
1139
-
1140
- Create a complete child-statechart descriptor:
320
+ A transition should choose the next state from the current snapshot and event.
321
+ Use ordinary TypeScript conditions when one event has several valid outcomes.
1141
322
 
1142
323
  ```ts
1143
- const Editor = Machine.child("editor", EditorMachine)
1144
- ```
1145
-
1146
- Invoke it from its owning state:
324
+ const ReviewEvents = Machine.events(
325
+ Schema.TaggedUnion({
326
+ Evaluate: { score: Schema.Number }
327
+ })
328
+ )
1147
329
 
1148
- ```ts
1149
- machine.handle({
1150
- Editing: {
1151
- invoke: (from) =>
1152
- from.child(Editor, { input: editorInput })
1153
- .onDone((to) => to.full.EditorDone().resolve(({ output, target }) => target.from({ output })))
1154
- }
330
+ const ReviewMachine = Machine.make({
331
+ states: ReviewStates.states,
332
+ events: ReviewEvents,
333
+ initial: (to) => to.Pending()
334
+ }).handle({
335
+ Pending: {
336
+ on: {
337
+ Evaluate: (to) =>
338
+ to
339
+ .branches({
340
+ accepted: { target: to.full.Accepted() },
341
+ rejected: { target: to.full.Rejected() }
342
+ })
343
+ .resolve(({ event, select }) =>
344
+ event.score >= 80
345
+ ? select.accepted.from()
346
+ : select.rejected.from()
347
+ )
348
+ }
349
+ },
350
+ Accepted: {},
351
+ Rejected: {}
1155
352
  })
1156
353
  ```
1157
354
 
1158
- Use `Editor` for:
1159
-
1160
- ```ts
1161
- Machine.sendTo(Editor, EditorEvent.Reset())
1162
- parentRef.child(Editor)
1163
- parentAtom.child(Editor)
1164
- ```
1165
-
1166
- Child emissions remain on the child's hot `emissions` stream; they are never
1167
- delivered implicitly to the parent. A child sends an input explicitly with
1168
- `enqueue.sendTo(parent, ParentEvents.Example())`. `onSnapshot`, `onDone`, and
1169
- `onFailure` are direct parent transitions. Invoked child IDs must be unique
1170
- while simultaneously active.
1171
-
1172
- Descriptors with the same id and machine identity address the same child, even
1173
- when independently constructed. The descriptor objects themselves are not
1174
- canonicalized. Prefer exporting one descriptor as the application boundary.
1175
- Use the separate
1176
- `Machine.childAddress<Event>(id)` constructor only for lower-level process
1177
- logic that does not have a complete machine descriptor.
1178
-
1179
- ### Inspecting state-owned activities
1180
-
1181
- Use `Machine.activityDefinitions(machine)` to inspect invokes without running
1182
- them. Static inline fluent invocation definitions expose serializable ownership
1183
- metadata:
1184
-
1185
- ```ts
1186
- Machine.activityDefinitions(machine)
1187
- // [{ source: "Loading", id: "load-timeout", type: "timer",
1188
- // duration: "10s" }]
1189
- ```
1190
-
1191
- Child machines expose descriptor identity, never their runtime or
1192
- implementation. Function-valued sources and durations are represented as
1193
- dynamic because inspection must not evaluate user code.
1194
-
1195
- ## AtomMachine and React
1196
-
1197
- `AtomMachine.make(machine, ...input)` works when the machine has no external
1198
- service requirements. For an application runtime, the canonical form is to
1199
- bind it once at the composition boundary:
1200
-
1201
- ```ts
1202
- const runtime = Atom.runtime(AppLayer)
1203
- const machines = AtomMachine.bind(runtime)
1204
- const machineAtom = machines.make(machine, input)
1205
- ```
1206
-
1207
- One bridge owns one machine instance per `AtomRegistry`. In React:
1208
-
1209
- 1. Render a `RegistryProvider` from `@effect/atom-react`.
1210
- 2. Keep a component-owned bridge referentially stable, normally with
1211
- `useMemo`.
1212
- 3. Use scalar dependencies that define when the machine should restart.
1213
- 4. Expect a new bridge identity to create a new instance once mounted.
1214
-
1215
- The root bridge shapes are:
1216
-
1217
- ```ts
1218
- machineAtom.state
1219
- // Atom<AsyncResult<State, StartError>>
1220
-
1221
- machineAtom.result
1222
- // Atom<AsyncResult<State, StartError | RuntimeError>>
1223
-
1224
- machineAtom.snapshot
1225
- // Atom<AsyncResult<RuntimeSnapshot<State, RuntimeError, Output>, StartError>>
1226
- ```
1227
-
1228
- `state` remains a successful last-state value after a post-start runtime
1229
- failure. Prefer `result` for ordinary fail-aware UI state. Use `snapshot` when
1230
- the full lifecycle, completion output, cause, or stopped status matters.
1231
-
1232
- Use equality-aware selectors instead of repeating AsyncResult/Option unwrapping.
1233
- Paths and selected values are inferred from the bridge snapshot, so do not pass
1234
- the `DefinedStates` object:
1235
-
1236
- ```ts
1237
- AtomMachine.select(machineAtom, "Ready")
1238
- AtomMachine.selectSnapshot(machineAtom, "Ready")
1239
- AtomMachine.matches(machineAtom, "Ready.Saving")
1240
- AtomMachine.selectChild(childAtom, "Editing")
1241
- AtomMachine.selectSnapshotChild(childAtom, "Editing")
1242
- AtomMachine.matchesChild(childAtom, "Editing")
1243
- ```
1244
-
1245
- `select` returns only the decoded state value. Use `selectSnapshot` when a
1246
- component needs the selected node's compound or parallel child topology.
355
+ Given the same snapshot and event, the handler should choose the same result.
356
+ Do not read the clock, generate randomness, call a service, or await work while
357
+ choosing a transition. Receive such values in an event or produce them through
358
+ state-owned work first.
1247
359
 
1248
- Like ordinary Effect Atom combinators, each selector call returns a derived
1249
- atom. Define it at a stable composition boundary or memoize it when constructing
1250
- it inside a component.
360
+ ## Test paths and invariants
1251
361
 
1252
- An invoked child bridge adds an inactivity axis. Keep the descriptor stable;
1253
- the bridge uses Effect's `Atom.family` identity semantics:
1254
-
1255
- ```ts
1256
- const editorAtom = parentAtom.child(Editor)
1257
-
1258
- editorAtom.state
1259
- // Atom<AsyncResult<Option<State>, StartError>>
1260
- ```
1261
-
1262
- `Option.none()` means the child is not currently active or has not become
1263
- active yet. A child command while inactive fails with `ChildNotActiveError`.
1264
- Use `AtomMachine.ChildMachineAtom<typeof Editor>` for a descriptor-based child prop,
1265
- or `AtomMachine.ChildOf<typeof parentAtom, typeof Editor>` to infer the exact
1266
- bridge from a parent.
1267
-
1268
- ## Persistence
1269
-
1270
- Use `Machine.encodeSnapshot` and `Machine.decodeSnapshot` for validated logical
1271
- statechart data. Persist machine identity and an application migration/version
1272
- next to the encoded snapshot.
1273
-
1274
- The canonical resumption boundary is explicit:
1275
-
1276
- ```ts
1277
- const encoded = yield* Machine.encodeSnapshot(machine, snapshot)
1278
- const decoded = yield* Machine.decodeSnapshot(machine, encoded)
1279
- const ref = yield* Machine.resume(machine, decoded)
1280
- ```
1281
-
1282
- `Machine.Snapshot` is the decoded, process-local representation. It may retain
1283
- `Schema.Class` instances and capabilities that cannot cross a JSON boundary.
1284
- `Machine.EncodedSnapshot` is different: successful `encodeSnapshot` calls
1285
- guarantee canonical `Schema.Json` for every active value, completion output,
1286
- and history value. Schema codecs convert rich values such as `Date`, `bigint`,
1287
- and `undefined` to their declared JSON forms. Cycles, functions, symbols, and
1288
- opaque values without a JSON representation fail with the typed
1289
- `MachineSchemaEncodeError`; they never become a later `JSON.stringify` defect.
1290
-
1291
- Keep DOM nodes, open handles, services, and similar capabilities in an Effect
1292
- service or UI adapter. Local events may carry those values when they stay inside
1293
- one process. Cluster public input events, persisted state, and completion
1294
- outputs are transport protocols and must instead declare JSON-compatible
1295
- encoded forms; use an explicit transform or `Schema.toCodecJson` where the
1296
- canonical codec is the intended wire contract.
1297
-
1298
- Pass only a decoded `Machine.Snapshot` to `resume`; encoded or arbitrary
1299
- transport data belongs at `decodeSnapshot`. Resumption validates and normalizes
1300
- the logical snapshot again, then publishes it as the fresh runtime's first
1301
- state. It does not call the initial function, require machine input, or include
1302
- initial-only failures and services in its Effect type.
1303
-
1304
- Encoding does not preserve:
1305
-
1306
- - running invokes or spawned children;
1307
- - subscriptions, queued events, fibers, scopes, timers, or services;
1308
- - the machine definition;
1309
- - application migration metadata.
1310
-
1311
- `resume` reconstructs runtime ownership from logical state only:
1312
-
1313
- - no historical entry, transition, completion, eventless, raise, or emit work
1314
- is replayed;
1315
- - completion and history records survive but do not retrigger `onDone`;
1316
- - active-state invokes start once in ordinary ancestor/document order with
1317
- `Machine.InitialEvent`;
1318
- - inline Effects restart, child machines start fresh from their normal initial
1319
- state, and timers restart their complete duration;
1320
- - inactive invokes, spawned children, child snapshots, elapsed timer time, and
1321
- prior `RuntimeSnapshot` status/errors are not restored;
1322
- - a final logical snapshot creates an immediately completed ref;
1323
- - `resume` itself does not evaluate `always` or `onDone`, including transitions
1324
- newly enabled by a changed machine definition. Later events use ordinary
1325
- planning semantics.
1326
-
1327
- Use `AtomMachine.resume(machine, decoded)` or
1328
- `AtomMachine.bind(runtime).resume(machine, decoded)` for the same contract in a
1329
- lazy atom bridge. Registry disposal stops the fresh invokes and timers exactly
1330
- as it does for `AtomMachine.make`.
1331
-
1332
- This is not durable runtime restoration. `ClusterMachine` has a separate
1333
- checkpoint/planning contract and process-local restrictions; do not substitute
1334
- `Machine.resume` for cluster recovery.
1335
-
1336
- ## Testing machine semantics
1337
-
1338
- Import planner testing tools from the dedicated entrypoint:
362
+ Test the statechart as a graph. Send domain events, inspect reached states, and
363
+ state the rules that every trace must preserve. Do not duplicate the handler's
364
+ branches inside the test.
1339
365
 
1340
366
  ```ts
1341
367
  import { MachineTest } from "@typeonce/effect-machine/testing"
1342
- ```
1343
-
1344
- Use three distinct layers:
1345
-
1346
- 1. `MachineTest.verify(machine, trace)` checks structural statechart and
1347
- planner lifecycle laws.
1348
- 2. `MachineTest.assertInvariants(machine, trace, laws)` checks application
1349
- semantics such as conservation, authorization, and exact state updates.
1350
- 3. Runtime command models check executed actions, invokes, timing, process
1351
- publication, and cancellation. Planner traces do not execute this work.
1352
-
1353
- Define semantic laws with a machine-bound builder so the callback receives the
1354
- exact state and event types:
1355
-
1356
- ```ts
1357
- const invariant = MachineTest.invariants(machine)
1358
-
1359
- const laws = [
1360
- invariant.state("balance is never negative", ({ snapshot }) =>
1361
- snapshot.value.balance >= 0 || "negative balance"),
1362
- invariant.step("withdrawal is exact", ({ before, event, after }) =>
1363
- event._tag !== "Withdraw" ||
1364
- after.value.balance === before.value.balance - event.amount),
1365
- invariant.trace("all inputs were planned", ({ trace }) =>
1366
- trace.steps.length === trace.scenario.events.length)
1367
- ]
1368
- ```
1369
-
1370
- State laws observe settled states by default. Select `"microsteps"`, `"all"`,
1371
- or `"final"` only when the law requires that evidence. A `when` condition with
1372
- no matches is explicitly `untested`; use
1373
- `require: { minObservations: 1 }` when the current trace must exercise it.
1374
- Prefer `assertInvariants` inside FastCheck properties because it succeeds with
1375
- `void`. Use `checkInvariants` when the test needs the per-law report.
1376
-
1377
- For systematic planner exploration, provide a finite abstraction explicitly:
1378
-
1379
- ```ts
1380
- const explored = yield * MachineTest.explore(machine, {
1381
- events: ({ snapshot }) => eventRepresentatives(snapshot),
1382
- stateKey: ({ snapshot }) => logicalStateKey(snapshot),
1383
- limits: { maxDepth: 20, maxStates: 1_000 },
1384
- invariants: laws
1385
- })
1386
- ```
1387
-
1388
- The event callback returns concrete representatives, not schemas or
1389
- arbitraries. Include meaningful boundary values based on the current snapshot.
1390
- The key defines which snapshots are treated as equivalent; it must retain every
1391
- piece of data that can change the future behavior being tested. A coarse key
1392
- can make exploration finite but under-approximate behavior.
1393
-
1394
- `assertReachable` returns the shortest witness. `assertUnreachable` succeeds
1395
- only when `explored.completeness` is `Complete`. Never interpret a truncated
1396
- depth, state, or transition frontier as an unreachability proof. The explorer
1397
- retains cycles as graph edges but does not enumerate every path around them;
1398
- use a separate temporal/path model when a law depends on repeated traversal
1399
- rather than logical-state reachability.
1400
-
1401
- Do not encode application invariants as guards merely to make them testable.
1402
- Keep ordinary TypeScript branching in transition handlers unless a choice is
1403
- part of the statechart topology. Invariants independently verify the resulting
1404
- trace without changing production transition selection.
1405
-
1406
- ### Live event causality
1407
-
1408
- Use a probe when a test must establish that one event was processed by a
1409
- running statechart rather than merely accepted by its mailbox:
1410
-
1411
- ```ts
1412
- const ref = yield * Machine.start(machine)
1413
- const probe = yield * MachineTest.probe(machine, ref)
1414
- const step = yield * probe.sendAndAwait(event)
1415
- ```
1416
-
1417
- Inspect `step.before`, `step.after`, `step.plan`, `step.handled`, and
1418
- `step.configurationChanged`. An ignored event, including one for which every
1419
- eligible candidate declines, has `handled: false` and an empty microstep list,
1420
- but still completes its acknowledgement. A targetless handler has
1421
- `handled: true` even if its before and after snapshots are equal.
1422
-
1423
- Do not use a probe as a substitute for a domain completion event. The
1424
- acknowledgement covers the submitted event's synchronous macrostep, state
1425
- commit, emissions, and invoke startup; it does not wait for an invoke or timer
1426
- to complete. Application code should continue to use `MachineRef.send`.
1427
-
1428
- For generated runtime command sequences, select delivery behavior by name:
1429
-
1430
- ```ts
1431
- yield* MachineTest.runCausalCommands(probe, commands, causalModel)
1432
- yield* MachineTest.runEnqueuedCommands(ref, commands, enqueueModel)
1433
- ```
1434
-
1435
- Prefer `runCausalCommands` for semantic and reference-model properties. Every
1436
- accepted send produces a `SendProcessed` result containing its exact
1437
- `ProbeStep`, including ignored and targetless events. A machine processing
1438
- error fails that exact command and retains its checked prefix for shrinking.
1439
- The next command does not begin until the submitted send's managed macrostep
1440
- has completed.
1441
-
1442
- Use `probe.await.until(predicate)` in a causal model step only when the
1443
- assertion also requires later timer, invoke, or child activity. It observes the
1444
- current runtime snapshot before waiting for subsequent publications, so it
1445
- does not miss work that completed immediately after the causal boundary.
1446
-
1447
- Use `runEnqueuedCommands` only when outstanding mailbox work is intentional,
1448
- such as burst ordering and queue behavior. Its `RuntimeSynchronization`
1449
- policies observe public snapshots but do not turn send acceptance into causal
1450
- completion. Do not use the deprecated `runRuntimeCommands` name in new code;
1451
- it is an alias for enqueue behavior and hides that important distinction.
1452
-
1453
- For semantic laws over live execution, bind runtime invariant constructors to
1454
- the machine and use the law-oriented causal verifier:
1455
-
1456
- ```ts
1457
- const invariant = MachineTest.runtimeInvariants(machine)
1458
-
1459
- const laws = [
1460
- invariant.snapshot("balance never becomes negative", ({ snapshot }) =>
1461
- snapshot.state.value.balance >= 0
1462
- ),
1463
- invariant.command("stopped sends are rejected", ({ previous, result }) =>
1464
- previous?.result._tag !== "Stopped" || result._tag === "SendRejected"
368
+ import { Effect, Option } from "effect"
369
+
370
+ const testProgram = Effect.gen(function*() {
371
+ const define = MachineTest.invariants(CounterMachine)
372
+
373
+ const countNeverBecomesNegative = define.state(
374
+ "count never becomes negative",
375
+ ({ snapshot }) =>
376
+ !CounterStates.matches(snapshot, "Running") ||
377
+ CounterStates.get(snapshot, "Running").pipe(
378
+ Option.exists(({ count }) => count >= 0)
379
+ ) ||
380
+ "count became negative"
1465
381
  )
1466
- ]
1467
-
1468
- yield* MachineTest.verifyCausalCommands(probe, commands, { invariants: laws })
1469
- ```
1470
382
 
1471
- Do not create a dummy model merely to run runtime laws. Continue to use
1472
- `runCausalCommands` when an independent simplified model supplies exact
1473
- expected results, then apply the same laws to its returned transcript with
1474
- `assertRuntimeInvariants`. Conditional laws that must execute should declare
1475
- `require.minObservations` so irrelevant generated commands cannot pass them
1476
- vacuously.
1477
-
1478
- Use `assertPlannerRuntimeAgreement(machine, transcript)` only to check the
1479
- managed runtime boundary against a fresh pure plan. It is not an independent
1480
- business oracle and is intentionally an explicit operation rather than a
1481
- generic conformance mode. Combine it with application runtime laws or a
1482
- reference model when correctness of the expected behavior matters.
1483
-
1484
- ## Common compiler errors
1485
-
1486
- ### `initial` requires a static target
1487
-
1488
- Select the initial root separately from constructing its value:
1489
-
1490
- ```ts
1491
- initial: (to) => to.Idle()
1492
- ```
1493
-
1494
- ### Invoked child expects events not accepted by the parent
1495
-
1496
- Export one parent-event protocol from the child boundary and compose it into
1497
- the parent's public events:
1498
-
1499
- ```ts
1500
- export const ChildParentEvents = Machine.events(ChildFinished)
1501
-
1502
- // child-only machine
1503
- parent: Machine.parent(ChildParentEvents)
1504
-
1505
- // parent
1506
- events: Machine.events(Submit, ChildParentEvents)
1507
- ```
1508
-
1509
- Use `Machine.optionalParent(ChildParentEvents)` only when the child is also a
1510
- valid independent root and narrow `parent` before sending.
1511
-
1512
- ### An internal event is rejected by `send`
1513
-
1514
- This is intentional. Public input boundaries accept only schemas declared in
1515
- `events`. Handle the event as a child delivery or raised event; move it to
1516
- `events` only if external callers should genuinely be allowed to send it.
1517
-
1518
- ### Public and internal event tags overlap
1519
-
1520
- Give the cases distinct `_tag` values. The split is a protocol boundary, so one
1521
- tag cannot be both externally sendable and machine-local.
1522
-
1523
- ### Missing output implementation
1524
-
1525
- An output schema is a runtime contract, not an optional annotation. Add the
1526
- corresponding nested handler:
1527
-
1528
- ```ts
1529
- Done: {
1530
- output: ({ state }) => state.value
1531
- }
1532
- ```
1533
-
1534
- Keep `type: "final"` and `output: Schema...` in the state definition; do not
1535
- repeat the final marker in this handler.
1536
-
1537
- ### `type: "final"` is rejected by `handle`
1538
-
1539
- Move it to `Machine.states`. Definitions own statechart topology;
1540
- handlers own behavior.
1541
-
1542
- ### Parent property does not exist
1543
-
1544
- Use the structural ancestor's full path:
383
+ const trace = yield* MachineTest.run(CounterMachine, {
384
+ events: [
385
+ CounterEvents.Start(),
386
+ CounterEvents.Increment(),
387
+ CounterEvents.Increment()
388
+ ]
389
+ })
1545
390
 
1546
- ```ts
1547
- ancestors["Route.Ready"]
391
+ yield* MachineTest.verify(CounterMachine, trace)
392
+ yield* MachineTest.checkInvariants(CounterMachine, trace, [
393
+ countNeverBecomesNegative
394
+ ])
395
+ })
1548
396
  ```
1549
397
 
1550
- ### Child descriptor types are unrelated
1551
-
1552
- Use the descriptor exported by the module that configured the child invocation.
1553
- An independently created descriptor with the same id and machine identity also
1554
- matches; the same id paired with a different machine remains a distinct child.
1555
-
1556
- ### Child atom start error defaults to `unknown`
1557
-
1558
- `ChildMachineAtom<Child>` is suitable for a general boundary because its startup
1559
- error defaults to `unknown`. Atoms created with an `AtomRuntime<R, E>` include
1560
- `E` in their startup error type. Use `ChildOf<ParentAtom, Child>` to infer that
1561
- exact channel from a parent instead of restating it manually.
1562
-
1563
- ### Handler tree reaches a compiler instantiation limit
1564
-
1565
- `effect-machine` does not impose a fixed handler-tree depth. Inference follows
1566
- the nested handler object until TypeScript reaches its normal, shape-dependent
1567
- compiler resource or instantiation limits.
1568
-
1569
- ## Unsupported and intentionally imperative features
1570
-
1571
- The current API does not include:
1572
-
1573
- - declarative first-class guards;
1574
- - a complete inspectable graph for arbitrary transition Effects.
1575
-
1576
- Use ordinary TypeScript conditions for guards and inline
1577
- `invoke: (from) => from.timer(...)` chains for state-scoped timers. Do not
1578
- invent undocumented state-node properties such as `guard`.
398
+ Use pure planner traces for state and transition rules. Start a live machine
399
+ and use `MachineTest.probe` when a test depends on timers, invoked work, raised
400
+ events, or runtime scheduling.