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