compound-workflow 1.7.3 → 1.9.0

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 (92) hide show
  1. package/README.md +61 -69
  2. package/package.json +2 -8
  3. package/scripts/check-pack-readme.mjs +1 -16
  4. package/scripts/check-workflow-contracts.mjs +34 -44
  5. package/scripts/install-cli.mjs +273 -417
  6. package/src/AGENTS.md +59 -192
  7. package/src/{.agents/agents → agents}/research/best-practices-researcher.md +2 -2
  8. package/src/{.agents/commands → commands}/assess.md +4 -4
  9. package/src/commands/install.md +43 -0
  10. package/src/{.agents/commands → commands}/metrics.md +1 -1
  11. package/src/commands/workflow-agents.md +101 -0
  12. package/src/{.agents/commands → commands}/workflow-compound.md +1 -1
  13. package/src/{.agents/commands → commands}/workflow-plan.md +24 -33
  14. package/src/commands/workflow-work.md +836 -0
  15. package/src/{.agents/references → references}/README.md +1 -1
  16. package/src/{.agents/skills → skills}/capture-skill/SKILL.md +1 -1
  17. package/src/{.agents/skills → skills}/compound-docs/SKILL.md +6 -6
  18. package/src/{.agents/skills → skills}/compound-docs/references/yaml-schema.md +2 -2
  19. package/src/skills/setup-agents/SKILL.md +250 -0
  20. package/src/skills/standards/SKILL.md +79 -0
  21. package/src/skills/standards/references/architecture.md +228 -0
  22. package/src/skills/standards/references/code-quality.md +192 -0
  23. package/src/skills/standards/references/presentation.md +515 -0
  24. package/src/skills/standards/references/services.md +172 -0
  25. package/src/skills/standards/references/state-management.md +936 -0
  26. package/.claude-plugin/plugin.json +0 -7
  27. package/.cursor-plugin/plugin.json +0 -20
  28. package/.cursor-plugin/registration.json +0 -5
  29. package/scripts/check-version-parity.mjs +0 -36
  30. package/scripts/generate-platform-artifacts.mjs +0 -230
  31. package/src/.agents/commands/install.md +0 -51
  32. package/src/.agents/commands/workflow-work.md +0 -690
  33. package/src/.agents/registry.json +0 -48
  34. package/src/.agents/scripts/self-check.mjs +0 -227
  35. package/src/.agents/scripts/sync-opencode.mjs +0 -362
  36. package/src/.agents/skills/presentation-composability/SKILL.md +0 -72
  37. package/src/.agents/skills/react-ddd-mvc-frontend/SKILL.md +0 -51
  38. package/src/.agents/skills/react-ddd-mvc-frontend/references/feature-structure.md +0 -25
  39. package/src/.agents/skills/react-ddd-mvc-frontend/references/implementation-principles.md +0 -21
  40. package/src/.agents/skills/react-ddd-mvc-frontend/references/responsibility-gates.md +0 -41
  41. package/src/.agents/skills/react-ddd-mvc-frontend/references/source-map.md +0 -11
  42. package/src/.agents/skills/standards/SKILL.md +0 -747
  43. package/src/.agents/skills/xstate-actor-orchestration/SKILL.md +0 -197
  44. package/src/.agents/skills/xstate-actor-orchestration/agents/openai.yaml +0 -4
  45. package/src/.agents/skills/xstate-actor-orchestration/assets/statecharts/.gitkeep +0 -0
  46. package/src/.agents/skills/xstate-actor-orchestration/references/actor-system-patterns.md +0 -71
  47. package/src/.agents/skills/xstate-actor-orchestration/references/event-contracts.md +0 -73
  48. package/src/.agents/skills/xstate-actor-orchestration/references/functional-domain-patterns.md +0 -53
  49. package/src/.agents/skills/xstate-actor-orchestration/references/machine-structure-and-tags.md +0 -36
  50. package/src/.agents/skills/xstate-actor-orchestration/references/react-container-pattern.md +0 -45
  51. package/src/.agents/skills/xstate-actor-orchestration/references/reliability-observability.md +0 -39
  52. package/src/.agents/skills/xstate-actor-orchestration/references/skill-validation.md +0 -33
  53. package/src/.agents/skills/xstate-actor-orchestration/references/source-map.md +0 -44
  54. package/src/.agents/skills/xstate-actor-orchestration/references/statechart-review-and-signoff.md +0 -59
  55. package/src/.agents/skills/xstate-actor-orchestration/references/testing-strategy.md +0 -35
  56. package/src/.agents/skills/xstate-actor-orchestration/scripts/create-statechart-artifact.sh +0 -71
  57. package/src/.agents/skills/xstate-actor-orchestration/scripts/validate-skill.sh +0 -138
  58. package/src/generated/opencode.managed.json +0 -115
  59. /package/src/{.agents/agents → agents}/research/framework-docs-researcher.md +0 -0
  60. /package/src/{.agents/agents → agents}/research/git-history-analyzer.md +0 -0
  61. /package/src/{.agents/agents → agents}/research/learnings-researcher.md +0 -0
  62. /package/src/{.agents/agents → agents}/research/repo-research-analyst.md +0 -0
  63. /package/src/{.agents/agents → agents}/review/agent-native-reviewer.md +0 -0
  64. /package/src/{.agents/agents → agents}/review/planning-technical-reviewer.md +0 -0
  65. /package/src/{.agents/agents → agents}/workflow/bug-reproduction-validator.md +0 -0
  66. /package/src/{.agents/agents → agents}/workflow/lint.md +0 -0
  67. /package/src/{.agents/agents → agents}/workflow/spec-flow-analyzer.md +0 -0
  68. /package/src/{.agents/commands → commands}/test-browser.md +0 -0
  69. /package/src/{.agents/commands → commands}/workflow-brainstorm.md +0 -0
  70. /package/src/{.agents/commands → commands}/workflow-review.md +0 -0
  71. /package/src/{.agents/commands → commands}/workflow-tech-review.md +0 -0
  72. /package/src/{.agents/commands → commands}/workflow-triage.md +0 -0
  73. /package/src/{.agents/references → references}/standards/README.md +0 -0
  74. /package/src/{.agents/skills → skills}/agent-browser/SKILL.md +0 -0
  75. /package/src/{.agents/skills → skills}/audit-traceability/SKILL.md +0 -0
  76. /package/src/{.agents/skills → skills}/brainstorming/SKILL.md +0 -0
  77. /package/src/{.agents/skills → skills}/compound-docs/assets/critical-pattern-template.md +0 -0
  78. /package/src/{.agents/skills → skills}/compound-docs/assets/resolution-template.md +0 -0
  79. /package/src/{.agents/skills → skills}/compound-docs/schema.project.yaml +0 -0
  80. /package/src/{.agents/skills → skills}/compound-docs/schema.yaml +0 -0
  81. /package/src/{.agents/skills → skills}/data-foundations/SKILL.md +0 -0
  82. /package/src/{.agents/skills → skills}/document-review/SKILL.md +0 -0
  83. /package/src/{.agents/skills → skills}/file-todos/SKILL.md +0 -0
  84. /package/src/{.agents/skills → skills}/file-todos/assets/todo-template.md +0 -0
  85. /package/src/{.agents/skills → skills}/financial-workflow-integrity/SKILL.md +0 -0
  86. /package/src/{.agents/skills → skills}/git-worktree/SKILL.md +0 -0
  87. /package/src/{.agents/skills → skills}/pii-protection-prisma/SKILL.md +0 -0
  88. /package/src/{.agents/skills → skills}/process-metrics/SKILL.md +0 -0
  89. /package/src/{.agents/skills → skills}/process-metrics/assets/daily-template.md +0 -0
  90. /package/src/{.agents/skills → skills}/process-metrics/assets/monthly-template.md +0 -0
  91. /package/src/{.agents/skills → skills}/process-metrics/assets/weekly-template.md +0 -0
  92. /package/src/{.agents/skills → skills}/technical-review/SKILL.md +0 -0
