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 }}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’sPid ! Msg.
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.
keyed_by FIELDon 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, orString. AStringkey 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 oneDecimalfield or render it into theStringkey yourself.)where key == EXPRon a subscribe filters that subscriber.EXPRcan be a literal, aconst, orself.<field>— the common case, one instance per shard. It can also be the bare wordreplica: the instance’s 0-based replica index, so apinned(..., 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_idlater does not re-route the subscription; to change shards, dissolve the locus and instantiate a fresh one.
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; }swallow(the default) — the message is dropped silently. Run withLOTUS_BUS_LOG_UNMATCHED=1to 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 withwhere 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.