slapflow 1.0.2 → 1.2.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.
package/README.md CHANGED
@@ -14,6 +14,12 @@ Slapflow takes care of orchestration, concurrency, cancellation, and diagnostics
14
14
  - **Make async behavior deliberate.** Choose `parallel`, `latest`, `queue`, or `drop` for each event source. Actions receive an `AbortSignal` when cancellation matters.
15
15
  - **Use the same flow in more than one place.** The chain can start from a typed bus, DOM event, API callback, timer, worker, or WebSocket message.
16
16
 
17
+ ### When to use Slapflow
18
+
19
+ Slapflow orchestrates a control flow that lives and finishes inside a single process. Reach for it when a scenario spans several steps, branches, and failure paths and you want it in one place rather than spread across handlers and callbacks.
20
+
21
+ **What it is not:** a web framework (no routing, no middleware stack — pair it with Express, Hono, or Next.js routes) or a job queue (no built-in persistence, no distributed workers). It is also not a durable workflow engine: a chain runs inside a live process and stops with it — a `core.loop` can spin indefinitely while the process runs, but unlike Temporal there is no state that survives a crash or restart unless your application prevails it. It is closest to a lightweight, in-process state machine: decisions, ordering, concurrency, and cancellation live in a chain, while your application keeps the domain state and side effects.
22
+
17
23
  ## Installation
18
24
 
19
25
  ```bash
@@ -156,13 +162,14 @@ const config = {
156
162
  - A broad set of [built-in conditions](SPEC.md#built-in-conditions) for comparisons, type checks, collections, and compound logic.
157
163
  - Typed PubSub bindings and delegated DOM bindings.
158
164
  - `parallel`, `latest`, `queue`, and `drop` concurrency modes with per-entity lanes.
159
- - A WebSocket bridge for forwarding selected bus events.
165
+ - A native WebSocket client that proxies socket events into the bus.
160
166
  - `core.fetch` with response parsing, cancellation, and retry backoff.
161
167
  - Normalized results, execution trace, validation, and lifecycle diagnostics such as `slapflow.run.started` and `slapflow.run.failed`.
162
168
  - Runtime variables for configuration values, templates, and expressions.
163
169
 
164
170
  ## Where to go next
165
171
 
172
+ - See [slapflow-studio](https://github.com/khalilov/slapflow-studio), a working application built entirely on Slapflow.
166
173
  - Read the complete [technical specification](SPEC.md) for the runner API, built-in actions and conditions, expressions, validation, safety limits, transport behavior, and lifecycle semantics.
167
174
  - Russian documentation: [README-RU.md](README-RU.md) and [SPEC-RU.md](SPEC-RU.md).
168
175
 
package/SPEC-RU.md CHANGED
@@ -12,23 +12,20 @@
12
12
 
13
13
  ```ts
14
14
  import {
15
- createActionsRegistry,
16
- createConditionsRegistry,
15
+ BUILTIN_ACTIONS,
16
+ BUILTIN_CONDITIONS,
17
17
  createMemoryTraceSink,
18
18
  defineErrorReporter,
19
19
  createPubSub,
20
20
  PubSub,
21
21
  createFlow,
22
- createWS,
22
+ createWebSocket,
23
23
  catchError,
24
24
  } from 'slapflow'
25
25
  ```
26
26
 
27
27
  ```ts
28
- const flow = createFlow<Context, Patch>(
29
- { config: { strategies: {} } },
30
- { context: () => ({} as Context) }
31
- )
28
+ const flow = createFlow<Context, Patch>({ config: { strategies: {} } }, { context: () => ({}) as Context })
32
29
  const runner = flow.runner
33
30
 
34
31
  runner.registerAction('jobs.execute', executeJob)
@@ -45,8 +42,11 @@ type Config = {
45
42
  version?: 1
46
43
  strategies: Record<string, Strategy>
47
44
  entrypoints?: Record<string, string>
45
+ guards?: Record<string, ConditionExpression>
48
46
  }
49
47
 