@@ -0,0 +1,936 @@
1
+ # State Management Reference
2
+
3
+ The controller layer is implemented using **XState v5**. This reference covers all patterns, conventions, and rules for writing correct, maintainable state machines.
4
+
5
+ ## Table of Contents
6
+
7
+ 1. [Core Principles](#1-core-principles)
8
+ 2. [Setup Pattern](#2-setup-pattern)
9
+ 3. [State Nodes](#3-state-nodes)
10
+ 4. [Context](#4-context)
11
+ 5. [Events](#5-events)
12
+ 6. [Guards](#6-guards)
13
+ 7. [Actions](#7-actions)
14
+ 8. [Actors — invoke vs spawn](#8-actors--invoke-vs-spawn)
15
+ 9. [Cross-Machine Communication](#9-cross-machine-communication)
16
+ 10. [Effect Runtime Injection](#10-effect-runtime-injection)
17
+ 11. [Tags](#11-tags)
18
+ 12. [File Organisation](#12-file-organisation)
19
+ 13. [v5 API Changes from v4](#13-v5-api-changes-from-v4)
20
+ 14. [Quick Check — Common Violations](#14-quick-check--common-violations)
21
+
22
+ ---
23
+
24
+ ## 1. Core Principles
25
+
26
+ ### States represent behaviour, context holds data
27
+
28
+ A state node answers "what is the machine doing right now?"
29
+ Context answers "what data does the machine have?"
30
+
31
+ If you find yourself switching on a context value to determine behaviour — that value should be a state node, not context.
32
+
33
+ ### Explicit over implicit
34
+
35
+ Every possible transition is declared in the chart. No hidden control flow, no behaviour that emerges from combinations of context values. If it's not in the chart, it doesn't happen.
36
+
37
+ ### State chart over boolean flags
38
+
39
+ Model finite conditions as state nodes, not context booleans. Context booleans that gate behaviour are undeclared states — they make the machine harder to reason about and impossible to visualise.
40
+
41
+ ```typescript
42
+ // ❌ State disguised as context
43
+ type Context = {
44
+ isLoading: boolean;
45
+ isOpen: boolean;
46
+ viewMode: "folder" | "search";
47
+ };
48
+
49
+ // ✅ Modelled as state nodes
50
+ states: {
51
+ idle: {},
52
+ loading: {},
53
+ folderView: {},
54
+ searchView: {},
55
+ }
56
+ ```
57
+
58
+ ---
59
+
60
+ ## 2. Setup Pattern
61
+
62
+ `setup()` is the idiomatic entry point for all machines in v5. Declare all types, guards, actors, and actions here — before the machine definition — so XState can infer and propagate types throughout.
63
+
64
+ ```typescript
65
+ import { setup, assign, fromPromise, sendTo, enqueueActions } from "xstate";
66
+ import * as playlistSelectionEntity from "src/features/{feature}/domain/entities/PlaylistSelectionEntity";
67
+
68
+ export const playlistMachine = setup({
69
+ types: {} as {
70
+ input: { runtime: typeof AppRuntime };
71
+ context: {
72
+ runtime: typeof AppRuntime;
73
+ playlists: Playlist[];
74
+ activeFolderId: string | null;
75
+ };
76
+ events:
77
+ | { type: "playlist.load"; payload: { folderId: string } }
78
+ | { type: "playlist.sync"; payload: { playlists: Playlist[] } }
79
+ | { type: "playlist.public.selectionChanged"; payload: { ids: string[] } };
80
+ },
81
+ guards: {
82
+ hasPlaylists: ({ context }) =>
83
+ playlistSelectionEntity.hasPlaylists(context.playlists),
84
+ hasAddedPlaylists: ({ context, event }) =>
85
+ playlistSelectionEntity.hasAddedPlaylists({
86
+ current: context.playlists,
87
+ incoming: event.payload.playlists,
88
+ }),
89
+ },
90
+ actions: {
91
+ assignPlaylists: assign({
92
+ playlists: ({ event }) => event.payload.playlists,
93
+ }),
94
+ notifyLoaded: sendTo(
95
+ ({ system }) => system.get("notifier"),
96
+ { type: "notifier.notify", payload: { message: "Playlists loaded" } }
97
+ ),
98
+ },
99
+ actors: {
100
+ fetchPlaylists: fromPromise(
101
+ ({ input }: { input: { runtime: typeof AppRuntime; folderId: string } }) =>
102
+ input.runtime.runPromise(
103
+ PlaylistService.pipe(Effect.flatMap(svc => svc.fetchPlaylists(input.folderId)))
104
+ )
105
+ ),
106
+ },
107
+ }).createMachine({
108
+ context: ({ input }) => ({
109
+ runtime: input.runtime,
110
+ playlists: [],
111
+ activeFolderId: null,
112
+ }),
113
+ // ...
114
+ });
115
+ ```
116
+
117
+ **Everything declared in `setup()` is strongly typed throughout the machine.** Guards, actions, and actors are referenced by string key inside the machine config — XState infers from the `setup()` declaration.
118
+
119
+ ---
120
+
121
+ ## 3. State Nodes
122
+
123
+ ### Every state node requires a comment
124
+
125
+ Every state node must have a comment describing its intent — what it represents in the domain, not what it does mechanically.
126
+
127
+ ```typescript
128
+ states: {
129
+ idle: {
130
+ // Waiting for the user to initiate an action. No background work.
131
+ },
132
+ loading: {
133
+ // Fetching playlist data from the backend via the injected runtime.
134
+ // Transitions to loaded on success, error on failure.
135
+ },
136
+ backgroundSync: {
137
+ // Receives external data updates and syncs into context.
138
+ // Runs concurrently with foreground states — neither blocks the other.
139
+ type: "parallel",
140
+ states: { ... }
141
+ },
142
+ }
143
+ ```
144
+
145
+ ### Parallel states
146
+
147
+ Use `type: "parallel"` when multiple independent concerns must be active simultaneously. Each region is self-contained — regions do not transition into each other.
148
+
149
+ ```typescript
150
+ states: {
151
+ session: {
152
+ // Manages auth and data sync as independent concurrent concerns.
153
+ type: "parallel",
154
+ states: {
155
+ auth: {
156
+ // Tracks authentication state independently of data loading.
157
+ initial: "authenticated",
158
+ states: {
159
+ authenticated: { /* ... */ },
160
+ expired: { /* ... */ },
161
+ },
162
+ },
163
+ sync: {
164
+ // Polls for data updates independently of auth state.
165
+ initial: "idle",
166
+ states: {
167
+ idle: { /* ... */ },
168
+ polling: { /* ... */ },
169
+ },
170
+ },
171
+ },
172
+ },
173
+ }
174
+ ```
175
+
176
+ **When to use parallel states vs separate machines:**
177
+ - Use `type: "parallel"` when concerns share context and lifecycle — they start and stop together
178
+ - Use separate invoked machines when concerns are fully independent and need their own context
179
+ - Do not use parallel states for concerns that need to coordinate transitions between regions — that coupling is a signal to rethink the model
180
+
181
+ ### Reenter behaviour (`reenter: true`)
182
+
183
+ In v5, transitions to a sibling or descendant state within a compound state are **internal by default** — entry and exit actions on the parent state do not fire. If you need the parent's entry/exit to re-run, you must opt in explicitly with `reenter: true`.
184
+
185
+ ```typescript
186
+ states: {
187
+ editing: {
188
+ // User is actively editing a playlist item.
189
+ entry: "resetForm",
190
+ states: {
191
+ idle: {},
192
+ saving: {},
193
+ },
194
+ on: {
195
+ "playlist.reset": {
196
+ target: "editing.idle",
197
+ // ✅ Explicitly re-enters editing — resetForm fires again
198
+ reenter: true,
199
+ },
200
+ "playlist.save": {
201
+ // ❌ Without reenter: true, resetForm does NOT fire on this transition
202
+ target: "editing.saving",
203
+ },
204
+ },
205
+ },
206
+ }
207
+ ```
208
+
209
+ This is a common source of bugs when migrating from v4, where external transitions were the default.
210
+
211
+ ---
212
+
213
+ ## 4. Context
214
+
215
+ Context is appropriate for:
216
+ - **Payloads** — data received from events, passed downstream to containers
217
+ - **Previous values** — snapshots held for diffing/change detection
218
+ - **Derived UI data** — transformed data ready for containers to select
219
+ - **Identifiers** — IDs and references needed across transitions
220
+ - **Actor refs** — parent references passed via `input` for child-to-parent communication
221
+ - **Runtime** — the injected Effect runtime (infrastructure, not domain data)
222
+
223
+ Context is **not** appropriate for:
224
+ - Values you switch on to determine transitions
225
+ - Flags that represent where the machine is or what it's doing
226
+ - Anything with a finite set of values that map to distinct behaviours
227
+
228
+ **Exception — previous values:** Storing previous values for change detection is valid data, not state.
229
+
230
+ ```typescript
231
+ type Context = {
232
+ previousHierarchy: Hierarchy | null; // for diffing against incoming value
233
+ };
234
+ ```
235
+
236
+ **Exception — runtime:** The Effect runtime lives in context as the sanctioned infrastructure exception. It is never switched on for transitions.
237
+
238
+ ```typescript
239
+ type Context = {
240
+ runtime: typeof AppRuntime; // injected once, used to call services
241
+ };
242
+ ```
243
+
244
+ ### Type-Safe Context Access
245
+
246
+ When types are defined in `setup({ types: { context: ... } })`, destructure directly without casting. XState's `setup()` provides full type inference — trust the types.
247
+
248
+ ```typescript
249
+ // Machine with typed context
250
+ export const machine = setup({
251
+ types: {
252
+ context: {} as { sidebarCollapsed: boolean; currentRoute: string },
253
+ },
254
+ }).createMachine({
255
+ context: { sidebarCollapsed: false, currentRoute: "/" },
256
+ // ...
257
+ });
258
+
259
+ // Container — no casting needed
260
+ const [snapshot] = useMachine(machine);
261
+ const { sidebarCollapsed, currentRoute } = snapshot.context; // Fully typed
262
+ ```
263
+
264
+ ```typescript
265
+ // ❌ Casting when types are already declared in setup()
266
+ const collapsed = (snapshot.context as { sidebarCollapsed: boolean }).sidebarCollapsed;
267
+
268
+ // ✅ Destructure directly — types flow from setup()
269
+ const { sidebarCollapsed } = snapshot.context;
270
+ ```
271
+
272
+ ---
273
+
274
+ ## 5. Events
275
+
276
+ ### String literal unions, not enums
277
+
278
+ Events are typed as **string literal discriminated unions**. XState v5's type inference in `setup()` flows from the union declared in `types.events`. Enums produce opaque values that break this inference chain and add unnecessary runtime overhead.
279
+
280
+ ```typescript
281
+ // ✅ String literal union — inference flows through setup(), zero runtime cost
282
+ setup({
283
+ types: {} as {
284
+ events:
285
+ | { type: "playlist.load"; payload: { folderId: string } }
286
+ | { type: "playlist.sync"; payload: { playlists: Playlist[] } };
287
+ },
288
+ })
289
+
290
+ // ❌ Enum — breaks XState v5 type inference
291
+ enum EventType {
292
+ LOAD = "playlist.load",
293
+ SYNC = "playlist.sync",
294
+ }
295
+ ```
296
+
297
+ ### Namespace convention
298
+
299
+ **Internal events** — private to the machine, defined in the controller file:
300
+ ```
301
+ { type: "{controllerNamespace}.{eventName}" }
302
+
303
+ // e.g.
304
+ { type: "playlist.load" }
305
+ { type: "playlist.sync" }
306
+ ```
307
+
308
+ **Public events** — cross-machine API, defined in `types.ts`:
309
+ ```
310
+ { type: "{controllerNamespace}.public.{eventName}" }
311
+
312
+ // e.g.
313
+ { type: "playlist.public.selectionChanged" }
314
+ ```
315
+
316
+ ### Partial event wildcards
317
+
318
+ Because events follow dot-delimited namespaces, you can handle an entire namespace group with a wildcard (`.*`). This is intentional — the naming convention enables the pattern.
319
+
320
+ ```typescript
321
+ on: {
322
+ // Handle all playlist events in one transition
323
+ "playlist.*": {
324
+ actions: "logPlaylistEvent",
325
+ },
326
+
327
+ // More specific transitions are checked first — wildcard is a fallback
328
+ "playlist.load": {
329
+ target: "loading",
330
+ },
331
+ }
332
+ ```
333
+
334
+ Use wildcards for cross-cutting concerns (logging, analytics, error boundaries) — not as a substitute for declaring explicit transitions.
335
+
336
+ ### Public event documentation
337
+
338
+ Public event types in `types.ts` must have JSDoc comments describing when the event is emitted and what a subscriber receives:
339
+
340
+ ```typescript
341
+ // types.ts
342
+ export type PublicPlaylistEvent =
343
+ /** Emitted when the active playlist selection changes. Payload contains the updated selection IDs. */
344
+ | { type: "playlist.public.selectionChanged"; payload: { ids: string[] } }
345
+ /** Emitted when playlist data has been synchronised from the backend. */
346
+ | { type: "playlist.public.synced"; payload: { playlists: Playlist[] } };
347
+ ```
348
+
349
+ ---
350
+
351
+ ## 6. Guards
352
+
353
+ ### Declaration
354
+
355
+ Declare all guards in `setup()`. Call entity predicates directly — no wrapper functions:
356
+
357
+ ```typescript
358
+ import * as playlistSelectionEntity from "src/features/{feature}/domain/entities/PlaylistSelectionEntity";
359
+
360
+ setup({
361
+ guards: {
362
+ hasAddedPlaylists: ({ context, event }) =>
363
+ playlistSelectionEntity.hasAddedPlaylists({
364
+ current: context.playlists,
365
+ incoming: event.payload.playlists,
366
+ }),
367
+ hasPlaylists: ({ context }) =>
368
+ playlistSelectionEntity.hasPlaylists(context.playlists),
369
+ },
370
+ })
371
+ ```
372
+
373
+ ### Guarded transition arrays
374
+
375
+ When a single event can lead to multiple targets depending on conditions, use an array of guarded transitions. The first guard that evaluates to `true` wins. A final entry with no guard is the default.
376
+
377
+ ```typescript
378
+ on: {
379
+ "playlist.sync": [
380
+ {
381
+ guard: "hasAddedPlaylists",
382
+ target: "reconciling",
383
+ actions: "assignIncomingPlaylists",
384
+ },
385
+ {
386
+ guard: "hasPlaylists",
387
+ target: "loaded",
388
+ },
389
+ {
390
+ // Default — no guard
391
+ target: "empty",
392
+ },
393
+ ],
394
+ }
395
+ ```
396
+
397
+ ### Higher-order guards
398
+
399
+ v5 ships `and`, `or`, and `not` combinators for composing guards without inline logic:
400
+
401
+ ```typescript
402
+ import { and, or, not } from "xstate";
403
+
404
+ setup({
405
+ guards: {
406
+ isAuthenticated: ({ context }) => context.isAuthenticated,
407
+ hasPermission: ({ context }) => context.role === "editor",
408
+ isReadOnly: ({ context }) => context.accessLevel === "read",
409
+ },
410
+ })
411
+
412
+ // In transitions — compose without extra guard definitions
413
+ on: {
414
+ "playlist.edit": {
415
+ guard: and(["isAuthenticated", "hasPermission"]),
416
+ target: "editing",
417
+ },
418
+ "playlist.view": {
419
+ guard: or(["isAuthenticated", not("isReadOnly")]),
420
+ target: "viewing",
421
+ },
422
+ }
423
+ ```
424
+
425
+ Use combinators to express compound conditions in the chart rather than creating new named guards that just combine others.
426
+
427
+ ---
428
+
429
+ ## 7. Actions
430
+
431
+ ### Declaration
432
+
433
+ Declare named actions in `setup()`. Actions referenced by string key inside the machine config are fully typed from `setup()`.
434
+
435
+ ```typescript
436
+ setup({
437
+ actions: {
438
+ assignPlaylists: assign({
439
+ playlists: ({ event }) => event.payload.playlists,
440
+ }),
441
+ resetContext: assign({
442
+ playlists: [],
443
+ activeFolderId: null,
444
+ }),
445
+ },
446
+ })
447
+ ```
448
+
449
+ ### Action params
450
+
451
+ Pass typed parameters to named actions using the `params` property. This keeps actions reusable across different transitions without coupling them to specific event shapes:
452
+
453
+ ```typescript
454
+ setup({
455
+ actions: {
456
+ // Action accepts typed params — not bound to a specific event
457
+ notifyUser: (_, params: { message: string; level: "info" | "error" }) => {
458
+ // send to notifier system actor
459
+ },
460
+ },
461
+ })
462
+
463
+ // In transitions — pass params alongside the action reference
464
+ on: {
465
+ "playlist.saved": {
466
+ actions: {
467
+ type: "notifyUser",
468
+ params: { message: "Playlist saved", level: "info" },
469
+ },
470
+ },
471
+ "playlist.error": {
472
+ actions: {
473
+ type: "notifyUser",
474
+ params: { message: "Save failed", level: "error" },
475
+ },
476
+ },
477
+ }
478
+ ```
479
+
480
+ ### enqueueActions — conditional action sequences
481
+
482
+ Use `enqueueActions` when a single transition needs to conditionally execute multiple actions in sequence. This replaces the removed `pure()` and `choose()` from v4.
483
+
484
+ ```typescript
485
+ import { enqueueActions } from "xstate";
486
+
487
+ setup({
488
+ actions: {
489
+ handlePlaylistSync: enqueueActions(({ enqueue, check, context, event }) => {
490
+ // Always assign the incoming data
491
+ enqueue.assign({
492
+ playlists: event.payload.playlists,
493
+ });
494
+
495
+ // Conditionally notify if something changed
496
+ if (check("hasAddedPlaylists")) {
497
+ enqueue.sendTo(
498
+ ({ system }) => system.get("notifier"),
499
+ { type: "notifier.notify", payload: { message: "New playlists added" } }
500
+ );
501
+ }
502
+
503
+ // Raise an internal event for further processing
504
+ enqueue.raise({ type: "playlist.reconcile" });
505
+ }),
506
+ },
507
+ })
508
+ ```
509
+
510
+ **When to use `enqueueActions`:**
511
+ - A transition needs to run multiple actions where some are conditional
512
+ - You need to mix `assign` with `sendTo` or `raise` in one logical step
513
+ - You need guard-checked branching inside a single action handler
514
+
515
+ **When not to use `enqueueActions`:**
516
+ - The actions are all unconditional — list them in the `actions` array directly
517
+ - The branching should be a guarded transition array instead
518
+
519
+ ### assertEvent
520
+
521
+ `assertEvent` is used where XState does not automatically narrow the event type. This occurs in `entry` and `exit` actions — which can be triggered by multiple events — but not in transition-scoped handlers, where the type is already narrowed.
522
+
523
+ ```typescript
524
+ // ✅ No assertEvent needed — transition scope narrows the type
525
+ on: {
526
+ "playlist.load": {
527
+ actions: assign({
528
+ activeFolderId: ({ event }) => event.payload.folderId,
529
+ }),
530
+ },
531
+ }
532
+
533
+ // ✅ assertEvent required — entry can be triggered by multiple events
534
+ entry: ({ event }) => {
535
+ assertEvent(event, "playlist.load");
536
+ console.log(event.payload.folderId);
537
+ }
538
+
539
+ // ❌ Unnecessary — redundant inside a transition-scoped assign
540
+ on: {
541
+ "playlist.load": {
542
+ actions: assign({
543
+ activeFolderId: ({ event }) => {
544
+ assertEvent(event, "playlist.load"); // redundant
545
+ return event.payload.folderId;
546
+ },
547
+ }),
548
+ },
549
+ }
550
+ ```
551
+
552
+ ---
553
+
554
+ ## 8. Actors — invoke vs spawn
555
+
556
+ Choosing between `invoke` and `spawn` is an architectural decision, not a style preference. Getting it wrong produces actors that either die too early or leak indefinitely.
557
+
558
+ ### invoke — lifecycle bound to a state
559
+
560
+ Use `invoke` when the actor's work is scoped to a specific state. The actor starts when the state is entered and stops when it is exited. This is the correct choice for Effect service calls.
561
+
562
+ ```typescript
563
+ states: {
564
+ loading: {
565
+ // Fetching playlists — actor lives only while in this state
566
+ invoke: {
567
+ src: "fetchPlaylists",
568
+ input: ({ context }) => ({
569
+ runtime: context.runtime,
570
+ folderId: context.activeFolderId,
571
+ }),
572
+ onDone: {
573
+ target: "loaded",
574
+ actions: assign({ playlists: ({ event }) => event.output }),
575
+ },
576
+ onError: {
577
+ target: "error",
578
+ },
579
+ },
580
+ },
581
+ }
582
+ ```
583
+
584
+ ### spawn — dynamic, action-driven lifetime
585
+
586
+ Use `spawn` when you need to create actors dynamically at runtime, outside of a specific state's lifecycle. Spawned actors are created by an action, persist until explicitly stopped, and must be stored in context to be referenced later.
587
+
588
+ ```typescript
589
+ setup({
590
+ actions: {
591
+ spawnUploadWorker: assign({
592
+ uploadRef: ({ spawn, event }) =>
593
+ spawn("uploadWorker", {
594
+ input: { fileId: event.payload.fileId },
595
+ systemId: `upload-${event.payload.fileId}`,
596
+ }),
597
+ }),
598
+ },
599
+ })
600
+ ```
601
+
602
+ ### Decision rule
603
+
604
+ | Question | Answer | Use |
605
+ |---|---|---|
606
+ | Does the work belong to a specific state? | Yes | `invoke` |
607
+ | Should the actor stop when the state exits? | Yes | `invoke` |
608
+ | Are you calling an Effect service? | Yes | `invoke` |
609
+ | Do you need a dynamic number of actors? | Yes | `spawn` |
610
+ | Does the actor need to outlive the state that created it? | Yes | `spawn` |
611
+ | Is it a system-wide actor registered with `systemId`? | Yes | `invoke` at root |
612
+
613
+ **`invoke` is the default.** Effect service calls are always state-scoped. Reach for `spawn` only when the use case clearly requires a dynamic, independently-lived actor.
614
+
615
+ ---
616
+
617
+ ## 9. Cross-Machine Communication
618
+
619
+ ### Receptionist pattern — system-wide actors
620
+
621
+ In v5, cross-actor communication uses the native actor system. There is no `broadcast()` function — that was a custom v4 abstraction with no equivalent in v5.
622
+
623
+ When `createActor()` is called on the root machine, an implicit actor system is created. Any actor invoked with a `systemId` is registered and can be looked up by any actor in the system via `system.get('systemId')`.
624
+
625
+ **Step 1 — Register the actor at the root:**
626
+
627
+ ```typescript
628
+ export const rootMachine = setup({
629
+ actors: { notifierMachine },
630
+ }).createMachine({
631
+ invoke: {
632
+ src: "notifierMachine",
633
+ systemId: "notifier", // registered system-wide
634
+ },
635
+ // ...
636
+ });
637
+ ```
638
+
639
+ **Step 2 — Send to it from any child:**
640
+
641
+ ```typescript
642
+ export const playlistMachine = setup({
643
+ actions: {
644
+ notifyPlaylistSaved: sendTo(
645
+ ({ system }) => system.get("notifier"),
646
+ { type: "notifier.notify", payload: { message: "Playlist saved" } }
647
+ ),
648
+ },
649
+ }).createMachine({
650
+ on: {
651
+ "playlist.save": {
652
+ actions: "notifyPlaylistSaved",
653
+ },
654
+ },
655
+ });
656
+ ```
657
+
658
+ No import of the notifier machine is needed in child machines. The system is the coupling point.
659
+
660
+ ### Child-to-parent communication
661
+
662
+ Pass `self` as input when invoking a child machine. The child stores the parent ref in context and uses `sendTo` to communicate back. `sendParent()` is deprecated in v5.
663
+
664
+ ```typescript
665
+ // Parent — passes self reference as input
666
+ invoke: {
667
+ src: "childMachine",
668
+ input: ({ self }) => ({ parentRef: self }),
669
+ }
670
+
671
+ // Child — stores parent ref, communicates via sendTo
672
+ setup({
673
+ types: {} as {
674
+ input: { parentRef: ActorRef<Snapshot<unknown>, ParentEvent> };
675
+ context: { parentRef: ActorRef<Snapshot<unknown>, ParentEvent> };
676
+ },
677
+ }).createMachine({
678
+ context: ({ input }) => ({ parentRef: input.parentRef }),
679
+ on: {
680
+ "child.done": {
681
+ actions: sendTo(
682
+ ({ context }) => context.parentRef,
683
+ { type: "playlist.public.childCompleted" }
684
+ ),
685
+ },
686
+ },
687
+ })
688
+ ```
689
+
690
+ ### Rules
691
+
692
+ - **No `broadcast()`** — removed in v5. Use `systemId` + `system.get()` for fan-out
693
+ - **No `sendParent()`** — deprecated. Pass `self` via `input`, store as `parentRef`, use `sendTo`
694
+ - Register system-wide actors with `systemId` at the root machine only
695
+ - Child machines reach system actors via `system.get('systemId')` — never import actor refs directly
696
+
697
+ ---
698
+
699
+ ## 10. Effect Runtime Injection
700
+
701
+ The app runtime is provided once via XState v5 `input` at the root actor creation site. The root machine stores it in context and passes it as `input` to invoked service actors. No machine constructs or imports the runtime directly.
702
+
703
+ ```typescript
704
+ // src/main.ts — actor creation site
705
+ import { createActor } from "xstate";
706
+ import { AppRuntime } from "src/effects.ts";
707
+
708
+ const actor = createActor(rootMachine, {
709
+ input: { runtime: AppRuntime },
710
+ });
711
+ actor.start();
712
+ ```
713
+
714
+ ```typescript
715
+ // Root machine — receives runtime via input, stores in context
716
+ setup({
717
+ types: {} as {
718
+ input: { runtime: typeof AppRuntime };
719
+ context: { runtime: typeof AppRuntime };
720
+ },
721
+ }).createMachine({
722
+ context: ({ input }) => ({
723
+ runtime: input.runtime,
724
+ }),
725
+
726
+ states: {
727
+ loading: {
728
+ // Passes runtime into the invoked actor — never calls runtime directly
729
+ invoke: {
730
+ src: "fetchPlaylists",
731
+ input: ({ context }) => ({
732
+ runtime: context.runtime,
733
+ folderId: context.activeFolderId,
734
+ }),
735
+ onDone: {
736
+ target: "loaded",
737
+ actions: assign({ playlists: ({ event }) => event.output }),
738
+ },
739
+ onError: { target: "error" },
740
+ },
741
+ },
742
+ },
743
+ });
744
+ ```
745
+
746
+ **`runtime` in context is the sanctioned exception** to "context holds domain data only." It is infrastructure — it is never switched on for transitions and never passed to containers.
747
+
748
+ ---
749
+
750
+ ## 11. Tags
751
+
752
+ Tags are the public API of the machine — the contract between controller and container. State names are internal implementation detail. Containers read tags; they never read state names.
753
+
754
+ Tags are defined as enums. Unlike events, tags are identifier constants used to check set membership — they are not discriminants in a union type, so enums are appropriate and add clarity here.
755
+
756
+ ```typescript
757
+ // In the controller file
758
+ export enum Tags {
759
+ VIEW_MODE_FOLDER = "viewModeFolder",
760
+ VIEW_MODE_SEARCH = "viewModeSearch",
761
+ LOADING = "loading",
762
+ SAVING = "saving",
763
+ }
764
+ ```
765
+
766
+ ```typescript
767
+ // In the machine — applied to state nodes
768
+ states: {
769
+ folderView: {
770
+ // User is browsing content via the folder hierarchy
771
+ tags: [Tags.VIEW_MODE_FOLDER],
772
+ },
773
+ loading: {
774
+ // Fetching data — UI should show a loading indicator
775
+ tags: [Tags.LOADING],
776
+ },
777
+ }
778
+ ```
779
+
780
+ ```typescript
781
+ // In the container — reads tags, never state names
782
+ const isFolderView = useSelector(actor, s => s.hasTag(Tags.VIEW_MODE_FOLDER));
783
+ const isLoading = useSelector(actor, s => s.hasTag(Tags.LOADING));
784
+
785
+ // ❌ Never do this — tightly coupled to internal structure
786
+ const isFolderView = useSelector(actor, s => s.matches("viewModeManagement.folderView"));
787
+ ```
788
+
789
+ ---
790
+
791
+ ## 12. File Organisation
792
+
793
+ ```
794
+ src/features/{feature}/application/controllers/
795
+ └── {Feature}Controller.ts # Machine, Tags enum, internal event types
796
+ ```
797
+
798
+ ```
799
+ src/features/{feature}/
800
+ └── types.ts # Public event union types only — create when needed
801
+ ```
802
+
803
+ | Concern | Location |
804
+ |---|---|
805
+ | `setup()` + machine definition | Controller file |
806
+ | Tags enum | Controller file |
807
+ | Internal event union type | Controller file |
808
+ | Public event union type | `types.ts` |
809
+
810
+ `types.ts` is only introduced when public events need to be consumed by other controllers. Do not create it preemptively.
811
+
812
+ ---
813
+
814
+ ## 13. v5 API Changes from v4
815
+
816
+ | v4 | v5 |
817
+ |---|---|
818
+ | `createMachine(config, options)` | `setup({ guards, actors, actions }).createMachine(config)` |
819
+ | `interpret(machine)` | `createActor(machine, { input })` |
820
+ | `service.send()` | `actor.send()` |
821
+ | `cond: 'guardName'` | `guard: 'guardName'` |
822
+ | `assign((ctx, evt) => ...)` | `assign(({ context, event }) => ...)` |
823
+ | `send()` action | `sendTo()` / `raise()` |
824
+ | `sendParent()` | `sendTo(({ context }) => context.parentRef, event)` |
825
+ | `pure()` / `choose()` | `enqueueActions()` |
826
+ | `broadcast()` (custom) | Receptionist pattern — `sendTo(({ system }) => system.get('id'), event)` |
827
+ | Implicit external transitions | Internal by default — use `reenter: true` to re-enter |
828
+ | Enum event types | String literal union event types |
829
+ | `in: '...'` transition property | `guard: stateIn(...)` from `xstate/guards` |
830
+ | `state.history` | Track previous snapshot manually via `actor.subscribe()` |
831
+ | `escalate()` action | Throw directly in actions — errors propagate automatically |
832
+
833
+ ---
834
+
835
+ ## 14. Quick Check — Common Violations
836
+
837
+ **Using `broadcast()` — removed in v5:**
838
+ ```typescript
839
+ // ❌
840
+ broadcast({ type: "panelStack.public.sync", payload: ... })
841
+
842
+ // ✅
843
+ sendTo(({ system }) => system.get("notifier"), { type: "notifier.notify", ... })
844
+ ```
845
+
846
+ **Using enum for event types:**
847
+ ```typescript
848
+ // ❌ Breaks XState v5 type inference
849
+ enum EventType { LOAD = "playlist.load" }
850
+ on: { [EventType.LOAD]: { ... } }
851
+
852
+ // ✅
853
+ on: { "playlist.load": { ... } }
854
+ ```
855
+
856
+ **Using `sendParent()` — deprecated:**
857
+ ```typescript
858
+ // ❌
859
+ actions: sendParent({ type: "child.done" })
860
+
861
+ // ✅
862
+ actions: sendTo(({ context }) => context.parentRef, { type: "playlist.public.childCompleted" })
863
+ ```
864
+
865
+ **Using `pure()` or `choose()` — removed:**
866
+ ```typescript
867
+ // ❌
868
+ actions: pure((context, event) => [
869
+ assign({ playlists: event.payload.playlists }),
870
+ condition ? sendTo("notifier", ...) : undefined,
871
+ ])
872
+
873
+ // ✅
874
+ actions: enqueueActions(({ enqueue, check }) => {
875
+ enqueue.assign({ playlists: ({ event }) => event.payload.playlists });
876
+ if (check("hasAddedPlaylists")) {
877
+ enqueue.sendTo(({ system }) => system.get("notifier"), { type: "notifier.notify" });
878
+ }
879
+ })
880
+ ```
881
+
882
+ **Expecting parent entry/exit to fire without `reenter: true`:**
883
+ ```typescript
884
+ // ❌ resetForm will NOT fire — internal transition by default in v5
885
+ on: {
886
+ "playlist.reset": { target: "editing.idle" }
887
+ }
888
+
889
+ // ✅ resetForm fires — explicitly re-enters the parent
890
+ on: {
891
+ "playlist.reset": { target: "editing.idle", reenter: true }
892
+ }
893
+ ```
894
+
895
+ **Spawning when invoke is correct:**
896
+ ```typescript
897
+ // ❌ spawn leaks — nothing stops this actor when the state exits
898
+ actions: assign({
899
+ ref: ({ spawn }) => spawn("fetchPlaylists", { input: { folderId } }),
900
+ })
901
+
902
+ // ✅ invoke is lifecycle-bound — stops when state exits
903
+ invoke: {
904
+ src: "fetchPlaylists",
905
+ input: ({ context }) => ({ runtime: context.runtime, folderId: context.activeFolderId }),
906
+ }
907
+ ```
908
+
909
+ **Boolean flag in context that should be a state node:**
910
+ ```typescript
911
+ // ❌
912
+ type Context = { isLoading: boolean; hasError: boolean };
913
+
914
+ // ✅
915
+ states: { idle: {}, loading: {}, error: {} }
916
+ ```
917
+
918
+ **Using v4 `cond` and `assign` signatures:**
919
+ ```typescript
920
+ // ❌ v4
921
+ { cond: "hasPlaylists" }
922
+ assign((context, event) => ({ ... }))
923
+
924
+ // ✅ v5
925
+ { guard: "hasPlaylists" }
926
+ assign(({ context, event }) => ({ ... }))
927
+ ```
928
+
929
+ **Targeting state name in container:**
930
+ ```typescript
931
+ // ❌
932
+ s.matches("viewModeManagement.folderView")
933
+
934
+ // ✅
935
+ s.hasTag(Tags.VIEW_MODE_FOLDER)
936
+ ```