okengine 0.19.2 → 0.19.3

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 (35) hide show
  1. package/package.json +1 -1
  2. package/site/content/docs/elements/clock/index.mdx +144 -3
  3. package/site/content/docs/elements/flow/consumers.mdx +79 -59
  4. package/site/content/docs/elements/flow/index.mdx +2 -2
  5. package/site/content/docs/elements/flow/routing.mdx +12 -5
  6. package/site/content/docs/elements/signal/broadcast.mdx +9 -13
  7. package/site/content/docs/elements/signal/index.mdx +236 -17
  8. package/site/content/docs/elements/signal/live.mdx +5 -13
  9. package/site/content/docs/elements/signal/once.mdx +83 -28
  10. package/site/content/docs/reference/errors.mdx +34 -27
  11. package/src/compiler/extract.test.ts +47 -0
  12. package/src/compiler/extract.ts +28 -0
  13. package/src/console/ui-next/dist/assets/{access-page-DceEWH9u.js → access-page-BpugjHHY.js} +1 -1
  14. package/src/console/ui-next/dist/assets/{agent-disclosure-tC9s2VFd.js → agent-disclosure-qnsmtJhf.js} +1 -1
  15. package/src/console/ui-next/dist/assets/{cache-glyph-C-naNQSR.js → cache-glyph-BcpWUM98.js} +1 -1
  16. package/src/console/ui-next/dist/assets/{call-pii-button-DnZ_MlDn.js → call-pii-button-Bz5xiNmR.js} +1 -1
  17. package/src/console/ui-next/dist/assets/{collapsible-RekgR6Qz.js → collapsible-Cuxn2WH8.js} +1 -1
  18. package/src/console/ui-next/dist/assets/{duration-tone-DugtWBS0.js → duration-tone-Bjnl3EaM.js} +1 -1
  19. package/src/console/ui-next/dist/assets/{flows-page-DVmn1ZuQ.js → flows-page-DVujp-T2.js} +1 -1
  20. package/src/console/ui-next/dist/assets/{highlighted-json-CvBGaiSD.js → highlighted-json-Bql0qlqW.js} +1 -1
  21. package/src/console/ui-next/dist/assets/{http-method-D7_OXbdC.js → http-method-BljvrfRg.js} +1 -1
  22. package/src/console/ui-next/dist/assets/{index-DH0K2f6N.js → index-D1vE656k.js} +3 -3
  23. package/src/console/ui-next/dist/assets/{observability-page-qJzF2nSN.js → observability-page-Bdi0bLJl.js} +1 -1
  24. package/src/console/ui-next/dist/assets/{replica-lag-Vqk0pUBA.js → replica-lag-CpkPfITG.js} +1 -1
  25. package/src/console/ui-next/dist/assets/{request-meta-BtShi4sG.js → request-meta-C4ZVNFVt.js} +1 -1
  26. package/src/console/ui-next/dist/assets/{store-page-CMsYH_vH.js → store-page-DAHtnesC.js} +1 -1
  27. package/src/console/ui-next/dist/assets/{trace-detail-sheet-ycFB2uua.js → trace-detail-sheet-B2c9QRBw.js} +1 -1
  28. package/src/console/ui-next/dist/assets/{tree-expand-toggle-iG1jcWgU.js → tree-expand-toggle-BP4tRC98.js} +1 -1
  29. package/src/console/ui-next/dist/assets/{units-page-Bavchke4.js → units-page-Oi6--n09.js} +1 -1
  30. package/src/console/ui-next/dist/assets/{vault-page-DxUFhiZI.js → vault-page-Dt-cPkiU.js} +1 -1
  31. package/src/console/ui-next/dist/index.html +1 -1
  32. package/src/kernel/app.ts +64 -0
  33. package/src/kernel/errors-once-signal.ts +25 -0
  34. package/src/kernel/errors.registry.test.ts +8 -0
  35. package/src/kernel/once-signal.test.ts +71 -0
@@ -19,10 +19,12 @@ For developers wiring async work on okengine — declare the physics, emit with
19
19
 
20
20
  ## Smallest Example
21
21
 
22
- <Steps>
22
+ <Callout title="One handle, three independent uses">
23
+ `orderPlaced` is a shared const. Declare it, bind a worker, and emit from a producer — different
24
+ files, different people, any order. None of these is a prerequisite step for the others.
25
+ </Callout>
23
26
 
24
- <Step>
25
- ### Declare the signal
27
+ ### Declare
26
28
 
27
29
  ```typescript title="src/signals/orders.ts"
28
30
  import { signal } from "okengine";
@@ -39,10 +41,7 @@ export const orderPlaced = signal.once("orders.placed", {
39
41
  });
40
42
  ```
41
43
 
42
- </Step>
43
-
44
- <Step>
45
- ### Bind a worker and emit
44
+ ### Bind a worker
46
45
 