48
+ type ConditionExpression = boolean | [operator: string, ...args: unknown[]] | ['guard', name: string]
49
+
50
50
  type Strategy = {
51
51
  fn: string
52
52
  props?: Record<string, unknown>
@@ -79,7 +79,7 @@ const reportError = defineErrorReporter({
79
79
 
80
80
  const flow = createFlow(
81
81
  { config: { strategies: {} } },
82
- { context: () => ({} as Context), trace: true, onError: reportError }
82
+ { context: () => ({}) as Context, trace: true, onError: reportError }
83
83
  )
84
84
  ```
85
85
 
@@ -113,29 +113,38 @@ type ErrorStage = {
113
113
 
114
114
  Если ошибка обработана через `catch`, `onError` всё равно вызывается для исходного сбоя, а итоговый `run` может завершиться со статусом `success`.
115
115
 
116
+ ## Нормализация возврата действия
117
+
118
+ Возвращаемое значение действия нормализуется в один итог. Соответствие:
119
+
120
+ | Возврат | Итог |
121
+ | ------------------------------------------------- | ------------------------------------------------------------- |
122
+ | `undefined` / `null` | `success` |
123
+ | `false` | `skipped` |
124
+ | `{ type: 'skip', reason?, data? }` | `skipped` (селектор пробует следующую ветку) |
125
+ | `{ type: 'stop', reason?, patch?, events? }` | `stopped` (цепочка останавливается без ошибки) |
126
+ | `{ type: 'fail', reason?, data?, error? }` | `failed` (запускается `catch`, затем `onError`) |
127
+ | `{ context?, data?, patch?, events?, continue? }` | `success`; `continue: false` прерывает оставшиеся `then`-цели |
128
+
129
+ Брошенное исключение трактуется как `fail`. Возврат `false` и `{ type: 'skip' }` эквивалентны.
130
+
116
131
  ## Модель реестров
117
132
 
118
- Исполнитель использует собственные реестры:
133
+ Встроенные элементы живут в двух общих константах — по одной на вид:
119
134
 
120
- ```text
121
- src/registry/
122
- actions.ts
123
- conditions.ts
135
+ ```ts
136
+ import { BUILTIN_ACTIONS, BUILTIN_CONDITIONS } from 'slapflow'
124
137
  ```
125
138
 
126
- `createActionsRegistry()` создаёт `Map`, предварительно заполненный встроенными действиями.
139
+ `BUILTIN_ACTIONS` это readonly-список `[name, action][]`, предварительно заполненный встроенными действиями; `BUILTIN_CONDITIONS` содержит встроенные условия. Каждый исполнитель получает собственный `new Map(BUILTIN_ACTIONS)` / `new Map(BUILTIN_CONDITIONS)`, поэтому регистрации остаются изолированными по исполнителям.
127
140
 
128
- `createConditionsRegistry()` создаёт `Map`, предварительно заполненный встроенными условиями.
129
-
130
- Каждый исполнитель получает собственную изменяемую копию реестра. Приложения могут переопределить любое встроенное действие или условие:
141
+ Встроенные элементы неизменяемы: `registerAction` и `registerCondition` отклоняют попытку переопределить встроенное имя.
131
142
 
132
143
  ```ts
133
144
  runner.registerAction('app.setData', customSetData)
134
- runner.registerCondition('eq', customEq)
145
+ runner.registerCondition('hasQueue', hasItems)
135
146
  ```
136
147
 
137
- Таким образом, встроенные элементы являются значениями по умолчанию, а не отдельным неизменяемым слоем.
138
-
139
148
  Проверка конфигурации обращается к реестрам через минимальный контракт `has(name)`.
140
149
 
141
150
  ## Встроенные действия
@@ -225,6 +234,18 @@ export const config = {
225
234
 
226
235
  `parallel` запускает цели `then` независимо. Простые объекты и массивы контекста и runtime data копируются для каждой ветки; инфраструктурные значения вроде функций, DOM-узлов и экземпляров классов остаются ссылками. Safety limits, включая `maxStepCount`, остаются общими для всего запуска. Полученные патчи и события возвращаются вызывающей стороне; исполнитель их не применяет.
227
236
 
237
+ ### Прерывание цепочки
238
+
239
+ Не-`success` итог шага меняет дальнейшее поведение в зависимости от режима:
240
+
241
+ | Итог | `sequence` | `selector` |
242
+ | --------- | --------------------- | ----------------------- |
243
+ | `skipped` | **прерывает остаток** | пробует следующую ветку |
244
+
245
+ `sequence` — режим по умолчанию, и он прерывает оставшиеся `then`-цели на _любом_ не-`success` (`skipped`, `stopped`, `failed`) — не только на сбое. Условный шаг внутри последовательности — это, таким образом, скрытый ранний выход для всего остатка. Если пропуск шага не должен рвать цепочку, заверните его в селектор с запасным `core.noop`.
246
+
247
+ `terminal: true` останавливает `then`-цепочку после этой стратегии даже при `success`; `continue: false` в `ActionSuccess` даёт тот же эффект.
248
+
228
249
  ## Вспомогательные средства среды выполнения
229
250
 
230
251
  ```ts
@@ -261,6 +282,29 @@ type Runtime = {
261
282
 
262
283
  Чтение и запись путей во время выполнения реализованы непосредственно через `objwalk`.
263
284
 
285
+ ## Guards
286
+
287
+ Переиспользуемые выражения `when` живут в карте `guards` на `Config` и подключаются к `when` стратегии (или шага `then`/`catch`) узлом `['guard', имя]`:
288
+
289
+ ```ts
290
+ const config = {
291
+ guards: {
292
+ 'has-colony': ['truthy', '$data.colonyId'],
293
+ 'same-colony': ['eq', '$input.colonyId', '$context.colonyId'],
294
+ },
295
+ strategies: {
296
+ 'colony.join': {
297
+ fn: 'colony.join',
298
+ when: ['and', ['guard', 'has-colony'], ['not', ['guard', 'same-colony']]],
299
+ },
300
+ },
301
+ }
302
+ ```
303
+
304
+ Guard — это обычное `ConditionExpression`, и сам может ссылаться на другие guards. Ссылки раскрываются один раз при загрузке конфигурации (`loadConfig`), до того как рантайм что-либо вычисляет, поэтому рантайм никогда не видит узел `['guard', ...]`. Guards раскрываются рекурсивно через `and`/`or`/`not`; ссылка на несуществующий guard даёт ошибку валидации `GUARD_NOT_FOUND`, а взаимные ссылки — `GUARD_CYCLE`. Значение guard должно быть выражением-условием, а не строкой `$path`.
305
+
306
+ Guards существуют, чтобы критерий истинности жил в одном месте, а не дублировался по стратегиям; это вычисляемые данные, а не зарегистрированный код (в отличие от `registerCondition`, который регистрирует функцию-оператор).
307
+
264
308
  ## Проверка конфигурации
265
309
 
266
310
  `validateConfig` проверяет:
@@ -270,7 +314,8 @@ type Runtime = {
270
314
  - отсутствующие стратегии в `then`, `catch` и `entrypoints`;
271
315
  - недопустимые режимы;
272
316
  - недопустимые ссылки на пути;
273
- - циклы без завершающего шага.
317
+ - циклы без завершающего шага;
318
+ - ссылки на guards (`GUARD_NOT_FOUND`, `GUARD_CYCLE`, `GUARD_INVALID`).
274
319
 
275
320
  ## Трассировка
276
321
 
@@ -308,11 +353,14 @@ unsubscribe()
308
353
 
309
354
  ```ts
310
355
  type Bus<TEvents extends object = Record<string, unknown>> = {
311
- on<TEvent extends keyof TEvents>(
312
- event: TEvent,
313
- handler: (event: BusEvent<TEvents[TEvent]>) => void
314
- ): () => void
315
- off<TEvent extends keyof TEvents>(event: TEvent, handler?: (event: BusEvent<TEvents[TEvent]>) => void): void
356
+ on: {
357
+ <TEvent extends keyof TEvents>(event: TEvent, handler: (event: BusEvent<TEvents[TEvent]>) => void): () => void
358
+ (event: EventPattern, handler: (event: BusEvent<unknown>) => void): () => void
359
+ }
360
+ off: {
361
+ <TEvent extends keyof TEvents>(event: TEvent, handler?: (event: BusEvent<TEvents[TEvent]>) => void): void
362
+ (event: EventPattern, handler?: (event: BusEvent<unknown>) => void): void
363
+ }
316
364
  emit<TEvent extends keyof TEvents>(
317
365
  topic: TEvent,
318
366
  payload: TEvents[TEvent],
@@ -320,6 +368,8 @@ type Bus<TEvents extends object = Record<string, unknown>> = {
320
368
  ): BusEvent<TEvents[TEvent]>
321
369
  }
322
370
 
371
+ type EventPattern = `${string}*${string}`
372
+
323
373
  type BusEvent<TPayload> = {
324
374
  id: string
325
375
  topic: string
@@ -332,6 +382,18 @@ type BusEvent<TPayload> = {
332
382
 
333
383
  `emit` создаёт конверт и сериализует полезную нагрузку один раз до запуска подписчиков. Идентификаторы событий — непрозрачные 12-символьные буквенно-цифровые runtime-ID для корреляции и подавления эха. Они не криптографически стойкие: не используйте их для access token, подписей, публичных ссылок или иных security-sensitive задач. `on` возвращает функцию отписки. `off(event, handler)` удаляет один обработчик, а `off(event)` очищает канал. Ошибка одного подписчика не блокирует остальных; `createPubSub({ onError })` получает ошибку и исходное событие. При ошибке сериализации шина передаёт `{ error }` в качестве `parsed` и тело ошибки в качестве `serialized`, после чего вызывает `onError` с исходной причиной.
334
384
 
385
+ ### Подписка по шаблону
386
+
387
+ На тему можно подписаться по шаблону, где `*` соответствует ровно одному сегменту, разделённому точкой. Шаблонный символ не пересекает границу `.`.
388
+
389
+ ```ts
390
+ bus.on('hub.user.*', ({ parsed }) => {}) // hub.user.created, hub.user.deleted
391
+ bus.on('hub.*.created', ({ parsed }) => {}) // hub.user.created, hub.team.created
392
+ bus.on('hub.*.export', ({ parsed }) => {}) // НЕ hub.user.audit.export (один сегмент)
393
+ ```
394
+
395
+ Подписки с точным именем остаются O(1); шаблоны обрабатываются отдельно, поэтому регистрации без `*` не несут накладных затрат на сравнение. Обработчик шаблона получает `parsed` как `unknown` — сужайте тип перед использованием. Шаблоны работают в `bus.on`/`bus.off`.
396
+
335
397
  ## Поток
336
398
 
337
399
  `createFlow` объединяет конфигурацию, действия, условия, поставщик контекста и привязки событий. Функция создаёт исполнитель (доступный через `flow.runner`) и поддерживает жизненный цикл `start`/`stop`.
@@ -415,24 +477,30 @@ type ConcurrencyOptions<TPayload> = {
415
477
 
416
478
  `defaultInput` имеет тип `{ type, value?, dataset, form? }`. `dataset` содержит все атрибуты `data-*` совпавшего элемента в виде ключей camelCase. `form` строится по ближайшему элементу `<form>`; повторяющиеся поля формы превращаются в массивы, а `File` остаётся `File`. Для `submit` значение `preventDefault` по умолчанию равно `true`; для остальных событий оно и `stopPropagation` по умолчанию равны `false`.
417
479
 
418
- ### WebSocket-мост
480
+ ### WebSocket-клиент
419
481
 
420
- `createWS` подключает шину к WebSocket-подобному транспорту. Мост принимает `createSocket`, поэтому одинаково работает с браузерным WebSocket и серверным адаптером.
482
+ `createWebSocket` открывает нативный `WebSocket` по `url` и проксирует каждое событие сокета в шину. Формат провода не предполагается — каждое событие отправляется с фиксированной темой и конвертом, где `parsed` содержит сырую полезную нагрузку.
421
483
 
422
484
  ```ts
423
- const ws = createWS({
485
+ const socket = createWebSocket({
486
+ url,
424
487
  bus,
425
- createSocket: () => new WebSocket(url),
426
- inboundTopics: ['order.created'],
427
- outboundTopics: ['slapflow.run.finished'],
428
- origin: 'worker',
429
- retry: { initialDelay: 500, maxDelay: 10_000, multiplier: 2, jitter: true, maxAttempts: 5 },
488
+ origin: 'client',
430
489
  })
431
490
 
432
- ws.start()
491
+ socket.start()
433
492
  ```
434
493
 
435
- Входящие темы проходят через явный список разрешений. Мост разбирает JSON-конверт и вызывает `bus.dispatch(event)`, сохраняя `id`, `occurredAt`, `origin`, `parsed` и `serialized`; принятые входящие идентификаторы запоминаются и не отправляются обратно наружу. Исходящие темы отправляют полный JSON-конверт события, поэтому удалённый мост может передать его в шину, не создавая новый идентификатор. `maxAttempts` ограничивает reconnect; без него повторы идут бесконечно. `start`, `stop`, `reconnect` и `status` управляют жизненным циклом транспорта. Диагностические события: `slapflow.ws.connecting`, `slapflow.ws.connected`, `slapflow.ws.disconnected`, `slapflow.ws.retrying` и `slapflow.ws.message.rejected`.
494
+ ```ts
495
+ bus.on('message', ({ parsed }) => {}) // parsed = сырое сообщение (JSON-декодированное, когда возможно)
496
+ bus.on('open', ({ parsed }) => {}) // { url }
497
+ bus.on('close', ({ parsed }) => {}) // { code, reason }
498
+ bus.on('error', ({ parsed }) => {}) // { error }
499
+ ```
500
+
501
+ События сокета приходят с темой `open`, `message`, `close` или `error`. Полезная нагрузка `message` JSON-декодируется в `parsed`, если валидна, иначе остаётся сырой строкой. Фильтрация тем — забота потребителя: внутри клиента ничего не разрешается и не отклоняется. `reconnect` принимает `initialDelay`, `maxDelay`, `multiplier`, `jitter` и `maxAttempts`; без `maxAttempts` повторы идут бесконечно. `start`, `stop`, `reconnect` и `status` управляют жизненным циклом; статус — одно из `idle`, `connecting`, `connected`, `reconnecting` или `stopped`.
502
+
503
+ `createWS` помечен как deprecated и будет удалён; мигрируйте на `createWebSocket`.
436
504
 
437
505
  ## Ограничения безопасности
438
506
 
package/SPEC.md CHANGED
@@ -12,23 +12,20 @@ The package is not coupled to a UI, server framework, scheduler, or domain model
12
12
 
13
13
  ```ts
14
14
  import {
15
- createActionsRegistry,
16
- createConditionsRegistry,
15
+ BUILTIN_ACTIONS,
16
+ BUILTIN_CONDITIONS,
17
17
  createMemoryTraceSink,
18
18
  defineErrorReporter,
19
19
  createPubSub,
20
20
  PubSub,
21
21
  createFlow,
22
- createWS,
22
+ createWebSocket,
23
23
  catchError,
24
24
  } from 'slapflow'
25
25
  ```
26
26
 
27
27
  ```ts
28
- const flow = createFlow<Context, Patch>(
29
- { config: { strategies: {} } },
30
- { context: () => ({} as Context) }
31
- )
28
+ const flow = createFlow<Context, Patch>({ config: { strategies: {} } }, { context: () => ({}) as Context })
32
29
  const runner = flow.runner
33
30
 
34
31
  runner.registerAction('jobs.execute', executeJob)
@@ -45,8 +42,11 @@ type Config = {
45
42
  version?: 1
46
43
  strategies: Record<string, Strategy>
47
44
  entrypoints?: Record<string, string>
45
+ guards?: Record<string, ConditionExpression>
48
46
  }
49
47
 
48
+ type ConditionExpression = boolean | [operator: string, ...args: unknown[]] | ['guard', name: string]
49
+
50
50
  type Strategy = {
51
51
  fn: string
52
52
  props?: Record<string, unknown>
@@ -79,7 +79,7 @@ const reportError = defineErrorReporter({
79
79
 
80
80
  const flow = createFlow(
81
81
  { config: { strategies: {} } },
82
- { context: () => ({} as Context), trace: true, onError: reportError }
82
+ { context: () => ({}) as Context, trace: true, onError: reportError }
83
83
  )
84
84
  ```
85
85
 
@@ -113,29 +113,38 @@ type ErrorStage = {
113
113
 
114
114
  If an error is recovered through `catch`, `onError` is still invoked for the original failure and the final `run` may finish with `success`.
115
115
 
116
+ ## Action Return Normalization
117
+
118
+ An action's return value is normalized into one outcome. The mapping:
119
+
120
+ | Return | Outcome |
121
+ | ------------------------------------------------- | --------------------------------------------------------------- |
122
+ | `undefined` / `null` | `success` |
123
+ | `false` | `skipped` |
124
+ | `{ type: 'skip', reason?, data? }` | `skipped` (a selector tries the next branch) |
125
+ | `{ type: 'stop', reason?, patch?, events? }` | `stopped` (the chain halts without error) |
126
+ | `{ type: 'fail', reason?, data?, error? }` | `failed` (`catch` runs, then `onError`) |
127
+ | `{ context?, data?, patch?, events?, continue? }` | `success`; `continue: false` halts the remaining `then` targets |
128
+
129
+ A thrown exception is treated as `fail`. Returning `false` and `{ type: 'skip' }` are equivalent.
130
+
116
131
  ## Registry Model
117
132
 
118
- The runner uses runner-scoped registries:
133
+ Built-ins live in two shared constants, one per kind:
119
134
 
120
- ```text
121
- src/registry/
122
- actions.ts
123
- conditions.ts
135
+ ```ts
136
+ import { BUILTIN_ACTIONS, BUILTIN_CONDITIONS } from 'slapflow'
124
137
  ```
125
138
 
126
- `createActionsRegistry()` creates a `Map` prepopulated with built-in actions.
139
+ `BUILTIN_ACTIONS` is a readonly `[name, action][]` list prepopulated with built-in actions; `BUILTIN_CONDITIONS` holds built-in conditions. Each runner receives its own `new Map(BUILTIN_ACTIONS)` / `new Map(BUILTIN_CONDITIONS)` seed, so registrations stay isolated per runner.
127
140
 
128
- `createConditionsRegistry()` creates a `Map` prepopulated with built-in conditions.
129
-
130
- Each runner receives its own mutable registry copy. Applications can override any built-in action or condition:
141
+ Built-ins are immutable defaults: `registerAction` and `registerCondition` reject an attempt to override a built-in name.
131
142
 
132
143
  ```ts
133
144
  runner.registerAction('app.setData', customSetData)
134
- runner.registerCondition('eq', customEq)
145
+ runner.registerCondition('hasQueue', hasItems)
135
146
  ```
136
147
 
137
- Built-ins are therefore default values, not a separate immutable layer.
138
-
139
148
  Configuration validation accesses registries through the minimal `has(name)` contract.
140
149
 
141
150
  ## Built-In Actions
@@ -225,6 +234,18 @@ export const config = {
225
234
 
226
235
  `parallel` runs `then` targets independently. Plain objects and arrays in runtime context and data are cloned for each branch; infrastructure values such as functions, DOM nodes, and class instances remain references. Safety limits, including `maxStepCount`, remain shared by the whole run. Resulting patches and events are returned to the caller; the runner does not apply them.
227
236
 
237
+ ### Chain interruption
238
+
239
+ A non-`success` outcome at a step changes what happens next, depending on the mode:
240
+
241
+ | Outcome | `sequence` | `selector` |
242
+ | --------- | ----------------------- | --------------------- |
243
+ | `skipped` | **interrupts the rest** | tries the next branch |
244
+
245
+ `sequence` is the default mode and interrupts the remaining `then` targets on _any_ non-`success` (`skipped`, `stopped`, `failed`) — not only on failure. A conditional step inside a sequence is therefore a hidden early exit for the whole remainder. When skipping a step must not break the chain, wrap it in a selector with a `core.noop` fallback.
246
+
247
+ `terminal: true` stops the `then` chain after that strategy even on `success`; `continue: false` in `ActionSuccess` has the same effect.
248
+
228
249
  ## Runtime Helpers
229
250
 
230
251
  ```ts
@@ -261,6 +282,29 @@ type Runtime = {
261
282
 
262
283
  Runtime path get/set is implemented directly through `objwalk`.
263
284
 
285
+ ## Guards
286
+
287
+ Reusable `when` expressions live in the `guards` map on `Config` and are referenced from a strategy's `when` (or a `then`/`catch` step's `when`) with the `['guard', name]` node:
288
+
289
+ ```ts
290
+ const config = {
291
+ guards: {
292
+ 'has-colony': ['truthy', '$data.colonyId'],
293
+ 'same-colony': ['eq', '$input.colonyId', '$context.colonyId'],
294
+ },
295
+ strategies: {
296
+ 'colony.join': {
297
+ fn: 'colony.join',
298
+ when: ['and', ['guard', 'has-colony'], ['not', ['guard', 'same-colony']]],
299
+ },
300
+ },
301
+ }
302
+ ```
303
+
304
+ A guard is a plain `ConditionExpression` and may itself reference other guards. References are expanded once when the config is loaded (`loadConfig`), before the runtime evaluates anything, so the runtime never sees a `['guard', ...]` node. Guards are resolved recursively through `and`/`or`/`not`; a reference to an undefined guard is a `GUARD_NOT_FOUND` validation error, and mutually referencing guards produce `GUARD_CYCLE`. A guard value must be a condition expression, not a `$path` string.
305
+
306
+ Guards exist so a truth criterion can live in one place instead of being duplicated across strategies; they are evaluated data, not registered code (unlike `registerCondition`, which registers an operator function).
307
+
264
308
  ## Validation
265
309
 
266
310
  `validateConfig` validates:
@@ -270,7 +314,8 @@ Runtime path get/set is implemented directly through `objwalk`.
270
314
  - missing strategies in `then`, `catch`, and `entrypoints`;
271
315
  - invalid modes;
272
316
  - invalid path references;
273
- - cycles without a terminal step.
317
+ - cycles without a terminal step;
318
+ - guard references (`GUARD_NOT_FOUND`, `GUARD_CYCLE`, `GUARD_INVALID`).
274
319
 
275
320
  ## Trace
276
321
 
@@ -308,11 +353,14 @@ unsubscribe()
308
353
 
309
354
  ```ts
310
355
  type Bus<TEvents extends object = Record<string, unknown>> = {
311
- on<TEvent extends keyof TEvents>(
312
- event: TEvent,
313
- handler: (event: BusEvent<TEvents[TEvent]>) => void
314
- ): () => void
315
- off<TEvent extends keyof TEvents>(event: TEvent, handler?: (event: BusEvent<TEvents[TEvent]>) => void): void
356
+ on: {
357
+ <TEvent extends keyof TEvents>(event: TEvent, handler: (event: BusEvent<TEvents[TEvent]>) => void): () => void
358
+ (event: EventPattern, handler: (event: BusEvent<unknown>) => void): () => void
359
+ }
360
+ off: {
361
+ <TEvent extends keyof TEvents>(event: TEvent, handler?: (event: BusEvent<TEvents[TEvent]>) => void): void
362
+ (event: EventPattern, handler?: (event: BusEvent<unknown>) => void): void
363
+ }
316
364
  emit<TEvent extends keyof TEvents>(
317
365
  topic: TEvent,
318
366
  payload: TEvents[TEvent],
@@ -320,6 +368,8 @@ type Bus<TEvents extends object = Record<string, unknown>> = {
320
368
  ): BusEvent<TEvents[TEvent]>
321
369
  }
322
370
 
371
+ type EventPattern = `${string}*${string}`
372
+
323
373
  type BusEvent<TPayload> = {
324
374
  id: string
325
375
  topic: string
@@ -332,6 +382,18 @@ type BusEvent<TPayload> = {
332
382
 
333
383
  `emit` creates an envelope and serializes the payload once before subscribers run. Event identifiers are opaque 12-character alphanumeric runtime IDs for correlation and echo suppression. They are not cryptographically secure and must not be used for access tokens, signatures, public links, or any security-sensitive purpose. `on` returns an unsubscribe function. `off(event, handler)` removes one handler, while `off(event)` clears the channel. An error in one subscriber does not block the others; `createPubSub({ onError })` receives the error and original event. On serialization failure, the bus delivers `{ error }` as `parsed` and the error body as `serialized`, then calls `onError` with the original cause.
334
384
 
385
+ ### Wildcard subscriptions
386
+
387
+ A topic may be subscribed by pattern, using `*` to match exactly one dot-delimited segment. A wildcard does not cross a `.` boundary.
388
+
389
+ ```ts
390
+ bus.on('hub.user.*', ({ parsed }) => {}) // hub.user.created, hub.user.deleted
391
+ bus.on('hub.*.created', ({ parsed }) => {}) // hub.user.created, hub.team.created
392
+ bus.on('hub.*.export', ({ parsed }) => {}) // NOT hub.user.audit.export (wildcard spans one segment)
393
+ ```
394
+
395
+ Exact-name subscriptions stay O(1); wildcard patterns are matched separately, so registrations without `*` pay no matching cost. A wildcard handler receives `parsed` as `unknown` — narrow it before use. Wildcards work in `bus.on`/`bus.off`.
396
+
335
397
  ## Flow
336
398
 
337
399
  `createFlow` combines configuration, actions, conditions, a context provider, and event bindings. It creates a runner (accessible via `flow.runner`) and supports the `start`/`stop` lifecycle.
@@ -415,24 +477,30 @@ A DOM binding key uses the `[dom] <css-selector>:<event>` format. Slapflow insta
415
477
 
416
478
  `defaultInput` has type `{ type, value?, dataset, form? }`. `dataset` contains all `data-*` attributes from the matching element as camelCase keys. `form` is built from the nearest `<form>`; repeated form entries become arrays and `File` remains `File`. For `submit`, `preventDefault` defaults to `true`; for other events, it and `stopPropagation` default to `false`.
417
479
 
418
- ### WebSocket Bridge
480
+ ### WebSocket Client
419
481
 
420
- `createWS` connects a bus to a WebSocket-like transport. The bridge accepts `createSocket`, so it works with browser WebSocket and a server adapter alike.
482
+ `createWebSocket` opens a native `WebSocket` to `url` and proxies every socket event into the bus. No wire format is assumed each event is dispatched with a fixed topic and an envelope whose `parsed` holds the raw payload.
421
483
 
422
484
  ```ts
423
- const ws = createWS({
485
+ const socket = createWebSocket({
486
+ url,
424
487
  bus,
425
- createSocket: () => new WebSocket(url),
426
- inboundTopics: ['order.created'],
427
- outboundTopics: ['slapflow.run.finished'],
428
- origin: 'worker',
429
- retry: { initialDelay: 500, maxDelay: 10_000, multiplier: 2, jitter: true, maxAttempts: 5 },
488
+ origin: 'client',
430
489
  })
431
490
 
432
- ws.start()
491
+ socket.start()
433
492
  ```
434
493
 
435
- Inbound topics pass an explicit allowlist. The bridge parses a JSON envelope and calls `bus.dispatch(event)`, preserving `id`, `occurredAt`, `origin`, `parsed`, and `serialized`; it remembers accepted inbound IDs and does not send them back outbound. Outbound topics send the complete event envelope as JSON, so the remote bridge can dispatch it without recreating its identity. `maxAttempts` limits reconnects; omitting it retries indefinitely. `start`, `stop`, `reconnect`, and `status` manage the transport lifecycle. Diagnostics: `slapflow.ws.connecting`, `slapflow.ws.connected`, `slapflow.ws.disconnected`, `slapflow.ws.retrying`, and `slapflow.ws.message.rejected`.
494
+ ```ts
495
+ bus.on('message', ({ parsed }) => {}) // parsed = the raw message (JSON-decoded when possible)
496
+ bus.on('open', ({ parsed }) => {}) // { url }
497
+ bus.on('close', ({ parsed }) => {}) // { code, reason }
498
+ bus.on('error', ({ parsed }) => {}) // { error }
499
+ ```
500
+
501
+ Socket events are forwarded with the topic `open`, `message`, `close`, or `error`. `message` payloads are JSON-decoded into `parsed` when valid, otherwise kept as the raw string. Filtering topics is the consumer's responsibility — nothing is allow-listed or rejected inside the client. `reconnect` accepts `initialDelay`, `maxDelay`, `multiplier`, `jitter`, and `maxAttempts`; omitting `maxAttempts` retries indefinitely. `start`, `stop`, `reconnect`, and `status` manage the lifecycle; status is one of `idle`, `connecting`, `connected`, `reconnecting`, or `stopped`.
502
+
503
+ `createWS` is deprecated and will be removed; migrate to `createWebSocket`.
436
504
 
437
505
  ## Safety Limits
438
506