Inbox and dispatch

A def often needs to hand work off to other defs without calling them directly. A def is a callable unit in Jaiph, written with def and invoked with a bare call name(args). A def that produces a finding should not have to know which other defs react to it, or in what order. Jaiph solves this with channels. A channel is a small message-passing feature that runs inside the same runtime as everything else, so there is no separate process or broker.

The rest of this page explains how channels behave and why the design works the way it does. For the syntax, see the Language and Grammar references. For the runtime implementation, see Architecture.

What problem channels solve

Channels help in situations where direct calls are awkward:

Channels give defs a way to publish and subscribe without leaving the process. The producer sends a message on a channel with send. The channel declaration lists one or more defs that listen on it, and the runtime delivers the message to each of them.

When messages are delivered

A send does not run the route targets at the moment the line executes. Instead, each running def has a frame with its own in-memory queue. When the runtime runs a send, it adds the message to a queue and drains the queue later.

The runtime picks the queue by walking outward from the sender through the stack of running defs. It uses the nearest frame that declares routes for the channel. When no frame declares routes, it uses the sender’s own frame (see Routed and unrouted sends).

The runtime drains a frame’s queue only after that frame’s step list finishes successfully. If the def fails, the runtime returns the failure and never drains the queue, so its sends are not delivered. Finishing the step list includes the implicit join of any async handles that the step list created (see Spec: Async Handles). Only after the step list finishes does the runtime run each route target, one at a time, in the order the routes are declared.

The runtime delays delivery on purpose. The delay gives the following properties:

The trade-off is that channels are not a fast notification tool. Delivery only happens after a step list finishes. When you need one def to react to another right away, call it directly.

Routes are declared on the channel

A channel declaration lists its targets on the same line:

channel findings -> analyst, reviewer

A short round trip looks like this. The producer sends a payload on findings, and the runtime delivers it to analyst after main finishes its steps:

channel findings -> analyst

export def main() {
  send "found a null deref in parser.ts" -> findings
}

def analyst(message) {
  log "analyst got: ${message}"
}

Routes are top-level data on ChannelDef, not statements inside a def body. Declaring routes on the channel has the following effects:

  1. There is one list of subscribers per channel. The compiler checks every target when it compiles the module. Each target must be a def that declares 1 to 3 parameters (message, then channel, then sender). A script is rejected, and so is a def with 0 or more than 3 parameters. An unknown name fails with E_VALIDATE at compile time, not at dispatch time.
  2. The routes are visible at the top of the module. You can see which defs listen on findings without reading through def bodies to find the connections. The list of listeners is next to the channel it belongs to.

The runtime registers routes only on the entry frame, which is the first def the run starts. When that frame starts, the runtime reads the channel … -> declarations from that def’s module. A nested call frame always keeps an empty route map. Because of this, a send from a nested def walks the stack outward until it reaches the entry frame that registered the channel.

A channel <name> line without -> still defines the channel name, so a send to it passes validation. The channel never enters any route map, so a send on it has no consumer. The message is still added to a queue, and the runtime still records an INBOX_ENQUEUE event in run_summary.jsonl for the timeline.

Dispatch is always sequential

For each queued message, the route targets run one at a time, in the order they are declared. The runtime does not start the next message until every target for the current message has finished. There is no option for parallel dispatch. An older build had one, and it has been removed.

The reason for sequential dispatch is that it keeps failures simple to reason about. Running one target at a time gives this behavior:

For each delivery, the runtime writes two events to run_summary.jsonl: an INBOX_DISPATCH_START event and an INBOX_DISPATCH_COMPLETE event. The INBOX_DISPATCH_COMPLETE event carries the target’s exit status and how long it ran. Together with the INBOX_ENQUEUE event for each send, these events let you reconstruct the full order of sends and deliveries after the run.

Each frame’s drain pass has a limit on how many messages it will dispatch. The default is 1000, and you can change it with JAIPH_INBOX_MAX_DISPATCH. When a drain pass hits the limit, it stops with E_INBOX_DISPATCH_LIMIT instead of running forever, which catches a circular send loop.

When you need concurrency inside dispatch, use async inside a target’s body. Dispatch across targets is always sequential and does not give you concurrency.

Routed and unrouted sends

A send behaves in one of two ways, depending on whether any running def declares a route for the channel:

An unrouted send is dropped on purpose, and it is not an error. Subscribers can be optional this way. A def can send on metrics even when nothing listens today, and you can add a subscriber later without changing the producer. If a missing subscriber should be a hard failure, check for it in a test or in a def, not in the channel runtime.

How a receiver def is called

A receiver is a normal def. The runtime binds a prefix of up to three positional arguments:

Position Meaning
1st parameter The message payload
2nd parameter The channel name (bare, e.g. findings)
3rd parameter The sender, which is the name of the def that ran the send

The receiver chooses its own parameter names and can declare one, two, or three parameters. The runtime passes only as many arguments as the receiver declares.

Summary

Channels are a deliberately small feature. A channel passes a message from one def to one or more other defs, inside the same process. You declare the routes once at the top of the module, and the compiler validates them. Delivery happens when a step list finishes successfully, and the runtime dispatches the targets one at a time. The runtime records every send and every delivery in run_summary.jsonl, and it writes an inbox/ audit file for each routed send, so you can inspect what happened after the run. Channels leave out concurrency, message brokers, retries, and dead-letter queues on purpose. Those problems belong to other tools, and leaving them out keeps channels small enough to understand without going outside the runtime.