47
46
  ```typescript title="src/flows/orders/fulfill.ts"
48
47
  import { on, flow } from "okengine";
@@ -58,8 +57,10 @@ export const fulfill = on(
58
57
  );
59
58
  ```
60
59
 
60
+ ### Emit
61
+
61
62
  ```typescript
62
- // Inside any Flow:
63
+ // Inside any Flow — need not live next to the worker:
63
64
  await fx.emit(orderPlaced, {
64
65
  orderId: "ord_99",
65
66
  amount: 150,
@@ -70,10 +71,6 @@ await fx.emit(orderPlaced, {
70
71
  The compiler records `emits: ["orders.placed"]` on the producer. The HTTP request does not wait
71
72
  for the worker.
72
73
 
73
- </Step>
74
-
75
- </Steps>
76
-
77
74
  <Callout title="Live is not a worker">
78
75
  `signal.live` is an HTTP SSE tape. Expose it with
79
76
  [`http.live`](/docs/elements/flow/http#live-streams) — do not bind `on(liveSignal, flow)` as a
@@ -84,14 +81,163 @@ for the worker.
84
81
 
85
82
  | Style | When |
86
83
  | --------------------------------------------- | --------------------------------------------------------------------------- |
87
- | `on(signal.once("name", opts), flow({ do }))` | Self-contained — nothing else emits to this Signal |
84
+ | `on(signal.once("name", opts), flow({ do }))` | Self-contained — nothing else needs the Signal handle |
88
85
  | `export const x = signal.once<Payload>(…)` | Another file needs `fx.emit(x, payload)` with compile-time payload checking |
89
86
 
87
+ Both styles stamp the same Manifest `flow.trigger`. The choice is where the
88
+ declaration lives, not two runtimes.
89
+
90
90
  A string `fx.emit("name", payload)` still runs (runtime `schema` still applies) but does not
91
- type-check the payload.
91
+ type-check. What the Flow is called is a separate choice — [Flow name](#flow-name).
92
+
93
+ <Tabs items={["Inline", "Named"]}>
94
+
95
+ <Tab value="Inline">
96
+
97
+ One file — declare and bind together. Producers emit with the string name:
98
+
99
+ ```typescript title="src/flows/hooks/inbound.ts"
100
+ import { on, flow, signal } from "okengine";
101
+ import { z } from "zod";
102
+
103
+ export const ingestWebhook = on(
104
+ signal.once("hooks.inbound", {
105
+ schema: z.object({ id: z.string() }),
106
+ retries: 3,
107
+ deadLetter: true,
108
+ }),
109
+ flow({
110
+ do: async ({ id }, fx) => {
111
+ await fx.call(persistHook, { id });
112
+ },
113
+ }),
114
+ );
115
+ ```
92
116
 
93
- Nameless `flow({ do })` inherits the Signal name; file-tree `unit.export` and explicit
94
- `flow("…")` win. Two nameless inheritances of the same name fail **OKE1070**.
117
+ ```typescript
118
+ await fx.emit("hooks.inbound", { id: "h_1" });
119
+ ```
120
+
121
+ </Tab>
122
+
123
+ <Tab value="Named">
124
+
125
+ Export the handle when another file must `fx.emit` with compile-time payload checking:
126
+
127
+ ```typescript title="src/signals/orders.ts"
128
+ import { signal } from "okengine";
129
+ import { z } from "zod";
130
+
131
+ export const orderPlaced = signal.once("orders.placed", {
132
+ schema: z.object({ orderId: z.string() }),
133
+ });
134
+ ```
135
+
136
+ ```typescript title="src/flows/orders/fulfill.ts"
137
+ import { on, flow } from "okengine";
138
+ import { orderPlaced } from "@/signals/orders";
139
+
140
+ export const fulfill = on(
141
+ orderPlaced,
142
+ flow("orders.fulfill", {
143
+ do: async ({ orderId }, fx) => {
144
+ await fx.call(chargeAndShip, { orderId });
145
+ },
146
+ }),
147
+ );
148
+ ```
149
+
150
+ ```typescript
151
+ await fx.emit(orderPlaced, { orderId: "ord_99" });
152
+ ```
153
+
154
+ </Tab>
155
+
156
+ </Tabs>
157
+
158
+ ## Flow name
159
+
160
+ | Style | When |
161
+ | ----------------------------- | ----------------------------------------------------------------------------- |
162
+ | `flow({ do })` | No unit folder — Flow name is the Signal name (`hooks.inbound`) |
163
+ | `flow("orders.fulfill")` | Manifest / `fx.call` name must differ from the Signal |
164
+ | Tree `export const onCreated` | `src/flows/notes/on-created.ts` stamps `notes.onCreated` — overwrites inherit |
165
+
166
+ Explicit `flow("…")` and the file tree overwrite inherit. HTTP does not inherit a
167
+ name from the path — nameless HTTP stays for the tree or fails **OKE1045**. Two
168
+ Flows that land on the same name fail **OKE1070** (`Flow "{flow}" is defined twice.`).
169
+
170
+ <Tabs items={["Inherit", "Explicit", "Tree"]}>
171
+
172
+ <Tab value="Inherit">
173
+
174
+ A file directly in `src/flows/` (no unit folder) has nothing to stamp. The Flow
175
+ is named `hooks.inbound` — same as the Signal:
176
+
177
+ ```typescript title="src/flows/hooks.ts"
178
+ import { on, flow, signal } from "okengine";
179
+ import { z } from "zod";
180
+
181
+ export const ingestWebhook = on(
182
+ signal.once("hooks.inbound", {
183
+ schema: z.object({ id: z.string() }),
184
+ retries: 3,
185
+ deadLetter: true,
186
+ }),
187
+ flow({
188
+ do: async ({ id }, fx) => {
189
+ await fx.call(persistHook, { id });
190
+ },
191
+ }),
192
+ );
193
+ ```
194
+
195
+ </Tab>
196
+
197
+ <Tab value="Explicit">
198
+
199
+ The Signal stays `orders.placed`. The Flow is `orders.fulfill` — that is the
200
+ Manifest / `fx.call` name:
201
+
202
+ ```typescript title="src/flows/fulfill.ts"
203
+ import { on, flow } from "okengine";
204
+ import { orderPlaced } from "@/signals/orders";
205
+
206
+ export const fulfill = on(
207
+ orderPlaced,
208
+ flow("orders.fulfill", {
209
+ do: async ({ orderId }, fx) => {
210
+ await fx.call(chargeAndShip, { orderId });
211
+ },
212
+ }),
213
+ );
214
+ ```
215
+
216
+ </Tab>
217
+
218
+ <Tab value="Tree">
219
+
220
+ Unit folder + `export const` stamps `unit.export`. The Flow is `notes.onCreated`,
221
+ not `note-created`:
222
+
223
+ ```typescript title="src/flows/notes/on-created.ts"
224
+ import { on, flow } from "okengine";
225
+ import { noteCreatedMail } from "@/core";
226
+ import { noteCreated } from "./signals";
227
+
228
+ export const onCreated = on(
229
+ noteCreated,
230
+ flow({
231
+ do: async ({ id }, fx) => {
232
+ await fx.send(noteCreatedMail, { to: "you@localhost", data: { id } });
233
+ },
234
+ }),
235
+ );
236
+ ```
237
+
238
+ </Tab>
239
+
240
+ </Tabs>
95
241
 
96
242
  ## Progressive Patterns
97
243
 
@@ -221,6 +367,66 @@ Fix: add `on(signal, …)` or mark `{ optional: true }`.
221
367
  | `signal.broadcast` | Pub/sub | Every active subscriber | None (miss if offline) | Cache invalidation, fan-out |
222
368
  | `signal.live` | SSE tape | HTTP clients via `http.live` | `Last-Event-ID` resume | Status feeds, progress |
223
369
 
370
+ ## Competing consumers (once vs broadcast)
371
+
372
+ This is the most common mix-up. `signal.once` is a work queue: **exactly one** worker claims each
373
+ message. It is not fan-out.
374
+
375
+ Three differently-named Flows bound to the same `once` signal, then one emit:
376
+
377
+ ```typescript title="src/flows/orders/side-effects.ts"
378
+ import { on, flow } from "okengine";
379
+ import { orderPlaced } from "@/signals/orders";
380
+
381
+ export const charge = on(
382
+ orderPlaced,
383
+ flow("orders.charge", {
384
+ do: async ({ orderId }, fx) => {
385
+ await fx.call(chargeOrder, { orderId });
386
+ },
387
+ }),
388
+ );
389
+ export const ship = on(
390
+ orderPlaced,
391
+ flow("orders.ship", {
392
+ do: async ({ orderId }, fx) => {
393
+ await fx.call(shipOrder, { orderId });
394
+ },
395
+ }),
396
+ );
397
+ export const notify = on(
398
+ orderPlaced,
399
+ flow("orders.notify", {
400
+ do: async ({ orderId }, fx) => {
401
+ await fx.call(notifyOrder, { orderId });
402
+ },
403
+ }),
404
+ );
405
+ ```
406
+
407
+ ```typescript
408
+ await fx.emit(orderPlaced, { orderId: "ord_99", amount: 150, userId: "usr_1" });
409
+ ```
410
+
411
+ | Expectation | Real result |
412
+ | ----------------------------------- | -------------------------------------------------------------------- |
413
+ | All three Flows run | **No.** Exactly one of the three runs |
414
+ | Always `orders.charge` (first `on`) | **No.** The winner is whichever claim lands first — not a fixed Flow |
415
+ | A sticky assignment to one Flow | **No.** There is no owner — only an exclusive claim per message |
416
+
417
+ **Consequence:** if every bound Flow should independently receive its own copy, that is
418
+ [`signal.broadcast`](/docs/elements/signal/broadcast) — switch the declaration. That is the correct
419
+ fix for this exact mistake.
420
+
421
+ Two or more **different** Flow definitions on the same `once` signal fail **OKE1071**.
422
+
423
+ Cause: `Once signal "{signal}" is bound to more than one Flow ({flows}).`
424
+
425
+ Load-balancing competing consumers is the **same** Flow on many process replicas — still one
426
+ `on()` in source.
427
+
428
+ Fix: `Use signal.broadcast if each flow should independently receive this event, or bind only one flow if these should compete for the same work.`
429
+
224
430
  ## The Capabilities of Signal
225
431
 
226
432
  <Cards>
@@ -320,6 +526,18 @@ export default defineConfig({
320
526
  on the Signal. The consumer never ran.
321
527
  </Accordion>
322
528
 
529
+ <Accordion title="OKE1070 — flow name defined twice">
530
+ Cause: `Flow "{flow}" is defined twice.` Two nameless consumers inherited the same Signal name, or
531
+ two explicit `flow("…")` calls collide. Give at least one a distinct `flow("…")` or tree export —
532
+ [Flow name](#flow-name).
533
+ </Accordion>
534
+
535
+ <Accordion title="OKE1071 — once signal bound to more than one Flow">
536
+ Cause: `Once signal "{signal}" is bound to more than one Flow ({flows}).` Use `signal.broadcast`
537
+ if each Flow should independently receive this event, or bind only one Flow. See [Competing
538
+ consumers](#competing-consumers-once-vs-broadcast).
539
+ </Accordion>
540
+
323
541
  <Accordion title='oke boot: signal driver "postgres" / "nats"'>
324
542
  Those ids are reserved but not bound for production yet. Use `"memory"` or `"redis"`, or inject a
325
543
  custom `elements.signal` runtime.
@@ -339,10 +557,11 @@ export default defineConfig({
339
557
  - [Broadcast](/docs/elements/signal/broadcast) — ephemeral fan-out
340
558
  - [Live](/docs/elements/signal/live) — SSE tapes and retention
341
559
  - [Consumers](/docs/elements/flow/consumers) — `on(signal)` workers next to Clock / CDC
560
+ - [Routing](/docs/elements/flow/routing#names) — tree `unit.export` vs inherit
342
561
  - [HTTP · Live Streams](/docs/elements/flow/http#live-streams) — `http.live` exposure
343
562
  - [fx](/docs/reference/fx) — `fx.emit`, `fx.deadLetters`, `fx.live`
344
563
  - [Client](/docs/client/live) — `api.live` for browsers
345
- - [Errors](/docs/reference/errors) — OKE1240 · OKE1250 · OKE1210
564
+ - [Errors](/docs/reference/errors) — OKE1070 · OKE1071 · OKE1240 · OKE1250 · OKE1210
346
565
 
347
566
  ## Next
348
567
 
@@ -21,10 +21,12 @@ from the typed client.
21
21
 
22
22
  ## Smallest Example
23
23
 
24
- <Steps>
24
+ <Callout title="One handle, three independent uses">
25
+ `orderStatus` is a shared const. Declare it, expose SSE, and emit — different files, any order.
26
+ The firehose is not "step 2"; emit is not "step 3".
27
+ </Callout>
25
28
 
26
- <Step>
27
- ### Declare the live signal
29
+ ### Declare
28
30
 
29
31
  ```typescript title="src/signals/orders.ts"
