okengine 0.19.2 → 0.19.4

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 (50) hide show
  1. package/package.json +1 -1
  2. package/site/content/docs/elements/channel/index.mdx +1 -1
  3. package/site/content/docs/elements/clock/index.mdx +106 -17
  4. package/site/content/docs/elements/clock/schedules.mdx +39 -11
  5. package/site/content/docs/elements/flow/consumers.mdx +129 -86
  6. package/site/content/docs/elements/flow/index.mdx +3 -3
  7. package/site/content/docs/elements/flow/routing.mdx +11 -8
  8. package/site/content/docs/elements/signal/broadcast.mdx +15 -14
  9. package/site/content/docs/elements/signal/index.mdx +99 -36
  10. package/site/content/docs/elements/signal/live.mdx +5 -13
  11. package/site/content/docs/elements/signal/once.mdx +88 -28
  12. package/site/content/docs/reference/errors.mdx +41 -27
  13. package/site/content/docs/understand/the-architecture.mdx +2 -2
  14. package/src/cli/competitor-mention-removal.test.ts +3 -3
  15. package/src/compiler/extract.test.ts +237 -15
  16. package/src/compiler/extract.ts +75 -4
  17. package/src/compiler/flow-path.test.ts +23 -0
  18. package/src/compiler/flow-path.ts +17 -1
  19. package/src/console/ui-next/dist/assets/{access-page-DceEWH9u.js → access-page-rO2HLPab.js} +1 -1
  20. package/src/console/ui-next/dist/assets/{agent-disclosure-tC9s2VFd.js → agent-disclosure-ae8w8JLx.js} +1 -1
  21. package/src/console/ui-next/dist/assets/{cache-glyph-C-naNQSR.js → cache-glyph-C-uHdh5d.js} +1 -1
  22. package/src/console/ui-next/dist/assets/{call-pii-button-DnZ_MlDn.js → call-pii-button-BJH54w4s.js} +1 -1
  23. package/src/console/ui-next/dist/assets/{collapsible-RekgR6Qz.js → collapsible-CaE8cs9p.js} +1 -1
  24. package/src/console/ui-next/dist/assets/{duration-tone-DugtWBS0.js → duration-tone-DdOQkQVK.js} +1 -1
  25. package/src/console/ui-next/dist/assets/{flows-page-DVmn1ZuQ.js → flows-page-DWeBszQQ.js} +1 -1
  26. package/src/console/ui-next/dist/assets/{highlighted-json-CvBGaiSD.js → highlighted-json-B2gBsC4B.js} +1 -1
  27. package/src/console/ui-next/dist/assets/{http-method-D7_OXbdC.js → http-method-BnMl0dJt.js} +1 -1
  28. package/src/console/ui-next/dist/assets/{index-DH0K2f6N.js → index-CBAP48v5.js} +3 -3
  29. package/src/console/ui-next/dist/assets/index-VxoEz295.css +2 -0
  30. package/src/console/ui-next/dist/assets/{observability-page-qJzF2nSN.js → observability-page-DVpcJEnW.js} +1 -1
  31. package/src/console/ui-next/dist/assets/{replica-lag-Vqk0pUBA.js → replica-lag-BUQf2Nf6.js} +1 -1
  32. package/src/console/ui-next/dist/assets/{request-meta-BtShi4sG.js → request-meta-CPkGDQgT.js} +1 -1
  33. package/src/console/ui-next/dist/assets/{store-page-CMsYH_vH.js → store-page-CwS_cY4V.js} +1 -1
  34. package/src/console/ui-next/dist/assets/{trace-detail-sheet-ycFB2uua.js → trace-detail-sheet-DNX7Y2bd.js} +1 -1
  35. package/src/console/ui-next/dist/assets/{tree-expand-toggle-iG1jcWgU.js → tree-expand-toggle-Bhdk27aF.js} +1 -1
  36. package/src/console/ui-next/dist/assets/{units-page-Bavchke4.js → units-page-CsBh0XbI.js} +1 -1
  37. package/src/console/ui-next/dist/assets/{vault-page-DxUFhiZI.js → vault-page-CsTlPtK9.js} +1 -1
  38. package/src/console/ui-next/dist/index.html +2 -2
  39. package/src/kernel/app.ts +87 -1
  40. package/src/kernel/errors-flow-name.ts +20 -4
  41. package/src/kernel/errors-once-signal.ts +25 -0
  42. package/src/kernel/errors.registry.test.ts +12 -2
  43. package/src/kernel/flow-name.test.ts +105 -20
  44. package/src/kernel/flow.ts +3 -3
  45. package/src/kernel/on.ts +0 -5
  46. package/src/kernel/once-signal.test.ts +71 -0
  47. package/src/kernel/stamp-http.test.ts +2 -1
  48. package/src/kernel/stamp-http.ts +5 -7
  49. package/src/kernel/unit.ts +1 -2
  50. package/src/console/ui-next/dist/assets/index-D0zS5rKO.css +0 -2
