Skip to content

The bus

Coming from Go? Topics are like channels, but typed by a declaration instead of by chan T, and many-to-many instead of point-to-point. You don’t pass a channel into a goroutine; a locus declares which topics it subscribes to and publishes, and the runtime wires the delivery. No channel plumbing threaded through constructors.

You met the bus implicitly in logging: emitters publish, sinks subscribe, neither references the other. Here you declare and use it directly.

A topic names a channel and the type that flows on it:

type Order { id: String; amount: Decimal; }
topic OrderPlaced { payload: Order; }
topic OrderShipped { payload: Order; }

A topic is a top-level declaration, like type or locus. It’s referenced by name — never a magic string — so the payload type is checked at every publish and every handler, and renaming the topic moves every use with it.

A locus declares its bus interface in a bus { } block:

locus Warehouse {
bus {
subscribe OrderPlaced as on_order; // inbound
publish OrderShipped; // outbound
}
fn on_order(o: Order) {
// ... pick and pack ...
OrderShipped <- o; // the send
}
}
  • subscribe TOPIC as HANDLER; wires inbound messages to a handler method. The handler must exist with the matching signature — fn on_order(o: Order) — and the compiler checks it.
  • publish TOPIC; authorizes this locus to send on the topic. Without it, a send is a compile error.
  • TOPIC <- value; is the send. It’s a statement, not an expression — it produces no value, like Erlang’s Pid ! Msg.

Subscribing is declarative — there’s no subscribe() call at runtime. Registration happens when the locus is constructed, and unsubscribe happens automatically at dissolve.

Usually the subject is fixed. When it isn’t — a logger that sends to log.<component>, a socket that reports on whatever subject you configured — you declare a wildcard publish and send to a computed string:

locus Wire {
params { log_subject: String = ""; }
bus { publish "io.tcp.**" of type LogEvent; }
fn read() {
if len(self.log_subject) > 0 {
self.log_subject <- LogEvent { phase: "recv" };
}
}
}

The publish "io.tcp.**" declaration isn’t paperwork. It’s the promise that bounds what this locus can do, and it is enforced:

  • The computed subject must lie under one of the patterns you declared. Wire can send to io.tcp.venue or to io.tcp itself, but not to app.order — that raises BusPublishUnauthorized.
  • It must not reach a subscriber expecting a different payload, which raises BusPayloadMismatch.

Both checks run only on the computed path. A literal TOPIC <- v is bound to its declaration when you compile, so it costs nothing.

Keep the pattern as narrow as the locus really needs. A wide pattern doesn’t just permit more sends — it tells the compiler less. Because a computed publish can’t escape its declaration, the compiler knows a locus declaring io.tcp.** can never become a publisher of app.order, so questions like “how many publishers does this topic have?” stay answerable even in a program doing socket I/O. Declare "**" and you’ve given that up everywhere.

If you subscribe to a subject that sits under another locus’s wildcard pattern while expecting a different payload, you’ll get a warning — that’s the arrangement where the two rules above would fire at runtime.

Notice that OrderShipped <- o; has no error handling — no or clause, no return code. That’s a promise, not an omission: a send succeeding means the broker accepted the message, and the broker is never allowed to accept a message it already knows it can’t deliver under the topic’s delivery guarantee. For an in-process topic that guarantee is “dispatched to every born subscriber”. For a bound topic the guarantee comes from the binding — and if a declared binding can’t be opened at startup at all, the program refuses to boot rather than run with a broker that would quietly drop your messages. The error channel isn’t missing from <-; it’s relocated to the one place it can be acted on — the structural failure path.

A subscriber must be born before a publisher sends, or the message has nowhere to land. In practice: instantiate your subscribers first in main. (This is the same rule you saw with the log sink.)

The mirror-image rule at the end of a program is handled for you: at teardown, the runtime joins pinned workers (and drains what they published) before dissolving their sibling subscribers, so events a worker publishes in its final moments still arrive even when main’s run() returns immediately. What can still be lost is a publish made after its last subscriber has dissolved — say, from a pool-placed publisher whose queued work outlives teardown. If losing those would matter, don’t lean on teardown ordering: have the parent wait for a completion signal (“exit when done”) before returning. Set LOTUS_BUS_LOG_DROP=1 to see any such drop while debugging.

Unbounded by default: most topics are low-rate control flow and a queue that grows briefly is fine. When a fast publisher meets a slow consumer, two opt-in knobs bound the damage — one for each side of the contract:

topic Frame {
payload: Reading;
bounded(1024);
on_full: fail;
}

The topic bound is the publisher’s contract: at capacity the broker refuses, and every send site must say what it does about that — or raise (a full queue is a bug), or discard (shed at the source, counted), or or wait (run at the consumer’s pace).

subscribe Frame as on_frame bounded(64, drop_old);

