Inbox & Dispatch — the design

Workflows often need to hand work off to other workflows without wiring direct calls between them. The sender knows it has produced a finding; it should not have to know which workflows want to react to that finding, or in what order. Jaiph addresses this with channels — a small, in-process message-passing model that lives inside the same workflow runtime as everything else.

This page explains the model: how channels behave, why the design is shaped the way it is, and how the pieces fit together. For the surface syntax see the Language and Grammar references; for the runtime implementation see Architecture — Channels and hooks in context.

What problem channels solve

Two patterns recur in real workflows:

Channels give workflows a publish/subscribe surface without leaving the process. The producer declares “this is a finding”; the channel declaration ties findings to one or more listeners; the runtime delivers them.

Drain-driven, not file-watched

The most important property of the inbox model is that delivery is drain-driven. Sends do not “fire” routes the moment the <- line executes. Instead, each workflow frame owns an in-memory queue; a send enqueues on the nearest stack frame that declares routes for the channel (or on the sender’s frame when none do — see Routed vs unrouted sends). The runtime drains that frame’s queue only after that frame’s step list finishes — including the implicit join of any run async handles created in that step list (Spec: Async Handles). Only then does the runtime invoke each route target, sequentially, in declaration order.

This is intentional:

The trade-off is that channels are not a low-latency notification primitive. They are an end-of-step-list handoff. For tighter coordination, use a direct run call.

Routes belong on the channel, not on workflows

A channel declaration carries its targets inline:

channel findings -> analyst, reviewer

Routes are top-level static data on ChannelDef, not statements inside a workflow body. The design choice has two consequences worth understanding:

  1. One canonical subscription list per channel. The compiler can validate every target up front: targets must be workflows (rules and scripts are rejected), they must declare exactly three parameters, and unknown names fail with E_VALIDATE at compile time, not at dispatch time.
  2. Routes are visible at the module boundary. A reader can see “who listens on findings” without scanning workflow bodies for ad-hoc wiring. Routing intent lives next to the channel it describes.

The runtime registers routes only on the entry workflow frame when that workflow starts: it reads channel … -> declarations from that workflow’s module. Nested run frames always keep an empty map, so sends from callees walk the workflow stack outward to the orchestrator frame that registered the channel.

A channel <name> line without -> still defines the name for send validation but never enters the route map — sends on a bare channel are still queued (and INBOX_ENQUEUE is still recorded for the timeline), they just have no consumer.

Sequential dispatch is the only mode

For each queued message, route targets run strictly in declaration order, one at a time. The next message is not processed until every target for the current message has completed. There is no opt-in parallel mode; older builds exposed one and it has been removed.

The reason is failure semantics. With sequential dispatch:

A single frame’s drain pass is bounded (default 1000 messages; override with JAIPH_INBOX_MAX_DISPATCH) so circular send loops abort with E_INBOX_DISPATCH_LIMIT instead of running forever.

When concurrency matters, the right tool is run async inside a target body, not parallel dispatch across targets.

Routed vs unrouted sends

The same <- operator behaves slightly differently depending on whether any frame on the workflow stack has the channel in its route map:

Unrouted sends are intentionally a silent drop, not an error. This lets optional subscribers be just that: a workflow can publish on metrics even if no one is listening today, and tomorrow a subscriber can be wired up without touching the producer. If a missing handler should be a hard failure, the right place to assert it is in a test or a rule check, not in the channel runtime.

The trigger contract

A receiver workflow is a normal workflow, dispatched with three positional arguments bound to the parameters it declares:

Position Meaning
1st parameter The message payload (the string sent on <-)
2nd parameter The channel name (bare, e.g. findings)
3rd parameter The sender — the workflow name that performed the send

The receiver picks its own parameter names. That is the entire contract: no environment plumbing, no special globals, no implicit context object. Targets that declare a different parameter count are rejected at compile time so receivers cannot drift away from the dispatch shape.

Why this design, in one paragraph

Channels are a deliberately small idea in Jaiph. They are an in-process, drain-driven, sequentially-dispatched, late-binding handoff between workflows — described once at the top of the module, validated at compile time, and recorded in run_summary.jsonl (every send) plus inbox/ audit files (routed sends only) for after-the-fact inspection. Anything more powerful (concurrency, brokers, retries, dead-letter queues) is intentionally out of scope: those problems belong to other tools, and Jaiph keeps channels small enough to reason about without leaving the runtime.