@@ -17,9 +17,11 @@ For developers wiring background work on okengine — bind the trigger, keep `do
17
17
 
18
18
  ## Smallest Example
19
19
 
20
- <Steps>
20
+ <Callout title="Bind and emit are independent">
21
+ The consumer and the producer share one Signal handle. They can live in different files, written
22
+ in any order — emit is not "step 2" after bind.
23
+ </Callout>
21
24
 
22
- <Step>
23
25
  ### Bind a Signal consumer
24
26
 
25
27
  ```typescript title="src/flows/notifications/welcome.ts"
@@ -40,9 +42,6 @@ export const sendWelcome = on(
40
42
  );
41
43
  ```
42
44
 
43
- </Step>
44
-
45
- <Step>
46
45
  ### Emit from any Flow
47
46
 
48
47
  ```typescript
@@ -52,10 +51,6 @@ await fx.emit(userSignedUp, { userId: "usr_123", email: "alice@example.com" });
52
51
  The compiler records `emits: ["users.signed-up"]` on the producer. The consumer runs after the
53
52
  emit commits — the HTTP request does not wait for the welcome mail.
54
53
 
55
- </Step>
56
-
57
- </Steps>
58
-
59
54
  <Callout title="Jobs are consumers">
60
55
  A named Clock bound with `on(clockDecl, flow)` is the same species — an asynchronous Flow. There