The subscriber bound is that consumer’s private coping strategy: drop_old keeps the newest 64 (right for reload-style events, where stale beats losing the freshest), drop_new rejects arrivals. A consumer bound below the topic bound sheds quietly before the publisher ever sees refusal. Pool- and pinned-placed subscribers don’t take these bounds — their queues are already fixed-size rings that push back on producers directly.

In the parent/child model, flow is strictly vertical — a locus only talks up to its parent and down to its children. The bus seems to let unrelated loci talk sideways. It doesn’t, really: publishers and subscribers don’t see each other, they see the topic, which lives at the runtime root — structurally above everyone. Every send goes up to the bus; every delivery comes down to a subscriber. It’s vertical flow through a shared root, which is why two loci on opposite branches of a deep tree can coordinate with no shared pointer and no registry lookup.

This is the productive shape for events: many-to-many flow without back-channels. A topic can have any number of publishers and subscribers.

If a topic is only ever used inside a single locus type — the same locus both publishes and subscribes, with no external binding — the compiler can prove every send routes back to a handler on the same instance, and rewrites the send into a direct method call. The bus is elided entirely. So you can use topics freely for a locus’s own internal event flow without paying dispatch cost; if the topic later grows a second subscriber or a deployment binding, the real bus path comes back automatically, and your code doesn’t change.

The static-dispatch devirtualization is broader than that intra-locus-type case: any quiet, flat-payload, same-thread handler on a closed-world local subject lowers to a direct synchronous call — even when the publisher and subscriber are distinct locus types.

A topic your deployment binds to a transport keeps the real bus path no matter how simple it looks: once a bindings { } entry names it, an external peer may be the counterparty, and the send has to go out the transport rather than into a local call.

You can see what the compiler decided for each subject:

Terminal window
hale model dump app.hl # …then read the dispatch_plan section

Each row names the wire subject, the flavor it lowers to (dynamic, static_bucket, static_direct), why — when the answer is “dynamic” — and which thread domains the publishers and subscribers actually run in. The decision is part of the build’s identity, so a recording made from one build is never replayed against another that dispatches differently.

Routing keys: one topic, sharded by a field

Section titled “Routing keys: one topic, sharded by a field”

By default every subscriber to a topic sees every message. When you have many subscribers that each care about one slice of the traffic — one connection, one symbol, one tenant — fanning every message to all of them and filtering in each handler is wasteful. A routing key moves that filter into the bus: a subscriber declares which key it wants, and the runtime only delivers matching messages.

Name a payload field as the key on the topic, then filter on it at the subscribe site:

type Tick { symbol_id: Int; price: Decimal; }
topic Quote { payload: Tick; keyed_by symbol_id; }
locus Feed {
params { symbol_id: Int = 0; }
bus {
subscribe Quote as on_quote where key == self.symbol_id;
}
fn on_quote(t: Tick) {
// only ticks whose symbol_id matches this Feed arrive
}
}

A publish carries its key in the payload, so the send is unchanged — Quote <- Tick { symbol_id: 7, price: 100.0d }; reaches only the Feed instances that subscribed with where key == 7.

  • keyed_by FIELD on the topic picks the routing field. It must be a field of the payload, and its type must be one the bus can compare per entry: Int, Bool, Time, Duration, a no-payload enum, Decimal, or String. A String key routes by name directly — keyed_by room + where key == self.name — with a hash gate so a non-matching key still costs one integer compare. (Need a compound key like (symbol, venue)? Pack it into one Decimal field or render it into the String key yourself.)
  • where key == EXPR on a subscribe filters that subscriber. EXPR can be a literal, a const, or self.<field> — the common case, one instance per shard. It can also be the bare word replica: the instance’s 0-based replica index, so a pinned(..., replicas = K) fan-out shards an Int-keyed topic with one subscribe line (see the concurrency chapter’s fan-out pattern). A filter of any shape requires a keyed topic — on an unkeyed one it would silently match nothing, and the checker now says so.
  • The key is captured by value when the locus is constructed. Reassigning self.symbol_id later does not re-route the subscription; to change shards, dissolve the locus and instantiate a fresh one.

A keyed publish whose key matches no subscriber is governed by the topic’s on_unmatched: policy:

topic Quote { payload: Tick; keyed_by symbol_id; on_unmatched: fallback; }
  • swallow (the default) — the message is dropped silently. Run with LOTUS_BUS_LOG_UNMATCHED=1 to log drops while debugging.
  • fail — the publish becomes fallible; every send site must dispose of it: Quote <- t or raise; panics on an unmatched key, Quote <- t or discard; swallows it. Use this when an unrouted message is a bug, not an expected case.
  • fallback — an unmatched message is delivered to a catch-all subscriber that opts in with where key == _. At least one such subscriber must exist program-wide, or the topic is rejected at compile time.

Next: where loci actually run — Concurrency & placement.