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.

Topics are typed declarations

Section titled “Topics are typed declarations”

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.

Subscribe and publish

Section titled “Subscribe and publish”

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
}
}

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

What sending actually means

Section titled “What sending actually means”

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.

One ordering rule

Section titled “One ordering rule”

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.

When a consumer can’t keep up

Section titled “When a consumer can’t keep up”

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.

Why this doesn’t break the tower

Section titled “Why this doesn’t break the tower”

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.

You won’t always pay for it

Section titled “You won’t always pay for it”

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.

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.

When nothing matches

Section titled “When nothing matches”

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; }

Next: where loci actually run — Concurrency & placement.