30
32
  import { signal } from "okengine";
@@ -39,9 +41,6 @@ export const orderStatus = signal.live("order-status", {
39
41
  });
40
42
  ```
41
43
 
42
- </Step>
43
-
44
- <Step>
45
44
  ### Expose SSE
46
45
 
47
46
  ```typescript title="src/flows/orders/firehose.ts"
@@ -52,9 +51,6 @@ import { orderStatus } from "@/signals/orders";
52
51
  export const firehose = on(http.live(orderStatus).gate(member));
53
52
  ```
54
53
 
55
- </Step>
56
-
57
- <Step>
58
54
  ### Emit and subscribe
59
55
 
60
56
  ```typescript
@@ -71,10 +67,6 @@ curl -N http://localhost:6530/_oke/live/order-status \
71
67
  Response `Content-Type` is `text/event-stream`. Frames are JSON `data:` lines
72
68
  (optional `id:` for resume), then `data: [DONE]`.
73
69
 
74
- </Step>
75
-
76
- </Steps>
77
-
78
70
  <Callout title="Pathless firehose">
79
71
  `on(http.live(signal))` always mounts `GET /_oke/live/{name}` — there is no pathless file-tree
80
72
  stamp for live. Custom paths use `http.get(path).live(signal)`. See [Exposure](#exposure).
@@ -21,10 +21,12 @@ For developers shipping jobs on okengine — declare the Signal, bind `on(signal
21
21
 
22
22
  ## Smallest Example
23
23
 
24
- <Steps>
24
+ <Callout title="One handle, three independent uses">
25
+ `emailTask` is a shared const. Declare it, bind a worker, and emit — different files, any order.
26
+ Binding is not "step 2" after declare; emit is not "step 3".
27
+ </Callout>
25
28
 
26
- <Step>
27
- ### Define the once signal
29
+ ### Declare
28
30
 
29
31
  ```typescript title="src/signals/email.ts"
30
32
  import { signal } from "okengine";
@@ -37,10 +39,7 @@ export const emailTask = signal.once("tasks.email", {
37
39
  });
38
40
  ```
39
41
 
40
- </Step>
41
-
42
- <Step>
43
- ### Attach a worker and emit
42
+ ### Bind a worker
44
43
 
45
44
  ```typescript title="src/flows/workers/email.ts"
46
45
  import { on, flow } from "okengine";
@@ -57,6 +56,8 @@ export const processEmail = on(
57
56
  );
58
57
  ```
59
58
 
59
+ ### Emit
60
+
60
61
  ```typescript
61
62
  await fx.emit(emailTask, { to: "alice@example.com", body: "Welcome" });
62
63
  ```
@@ -64,14 +65,67 @@ await fx.emit(emailTask, { to: "alice@example.com", body: "Welcome" });
64
65
  The emit resolves when the outbox commits. The worker runs asynchronously — the producer does not
65
66
  wait for `fx.send` to finish.
66
67
 
67
- </Step>
68
+ ## Competing consumers (once vs broadcast)
68
69
 
69
- </Steps>
70
+ This is the most common mix-up. `signal.once` is a competing-consumer work queue: **exactly one**
71
+ worker claims each message. It is not fan-out.
70
72
 
71
- <Callout title="Competing, not fan-out">
72
- Two Flows bound to the same `once` Signal compete — exactly one claims each message. For every
73
- subscriber to run, use [`broadcast`](/docs/elements/signal/broadcast).
74
- </Callout>
73
+ Three differently-named Flows bound to the same `once` signal:
74
+
75
+ ```typescript title="src/flows/orders/side-effects.ts"
76
+ import { on, flow } from "okengine";
77
+ import { orderPlaced } from "@/signals/orders";
78
+
79
+ export const charge = on(
80
+ orderPlaced,
81
+ flow("orders.charge", {
82
+ do: async ({ orderId }, fx) => {
83
+ await fx.call(chargeOrder, { orderId });
84
+ },
85
+ }),
86
+ );
87
+
88
+ export const ship = on(
89
+ orderPlaced,
90
+ flow("orders.ship", {
91
+ do: async ({ orderId }, fx) => {
92
+ await fx.call(shipOrder, { orderId });
93
+ },
94
+ }),
95
+ );
96
+
97
+ export const notify = on(
98
+ orderPlaced,
99
+ flow("orders.notify", {
100
+ do: async ({ orderId }, fx) => {
101
+ await fx.call(notifyOrder, { orderId });
102
+ },
103
+ }),
104
+ );
105
+ ```
106
+
107
+ ```typescript
108
+ await fx.emit(orderPlaced, { orderId: "ord_99", amount: 150, userId: "usr_1" });
109
+ ```
110
+
111
+ | Expectation | Real result |
112
+ | ----------------------------------- | -------------------------------------------------------------------- |
113
+ | All three Flows run | **No.** Exactly one of the three runs |
114
+ | Always `orders.charge` (first `on`) | **No.** The winner is whichever claim lands first — not a fixed Flow |
115
+ | A sticky assignment to one Flow | **No.** There is no owner — only an exclusive claim per message |
116
+
117
+ **Consequence:** if every bound Flow should independently receive its own copy, use
118
+ [`signal.broadcast`](/docs/elements/signal/broadcast). That is the correct fix for this exact
119
+ mistake.
120
+
121
+ Two or more **different** Flow definitions on the same `once` signal fail **OKE1071**.
122
+
123
+ Cause: `Once signal "{signal}" is bound to more than one Flow ({flows}).`
124
+
125
+ Fix: `Use signal.broadcast if each flow should independently receive this event, or bind only one flow if these should compete for the same work.`
126
+
127
+ Load-balancing competing consumers is the **same** Flow on many process replicas — still one
128
+ `on()` in source. That case is not a second Flow definition.
75
129
 
76
130
  ## Progressive Patterns
77
131
 
@@ -251,12 +305,11 @@ The compiler records `emits: ["tasks.email"]` on producers that call `fx.emit(em
251
305
 
252
306
  <Tab value="Competing">
253
307
 
254
- Two Flows on the same `once` Signal share the pool — exactly one claims each message:
255
-
256
- ```typescript title="src/flows/orders/fulfill-a.ts"
257
- import { on, flow } from "okengine";
258
- import { orderPlaced } from "@/signals/orders";
308
+ Two different Flows on the same `once` Signal is the once-vs-broadcast mix-up — see
309
+ [Competing consumers](#competing-consumers-once-vs-broadcast). The bus would let only one claim
310
+ each message (race winner, not both, not a fixed Flow). The Manifest now fails **OKE1071**:
259
311
 
312
+ ```typescript
260
313
  export const fulfillA = on(
261
314
  orderPlaced,
262
315
  flow("orders.fulfillA", {
@@ -265,11 +318,6 @@ export const fulfillA = on(
265
318
  },
266
319
  }),
267
320
  );
268
- ```
269
-
270
- ```typescript title="src/flows/orders/fulfill-b.ts"
271
- import { on, flow } from "okengine";
272
- import { orderPlaced } from "@/signals/orders";
273
321
 
274
322
  export const fulfillB = on(
275
323
  orderPlaced,
@@ -281,7 +329,8 @@ export const fulfillB = on(
281
329
  );
282
330
  ```
283
331
 
284
- **Consequence:** this is not fan-out. For every Flow to run, use `signal.broadcast`.
332
+ **Consequence:** bind one Flow (replicas of that process still compete for claims), or switch the
333
+ declaration to [`signal.broadcast`](/docs/elements/signal/broadcast) so every Flow gets a copy.
285
334
 
286
335
  </Tab>
287
336
 
@@ -531,9 +580,15 @@ See [Durable Workflows](/docs/elements/flow/workflows).
531
580
  </Accordion>
532
581
 
533
582
  <Accordion title="Both of two Flows ran on one once message">
534
- That is broadcast physics, not once. Confirm both files import the same `signal.once` handle —
535
- competing workers share one claim. If you need fan-out, switch the declaration to
536
- `signal.broadcast`.
583
+ That is broadcast physics, not once. Two different Flow definitions on one `once` signal now fail
584
+ **OKE1071** at boot. If you need every Flow to run, switch the declaration to
585
+ [`signal.broadcast`](/docs/elements/signal/broadcast).
586
+ </Accordion>
587
+
588
+ <Accordion title="OKE1071 — once signal bound to more than one Flow">
589
+ Cause: `Once signal "{signal}" is bound to more than one Flow ({flows}).` Use `signal.broadcast`
590
+ if each Flow should independently receive this event, or bind only one Flow. Replicas of one Flow
591
+ are still one `on()` in source.
537
592
  </Accordion>
538
593
 
539
594
  <Accordion title="Messages stuck inflight">
@@ -574,7 +629,7 @@ See [Durable Workflows](/docs/elements/flow/workflows).
574
629
  - [Consumers](/docs/elements/flow/consumers) — `on(signal)` next to Clock / CDC
575
630
  - [Workflows](/docs/elements/flow/workflows) — `durable` + `fx.step` for idempotent side effects
576
631
  - [fx](/docs/reference/fx) — `fx.emit`, `fx.deadLetters`
577
- - [Errors](/docs/reference/errors) — OKE1240 · OKE1250 · OKE1001
632
+ - [Errors](/docs/reference/errors) — OKE1071 · OKE1240 · OKE1250 · OKE1001
578
633
 
579
634
  ## Next
580
635
 
@@ -78,33 +78,34 @@ string. Custom app codes stay message-less until registered. Full catalogs:
78
78
 
79
79
  ## OKE numeric codes
80
80
 
81
- | Code | Name | Cause | Fix |
82
- | ------ | -------------------- | ------------------------------------------------------ | --------------------------------------------------------- |
83
- | `1001` | undeclared read | Flow reads a resource not in `effects.reads` | Add it to the flow's `effects.reads` |
84
- | `1002` | undeclared write | Flow writes a resource not in `effects.writes` | Add it to the flow's `effects.writes` |
85
- | `1003` | undeclared emit | Flow emits a signal not in `effects.emits` | Add it to the flow's `effects.emits` |
86
- | `1004` | undeclared send | Flow sends a template not in `effects.sends` | Add it to the flow's `effects.sends` |
87
- | `1005` | undeclared ask | Flow asks a prompt not in `effects.asks` | Add it to the flow's `effects.asks` |
88
- | `1006` | undeclared secret | Flow reads a secret not in `effects.secrets` | Add it to the flow's `effects.secrets` |
89
- | `1007` | undeclared call | Flow calls a flow not in `effects.calls` | Add it to the flow's `effects.calls` |
90
- | `1008` | undeclared fetch | Flow fetches a host not in `effects.fetches` | Add the hostname to the flow's `effects.fetches` |
91
- | `1009` | undeclared embed | Flow embeds with a model not in `effects.embeds` | Add it to the flow's `effects.embeds` |
92
- | `1020` | no effects declared | Flow has no `effects` and no Manifest to infer from | Run `oke build` / `oke dev`, or declare effects |
93
- | `1030` | adopt barrel stale | A `src/flows/<unit>` folder was not adopted | Run `oke dev` or `oke build` to regenerate `generated.ts` |
94
- | `1040` | HTTP path unresolved | Pathless `http.get()` never received a file-tree stamp | Import `@/flows/generated`, or pass `http.get("/…")` |
95
- | `1041` | HTTP route clash | Two HTTP flows share the same method + path | Give each flow a unique method + path |
96
- | `1045` | HTTP flow unnamed | Adopted HTTP flow still has no `unit.export` | Export from `flows/<unit>/` or pass a named `flow` |
97
- | `1050` | live exposure dup | Same signal, gates, and match on two GET routes | Change the gate or path-param filter |
98
- | `1060` | MCP tool duplicate | Two MCP tool bindings share the same tool name | Give each MCP tool exposure a unique name |
99
- | `1070` | flow name duplicate | Two Flows share the same Manifest / `fx.call` name | Give at least one an explicit `flow("…")` or tree export |
100
- | `1110` | schema missing | Domain table absent in `prod` — no auto-DDL | Run `oke db migrate` against this environment |
101
- | `1210` | live resume gap | `Last-Event-ID` is not on the retained tape | Reconnect without the cursor; remaining tape replays |
102
- | `1240` | orphan emit | Emit with zero subscribers and `optional` false | Add `on(signal, …)` or declare `optional: true` |
103
- | `1250` | signal schema | Emit payload failed the signal's `schema` | Pass a payload that matches `schema`, or remove it |
104
- | `1605` | channel schema | Send payload failed the template's `schema` | Fix template `data` payload or the template `schema` |
105
- | `1810` | tenant required | Tenant-scoped op with no `fx.tenant.id` | `switchTenant`, signed `tid`, or tenant header |
106
- | `1820` | tenant not member | Client-supplied tenant id is not a membership | Pick from `listTenants` or add the user as a member |
107
- | `1830` | tenant unknown scope | Tenant role used an invented or `console:*` scope | Use a declared application scope |
81
+ | Code | Name | Cause | Fix |
82
+ | ------ | ---------------------- | ------------------------------------------------------ | --------------------------------------------------------- |
83
+ | `1001` | undeclared read | Flow reads a resource not in `effects.reads` | Add it to the flow's `effects.reads` |
84
+ | `1002` | undeclared write | Flow writes a resource not in `effects.writes` | Add it to the flow's `effects.writes` |
85
+ | `1003` | undeclared emit | Flow emits a signal not in `effects.emits` | Add it to the flow's `effects.emits` |
86
+ | `1004` | undeclared send | Flow sends a template not in `effects.sends` | Add it to the flow's `effects.sends` |
87
+ | `1005` | undeclared ask | Flow asks a prompt not in `effects.asks` | Add it to the flow's `effects.asks` |
88
+ | `1006` | undeclared secret | Flow reads a secret not in `effects.secrets` | Add it to the flow's `effects.secrets` |
89
+ | `1007` | undeclared call | Flow calls a flow not in `effects.calls` | Add it to the flow's `effects.calls` |
90
+ | `1008` | undeclared fetch | Flow fetches a host not in `effects.fetches` | Add the hostname to the flow's `effects.fetches` |
91
+ | `1009` | undeclared embed | Flow embeds with a model not in `effects.embeds` | Add it to the flow's `effects.embeds` |
92
+ | `1020` | no effects declared | Flow has no `effects` and no Manifest to infer from | Run `oke build` / `oke dev`, or declare effects |
93
+ | `1030` | adopt barrel stale | A `src/flows/<unit>` folder was not adopted | Run `oke dev` or `oke build` to regenerate `generated.ts` |
94
+ | `1040` | HTTP path unresolved | Pathless `http.get()` never received a file-tree stamp | Import `@/flows/generated`, or pass `http.get("/…")` |
95
+ | `1041` | HTTP route clash | Two HTTP flows share the same method + path | Give each flow a unique method + path |
96
+ | `1045` | HTTP flow unnamed | Adopted HTTP flow still has no `unit.export` | Export from `flows/<unit>/` or pass a named `flow` |
97
+ | `1050` | live exposure dup | Same signal, gates, and match on two GET routes | Change the gate or path-param filter |
98
+ | `1060` | MCP tool duplicate | Two MCP tool bindings share the same tool name | Give each MCP tool exposure a unique name |
99
+ | `1070` | flow name duplicate | Two Flows share the same Manifest / `fx.call` name | Give at least one an explicit `flow("…")` or tree export |
100
+ | `1071` | once-signal multi-flow | Two different Flows bound to the same `signal.once` | Use `signal.broadcast`, or bind only one Flow |
101
+ | `1110` | schema missing | Domain table absent in `prod` — no auto-DDL | Run `oke db migrate` against this environment |
102
+ | `1210` | live resume gap | `Last-Event-ID` is not on the retained tape | Reconnect without the cursor; remaining tape replays |
103
+ | `1240` | orphan emit | Emit with zero subscribers and `optional` false | Add `on(signal, …)` or declare `optional: true` |
104
+ | `1250` | signal schema | Emit payload failed the signal's `schema` | Pass a payload that matches `schema`, or remove it |
105
+ | `1605` | channel schema | Send payload failed the template's `schema` | Fix template `data` payload or the template `schema` |
106
+ | `1810` | tenant required | Tenant-scoped op with no `fx.tenant.id` | `switchTenant`, signed `tid`, or tenant header |
107
+ | `1820` | tenant not member | Client-supplied tenant id is not a membership | Pick from `listTenants` or add the user as a member |
108
+ | `1830` | tenant unknown scope | Tenant role used an invented or `console:*` scope | Use a declared application scope |
108
109
 
109
110
  <Callout title="Effects are usually inferred">
110
111
  The 1001–1007 · 1008 · 1009 family exists for flows that declare effects explicitly. Most apps
@@ -172,6 +173,12 @@ Thrown by specific subsystems — each names its own cause:
172
173
  A resource mount and a handwritten route share the same method + path. Drop one binding.
173
174
  </Accordion>
174
175
 
176
+ <Accordion title="OKE1071 once signal bound to more than one Flow">
177
+ Cause: `Once signal "{signal}" is bound to more than one Flow ({flows}).` Use `signal.broadcast`
178
+ if each Flow should get a copy, or bind only one Flow. See [Once · Competing
179
+ consumers](/docs/elements/signal/once#competing-consumers-once-vs-broadcast).
180
+ </Accordion>
181
+
175
182
  <Accordion title="OKE1110 in production">
176
183
  Domain tables missing — prod has no auto-DDL. Run `oke db migrate` against that environment
177
184
  ([CLI](/docs/reference/cli)).
@@ -2144,3 +2144,50 @@ on(ping, flow({ do: () => ({ b: true }) }));
2144
2144
  );
2145
2145
  });
2146
2146
  });
2147
+
2148
+ describe("extractManifest — once-signal uniqueness OKE1071", () => {
2149
+ test("two differently-named flows on the same once signal fail OKE1071", async () => {
2150
+ const source = `
2151
+ import { on, flow, signal } from "okengine";
2152
+ export const orderPlaced = signal.once("orders.placed");
2153
+ on(orderPlaced, flow("orders.charge", { do: () => ({ a: true }) }));
2154
+ on(orderPlaced, flow("orders.ship", { do: () => ({ b: true }) }));
2155
+ `;
2156
+ try {
2157
+ await extractFromSources({ "dup-once.ts": source });
2158
+ expect.unreachable("extract should throw OKE1071");
2159
+ } catch (err) {
2160
+ expect(err).toBeInstanceOf(Error);
2161
+ const message = (err as Error).message;
2162
+ expect(message).toMatch(/OKE1071/);
2163
+ expect(message).toContain("orders.placed");
2164
+ expect(message).toContain("orders.charge");
2165
+ expect(message).toContain("orders.ship");
2166
+ expect(message).toMatch(/signal\.broadcast/);
2167
+ }
2168
+ });
2169
+
2170
+ test("the same scenario with signal.broadcast does not fail", async () => {
2171
+ const source = `
2172
+ import { on, flow, signal } from "okengine";
2173
+ export const catalogChanged = signal.broadcast("catalog.changed");
2174
+ on(catalogChanged, flow("cache.invalidate", { do: () => ({ a: true }) }));
2175
+ on(catalogChanged, flow("search.reindex", { do: () => ({ b: true }) }));
2176
+ `;
2177
+ const manifest = await extractFromSources({ "fanout.ts": source });
2178
+ expect(manifest.signals?.["catalog.changed"]?.delivery).toBe("broadcast");
2179
+ expect(manifest.flows?.["cache.invalidate"]?.trigger).toEqual({ signal: "catalog.changed" });
2180
+ expect(manifest.flows?.["search.reindex"]?.trigger).toEqual({ signal: "catalog.changed" });
2181
+ });
2182
+
2183
+ test("a single flow bound to once extracts cleanly", async () => {
2184
+ const source = `
2185
+ import { on, flow, signal } from "okengine";
2186
+ export const emailTask = signal.once("tasks.email");
2187
+ on(emailTask, flow("workers.email", { do: () => ({ ok: true }) }));
2188
+ `;
2189
+ const manifest = await extractFromSources({ "once.ts": source });
2190
+ expect(manifest.signals?.["tasks.email"]?.delivery).toBe("once");
2191
+ expect(manifest.flows?.["workers.email"]?.trigger).toEqual({ signal: "tasks.email" });
2192
+ });
2193
+ });