61
56
  is no separate job runner. See [Clock jobs](#clock-jobs).
@@ -101,15 +96,21 @@ export const processEmail = on(
101
96
 
102
97
  <Tab value="Clock">
103
98
 
104
- Name the schedule, then bind it. Omit `timezone` for `"UTC"`:
99
+ Name the schedule, then bind it. One Flow can write `clock.every` inside `on()` —
100
+ [Clock · Inline or named export](/docs/elements/clock#inline-or-named-export).
101
+
102
+ ```typescript title="src/clocks/metrics.ts"
103
+ import { clock } from "okengine";
104
+
105
+ export const cleanupClock = clock.every("metrics.cleanup", "1h");
106
+ ```
105
107
 
106
108
  ```typescript title="src/flows/metrics/cleanup.ts"
107
- import { on, flow, clock } from "okengine";
109
+ import { on, flow } from "okengine";
108
110
  import { lt } from "drizzle-orm";
111
+ import { cleanupClock } from "@/clocks/metrics";
109
112
  import { db, metricLogs } from "@/schema";
110
113
 
111
- export const cleanupClock = clock.every("metrics.cleanup", "1h");
112
-
113
114
  export const cleanupMetrics = on(
114
115
  cleanupClock,
115
116
  flow("metrics.cleanup", {
@@ -185,18 +186,19 @@ Omit `key` for competing consumers with no ordering.
185
186
 
186
187
  ## Trigger Reference
187
188
 
188
- | Trigger | Signature | Purpose | `do` input |
189
- | ---------- | --------------------------------------------------- | ------------------------------------------- | -------------------------------------- |
190
- | Signal | `on(signalHandle, flow)` or inline `signal.once(…)` | Queue (`once`) or fan-out (`broadcast`) | Payload (`schema`) |
191
- | Clock | `on(clockDecl, flow)` or inline `clock.every(…)` | Interval or cron tick | none (`_`) |
192
- | CDC any | `on(db.table(t).changed(), flow)` | Every insert / update / delete | `{ before, after, table, action, id }` |
193
- | CDC column | `on(db.table(t).changed("col"), flow)` | Same writes; column stamped on the Manifest | `{ before, after, table, action, id }` |
189
+ | Trigger | Signature | Purpose | `do` input |
190
+ | ---------- | ---------------------------------------------------------------- | ------------------------------------------- | -------------------------------------- |
191
+ | Signal | `on(handle, flow("name", { do }))` | Queue (`once`) or fan-out (`broadcast`) | Payload (`schema`) |
192
+ | Clock | `on(clockDecl, flow("name", { do }))` or inline `clock.every(…)` | Interval or cron tick | none (`_`) |
193
+ | CDC any | `on(db.table(t).changed(), flow)` | Every insert / update / delete | `{ before, after, table, action, id }` |
194
+ | CDC column | `on(db.table(t).changed("col"), flow)` | Same writes; column stamped on the Manifest | `{ before, after, table, action, id }` |
194
195
 
195
196
  `signal.live` is an HTTP SSE tape — bind it with [`http.live`](/docs/elements/flow/http#live-streams),
196
197
  not as a worker. A Flow with no trigger is [call-only](/docs/elements/flow).
197
198
 
198
- Inline `on(signal.once("…"), flow({ do }))` is supported; export the handle for typed `fx.emit`.
199
- Nameless `flow({ do })` inherits the trigger name; a second nameless inherit fails **OKE1070**.
199
+ Signal is always an exported const (`fx.emit` needs the handle). Clock may write
200
+ `clock.every(…)` inside `on()` — [Clock · Inline or named
201
+ export](/docs/elements/clock#inline-or-named-export). **OKE1072** if nameless.
200
202
 
201
203
  ## Signal Consumers
202
204
 
@@ -212,8 +214,12 @@ Each emit is handled according to the Signal helper you declared. The Flow is th
212
214
 
213
215
  <Tab value="Once">
214
216
 
215
- Competing workers — exactly one consumer processes each message. Failed attempts retry, then
216
- dead-letter when `deadLetter` is true (default):
217
+ Competing workers — exactly one consumer claims each message. Failed attempts retry, then
218
+ dead-letter when `deadLetter` is true (default).
219
+
220
+ Two different Flows on one `once` signal fail **OKE1071** — see
221
+ [Once · Competing consumers](/docs/elements/signal/once#competing-consumers-once-vs-broadcast).
222
+ For every bound Flow to run, use [`signal.broadcast`](/docs/elements/signal/broadcast).
217
223
 
218
224
  ```typescript title="src/signals/orders.ts"
219
225
  import { signal } from "okengine";
@@ -336,10 +342,9 @@ Broadcast does not use the `once` lease / DLQ path. Live uses the retained tape,
336
342
  </Accordion>
337
343
 
338
344
  <Accordion title="Schema at emit">
339
- Invalid payloads fail at `fx.emit` with **OKE1250** (`"{resource}": {detail}`) before any
340
- consumer runs. Fix the payload or the Signal's `schema`. This is an **emit** contract — not
341
- an HTTP invoke contract on `flow()`. Workers inherit the typed payload from the Signal
342
- declaration; they do not declare `in` on `flow({ do })`.
345
+ Invalid payloads fail at `fx.emit` with **OKE1250** (`"{resource}": {detail}`) before any consumer
346
+ runs. This is an **emit** contract — workers inherit the payload from the Signal; they do not
347
+ declare `in` on `flow()`.
343
348
  </Accordion>
344
349
 
345
350
  </Accordions>
@@ -347,11 +352,9 @@ Broadcast does not use the `once` lease / DLQ path. Live uses the retained tape,
347
352
  ## Clock Jobs
348
353
 
349
354
  <Callout title="Detailed section">
350
- Prefer `clock.every` / `clock.daily` / `clock.cron` (and the other named helpers) for fixed
351
- schedules — they compile to the same decl as the bare callable. The lower-level bare `clock(name,
352
- options)` form still works when you need a raw expression or a schedule chosen programmatically;
353
- missing both `cron` and `every` throws `clock("name"): require cron or every`. Bind the returned
354
- handle with `on(clockDecl, flow)`.
355
+ Prefer named helpers (`clock.every` / `daily` / `cron`). Bind with
356
+ `on(clockDecl, flow("name", { do }))`, or write `clock.every` inside `on()` —
357
+ [Clock · Inline or named export](/docs/elements/clock#inline-or-named-export).
355
358
  </Callout>
356
359
 
357
360
  Named clocks reconcile into the Store at boot. The scheduler leader-elects so N instances do not
@@ -363,10 +366,15 @@ double-fire. `do` receives no payload — read time through `fx.clock`.
363
366
 
364
367
  Human durations: `"200ms"` · `"30s"` · `"5m"` · `"1h"` · `"7d"` (integer + unit, no weeks):
365
368
 
366
- ```typescript title="src/flows/health/ping.ts"
367
- import { on, flow, clock } from "okengine";
369
+ ```typescript title="src/clocks/health.ts"
370
+ import { clock } from "okengine";
368
371
 
369
372
  export const pingClock = clock.every("health.pingExternal", "30s");
373
+ ```
374
+
375
+ ```typescript title="src/flows/health/ping.ts"
376
+ import { on, flow } from "okengine";
377
+ import { pingClock } from "@/clocks/health";
370
378
 
371
379
  export const pingExternal = on(
372
380
  pingClock,
@@ -385,13 +393,18 @@ export const pingExternal = on(
385
393
 
386
394
  Five-field cron (`m h dom mon dow`) plus an IANA `timezone` (default `"UTC"`):
387
395
 
388
- ```typescript title="src/flows/reports/daily.ts"
389
- import { on, flow, clock } from "okengine";
396
+ ```typescript title="src/clocks/reports.ts"
397
+ import { clock } from "okengine";
390
398
 
391
399
  export const dailyReportClock = clock.daily("reports.daily", {
392
400
  at: "06:00",
393
401
  timezone: "Asia/Riyadh",
394
402
  });
403
+ ```
404
+
405
+ ```typescript title="src/flows/reports/daily.ts"
406
+ import { on, flow } from "okengine";
407
+ import { dailyReportClock } from "@/clocks/reports";
395
408
 
396
409
  export const runDailyReport = on(
397
410
  dailyReportClock,
@@ -415,9 +428,14 @@ trigger when both are present.
415
428
  is never ticked:
416
429
 
417
430
  ```typescript title="src/clocks/invoices.ts"
418
- import { on, flow, clock } from "okengine";
431
+ import { clock } from "okengine";
419
432
 
420
433
  export const invoicesClock = clock.perTenant("invoices", { every: "1h" });
434
+ ```
435
+
436
+ ```typescript title="src/flows/billing/invoices.ts"
437
+ import { on, flow } from "okengine";
438
+ import { invoicesClock } from "@/clocks/invoices";
421
439
 
422
440
  export const runInvoices = on(
423
441
  invoicesClock,
@@ -489,49 +507,34 @@ do not fire.
489
507
  ## CDC
490
508
 
491
509
  <Callout title="Detailed section">
492
- If you only need any-write, jump to the example below. The handle is
510
+ If you only need any-write, jump to Bare or enriched below. The handle is
493
511
  `db.table(table).changed(column?)` — `db` is a `store.sql` declaration, `table` is a schema
494
512
  handle. `changed("insert")` is **not** an op filter; it stamps a column named `insert`.
495
513
  </Callout>
496
514
 
497
515
  SQL writes through `fx.store` notify CDC after commit. The Flow input is always
498
- `{ before, after }` plus `table`, `action`, and `id` (`CdcPayload`). Destructure
499
- only `{ before, after }` when that's all you need.
516
+ `{ before, after, table, action, id }` (`CdcPayload`).
500
517
 
501
- <Tabs items={["Audit log", "Bare", "Images"]}>
518
+ ### Bare or enriched
502
519
 
503
- <Tab value="Audit log">
520
+ | Style | When |
521
+ | ------------------------- | ---------------------------------------------------------------------- |
522
+ | `({ before, after })` | Bound to one table — images are enough (search reindex, listing cache) |
523
+ | `({ table, action, id })` | Log, route, or branch — kind of change and which record (audit log) |
504
524
 
505
- `table` is the real table name. `action` is `"created"` / `"updated"` / `"deleted"`.
506
- `id` is the declared primary-key value — not a hardcoded `"id"` column:
525
+ Both styles receive the same object. There is no second dispatch path, no
526
+ performance difference, and no correctness difference — the choice is which
527
+ fields this handler destructures.
507
528
 
508
- ```typescript title="src/flows/audit/users.ts"
509
- import { on, flow } from "okengine";
510
- import { db } from "@/core";
511
- import { users, auditLogs } from "@/schema";
512
-
513
- export const onUserWrite = on(
514
- db.table(users).changed(),
515
- flow("audit.users", {
516
- do: async ({ table, action, id, before, after }, fx) => {
517
- await fx
518
- .store(db)
519
- .insert(auditLogs)
520
- .values({
521
- table,
522
- recordId: String(id),
523
- action,
524
- });
525
- },
526
- }),
527
- );
528
- ```
529
+ `{ table, action, id }` are always populated; omitting them from `do` does not
530
+ drop them from the payload.
529
531
 
530
- </Tab>
532
+ <Tabs items={["Bare", "Enriched", "Images"]}>
531
533
 
532
534
  <Tab value="Bare">
533
535
 
534
- Omit `table` / `action` / `id` when you only need the row images:
536
+ Bound to `notes` — the table is already in the trigger. Images decide upsert vs
537
+ drop; the row's `id` is on the surviving image:
535
538
 
536
539
  ```typescript title="src/flows/search/reindex.ts"
537
540
  import { on, flow } from "okengine";
@@ -553,25 +556,30 @@ export const reindexNotes = on(
553
556
  );
554
557
  ```
555
558
 
556
- `changed("status")` stamps `trigger.cdc.column` on the Manifest. Still receive the
557
- same payload — filter in `do` when you only care about that field:
559
+ </Tab>
558
560
 
559
- ```typescript title="src/flows/tasks/on-status.ts"
561
+ <Tab value="Enriched">
562
+
563
+ `table` is the real table name. `action` is `"created"` / `"updated"` / `"deleted"`.
564
+ `id` is the declared primary-key value — not a hardcoded `"id"` column:
565
+
566
+ ```typescript title="src/flows/audit/users.ts"
560
567
  import { on, flow } from "okengine";
561
568
  import { db } from "@/core";
562
- import { tasks } from "@/schema";
569
+ import { users, auditLogs } from "@/schema";
563
570
 
564
- export const onStatus = on(
565
- db.table(tasks).changed("status"),
566
- flow("tasks.onStatus", {
567
- plane: "operator",
568
- do: async ({ before, after, id }, fx) => {
569
- if (before?.status === after?.status) return;
570
- await fx.emit(taskStatusChanged, {
571
- id,
572
- from: before?.status ?? null,
573
- to: after?.status ?? null,
574
- });
571
+ export const onUserWrite = on(
572
+ db.table(users).changed(),
573
+ flow("audit.users", {
574
+ do: async ({ table, action, id }, fx) => {
575
+ await fx
576
+ .store(db)
577
+ .insert(auditLogs)
578
+ .values({
579
+ table,
580
+ recordId: String(id),
581
+ action,
582
+ });
575
583
  },
576
584
  }),
577
585
  );
@@ -605,6 +613,30 @@ do: async ({ before, after, action }, fx) => {
605
613
 
606
614
  </Tabs>
607
615
 
616
+ `changed("status")` stamps `trigger.cdc.column` on the Manifest. Still the same
617
+ payload — filter in `do` when you only care about that field:
618
+
619
+ ```typescript title="src/flows/tasks/on-status.ts"
620
+ import { on, flow } from "okengine";
621
+ import { db } from "@/core";
622
+ import { tasks } from "@/schema";
623
+
624
+ export const onStatus = on(
625
+ db.table(tasks).changed("status"),
626
+ flow("tasks.onStatus", {
627
+ plane: "operator",
628
+ do: async ({ before, after, id }, fx) => {
629
+ if (before?.status === after?.status) return;
630
+ await fx.emit(taskStatusChanged, {
631
+ id,
632
+ from: before?.status ?? null,
633
+ to: after?.status ?? null,
634
+ });
635
+ },
636
+ }),
637
+ );
638
+ ```
639
+
608
640
  <Accordions>
609
641
 
610
642
  <Accordion title="CDC payload">
@@ -660,9 +692,19 @@ Clock drivers: **postgres** in `dev`/`prod`, **frozen** in `test`. Signal driver
660
692
  </Accordion>
661
693
 
662
694
  <Accordion title="OKE1070 — flow name defined twice">
663
- Cause: `Flow "{flow}" is defined twice.` Two nameless `flow({ do })` bindings inherited the same
664
- Signal or Clock name, or two explicit `flow("…")` calls share a name. Give at least one an
665
- explicit `flow("…")` or a distinct tree export.
695
+ Cause: `Flow "{flow}" is defined twice.` Two `flow("…")` strings share a name. Give at least one a
696
+ distinct name.
697
+ </Accordion>
698
+
699
+ <Accordion title="OKE1072 — Signal or Clock flow unnamed">
700
+ Cause: `A {kind} flow on "{trigger}" has no name.`
701
+ Fix: pass an explicit name — `on(handle, flow("unit.export", { do }))`.
702
+ </Accordion>
703
+
704
+ <Accordion title="OKE1071 — once signal bound to more than one Flow">
705
+ Cause: `Once signal "{signal}" is bound to more than one Flow ({flows}).` Use `signal.broadcast`
706
+ if each Flow should independently receive this event, or bind only one Flow. See [Once · Competing
707
+ consumers](/docs/elements/signal/once#competing-consumers-once-vs-broadcast).
666
708
  </Accordion>
667
709
 
668
710
  <Accordion title="OKE1240 — emit with no subscriber">
@@ -713,10 +755,11 @@ Clock drivers: **postgres** in `dev`/`prod`, **frozen** in `test`. Signal driver
713
755
  - [Signal](/docs/elements/signal) — `once` / `broadcast` / `live` physics
714
756
  - [Signal · Once](/docs/elements/signal/once) — leases, retries, partition keys
715
757
  - [Clock](/docs/elements/clock) — schedules, `fx.clock.sleep`
758
+ - [Clock · Inline or named export](/docs/elements/clock#inline-or-named-export) — one Flow vs shared schedule
716
759
  - [Store · SQL](/docs/elements/store/sql) — tables CDC watches
717
760
  - [HTTP · Live Streams](/docs/elements/flow/http#live-streams) — `signal.live` SSE
718
761
  - [fx](/docs/reference/fx) — `fx.emit`, `fx.deadLetters`, `fx.clock`
719
- - [Errors](/docs/reference/errors) — OKE1070 · OKE1240 · OKE1250
762
+ - [Errors](/docs/reference/errors) — OKE1070 · OKE1071 · OKE1072 · OKE1240 · OKE1250
720
763
  - [Workflows](/docs/elements/flow/workflows) — `durable: true` + `fx.step` on a consumer
721
764
 
722
765
  ## Next
@@ -344,9 +344,9 @@ A bare `404` with body `Not Found` means **no route matched** — not `fx.fail("
344
344
  </Accordion>
345
345
 
346
346
  <Accordion title="Name stamping">
347
- Prefer nameless `flow({ do })` on tree files — the file stamps `unit.export`.
348
- Pass `flow("notes.create", { … })` only for control. Nameless HTTP after adopt
349
- fails **OKE1045** — see [Routing](/docs/elements/flow/routing#when-to-omit--when-to-pass).
347
+ Prefer nameless `flow({ do })` on HTTP tree files — `src/flows/notes/[id]/get.ts` +
348
+ `export const get` stamps `notes.get`. Signal / Clock workers pass `flow("name", { do })`
349
+ (**OKE1072**; Clock inline is [Clock · Inline or named export](/docs/elements/clock#inline-or-named-export)).
350
350
  </Accordion>
351
351
 
352
352
  </Accordions>
@@ -455,11 +455,9 @@ prefix). Wrong-unit prefixes fail generate.
455
455
  Non-HTTP files still join the unit. A signal consumer in `notes/on-created.ts`
456
456
  is `api.notes.onCreated` over RPC (`POST /_oke/notes/onCreated`), not HTTP.
457
457
 
458
- Outside the tree, nameless `flow({ do })` inherits a named Signal or Clock trigger.
459
- File-tree `unit.export` still wins. Two nameless inheritances fail **OKE1070**. HTTP has no
460
- inherent trigger name — those Flows stay on the tree stamp or **OKE1045**.
461
-
462
- HTTP flows that remain nameless after adopt fail **OKE1045**.
458
+ Signal / Clock consumers pass an explicit `flow("…")` name (**OKE1072** if nameless
459
+ outside `src/flows/<unit>/`; **OKE1070** on collision). Clock may write `clock.every(…)`
460
+ inside `on()` — [Clock · Inline or named export](/docs/elements/clock#inline-or-named-export).
463
461
 
464
462
  ## Runtime Matching
465
463
 
@@ -517,8 +515,13 @@ plus a handwritten `http.get("/notes")`.
517
515
  </Accordion>
518
516
 
519
517
  <Accordion title="OKE1070 — flow name defined twice">
520
- Cause: `Flow "{flow}" is defined twice.` Two nameless Signal/Clock consumers inherited the same
521
- name, or two explicit `flow("…")` calls collide. Give at least one a distinct name or tree export.
518
+ Cause: `Flow "{flow}" is defined twice.` Two explicit `flow("…")` calls collide, or two tree
519
+ exports stamp the same `unit.export`. Give at least one a distinct name or tree export.
520
+ </Accordion>
521
+
522
+ <Accordion title="OKE1072 — Signal or Clock flow unnamed">
523
+ Cause: `A {kind} flow on "{trigger}" has no name.`
524
+ Fix: pass an explicit name — `on(handle, flow("unit.export", { do }))`.
522
525
  </Accordion>
523
526
 
524
527
  <Accordion title="422 — path param missing from in">
@@ -543,7 +546,7 @@ plus a handwritten `http.get("/notes")`.
543
546
 
544
547
  - [HTTP](/docs/elements/flow/http) — verbs, envelopes, `http.resource`, live SSE
545
548
  - [Client](/docs/client/calling) — `api.notes.get`, REST vs RPC, `$routes`
546
- - [Errors](/docs/reference/errors) — OKE1040 · OKE1030 · OKE1041 · OKE1045 · OKE1070
549
+ - [Errors](/docs/reference/errors) — OKE1040 · OKE1030 · OKE1041 · OKE1045 · OKE1070 · OKE1072
547
550
  - [The Architecture](/docs/understand/the-architecture) — derived routes, no hand-written table
548
551
  - [Gate](/docs/elements/gate) — `.gate(...)` / `.public()` on the same trigger
549
552
 
@@ -21,10 +21,12 @@ more `on(signal, flow)` subscribers, emit with `fx.emit`.
21
21
 
22
22
  ## Smallest Example
23
23
 
24
- <Steps>
24
+ <Callout title="One handle, three independent uses">
25
+ `cacheInvalidated` is a shared const. Declare it, bind any number of subscribers, and emit —
26
+ different files, any order. Bind and emit do not have a required sequence.
27
+ </Callout>
25
28
 
26
- <Step>
27
- ### Define the broadcast signal
29
+ ### Declare
28
30
 
29
31
  ```typescript title="src/signals/cache.ts"
30
32
  import { signal } from "okengine";
@@ -35,9 +37,6 @@ export const cacheInvalidated = signal.broadcast("cache.invalidated", {
35
37
  });
36
38
  ```
37
39
 
38
- </Step>
39
-
40
- <Step>
41
40
  ### Bind a subscriber
42
41
 
43
42
  ```typescript title="src/flows/cache/purge.ts"
@@ -54,10 +53,11 @@ export const purgeLocalCache = on(
54
53
  );
55
54
  ```
56
55
 
57
- </Step>
56
+ Multiple Flows may bind the same handle — every one gets a copy. That is fan-out, not a race.
57
+ See [Once · Competing consumers](/docs/elements/signal/once#competing-consumers-once-vs-broadcast)
58
+ when you meant a work queue instead.
58
59
 
59
- <Step>
60
- ### Emit from any Flow
60
+ ### Emit
61
61
 
62
62
  ```typescript title="src/flows/skus/[sku]/update.ts"
63
63
  import { on, flow, http } from "okengine";
@@ -81,10 +81,6 @@ export const update = on(
81
81
  The compiler records `emits: ["cache.invalidated"]` on the producer. Emit resolves when the
82
82
  outbox commits — the HTTP request does not wait for every subscriber to finish.
83
83
 
84
- </Step>
85
-
86
- </Steps>
87
-
88
84
  <Callout title="Same handle everywhere">
89
85
  Import the declared Signal handle (or the same name) in every subscriber and producer. A typo in
90
86
  the name creates a different Manifest entry — fan-out never crosses names.
@@ -470,6 +466,11 @@ dead-letter queue for exhausted retries in the queue sense — see
470
466
  Cause: `"{resource}": {detail}`. Align the payload with `schema` — no subscriber started.
471
467
  </Accordion>
472
468
 
469
+ <Accordion title="OKE1072 — Signal flow unnamed">
470
+ Cause: `A signal flow on "{trigger}" has no name.`
471
+ Fix: pass an explicit name — `on(handle, flow("cache.purgeLocal", { do }))`.
472
+ </Accordion>
473
+
473
474
  <Accordion title="Expecting retries / DLQ like a queue">
474
475
  Use `signal.once`. Broadcast fan-out is not the lease + DLQ path documented under
475
476
  [Once](/docs/elements/signal/once).
@@ -496,7 +497,7 @@ dead-letter queue for exhausted retries in the queue sense — see
496
497
  - [Consumers](/docs/elements/flow/consumers) — binding `on(signal)`
497
498
  - [HTTP · Live Streams](/docs/elements/flow/http#live-streams) — when the consumer is a browser
498
499
  - [fx](/docs/reference/fx) — `fx.emit`
499
- - [Errors](/docs/reference/errors) — OKE1240 · OKE1250
500
+ - [Errors](/docs/reference/errors) — OKE1072 · OKE1240 · OKE1250
500
501
 
501
502
  ## Next
502
503