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…

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.

The payload is a record, not a value

Section titled “The payload is a record, not a value”

Notice that Order is a type, not a Decimal. That’s the rule, not the example’s taste: a delivery is a serialized struct, so a payload needs a field layout. Three things have one —

A bare Int does not, and the compiler says so at the declaration:

bus { publish "org.metrics" of type Int; }
// ^^^ `Int` is not carried on
// the bus

Wrap it. type Metric { n: Int = 0; } costs one line and buys a name for what’s on the wire — which is what every later reader of that subject actually wants. (Inside the payload, primitives are exactly what its fields are made of.)

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.

Choosing the subject at runtime

Section titled “Choosing the subject at runtime”

Usually the subject is fixed. When it isn’t — a logger that sends to log.<component> for whichever component it was built for — you declare a wildcard publish and send to a computed string:

type LogEvent { phase: String; }
locus Trace {
params { component: String = "root"; }
bus { publish "log.**" of type LogEvent; }
fn note(phase: String) {
let subject = "log." + self.component;
subject <- LogEvent { phase: phase };
}
}

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

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 log.** can never become a publisher of app.order, so questions like “how many publishers does this topic have?” stay answerable even in a program that logs. 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.

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.

A payload is as large as its fields make it. In-process delivery is bounded only by the payload arena: a record carrying a 1 MiB String crosses the bus whole. A bound topic travels in 64 KiB frames, so a payload past that reaches every in-process subscriber and is lost for every remote one — there is no fallible-publish path or counter your program can see this through yet, only a stderr line once per subject for whoever is watching logs. Keep a subject a remote peer needs under 64 KiB on the wire.

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. That includes a subscriber nested inside a pool- or pinned-placed locus, which runs on its owner’s thread and receives through its owner’s ring.

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.

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, a locus from an imported seed included. A subject is static_direct only when its payload is flat (every field an inline scalar: Int, Float, Bool, Decimal, Duration); one carrying a String, Bytes or another struct is a static_bucket, however quiet its handlers. 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.

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:

type Tick { symbol_id: Int; price: Decimal; }
topic Quote { payload: Tick; keyed_by symbol_id; on_unmatched: fallback; }
locus Unrouted {
bus { subscribe Quote as on_stray where key == _; } // the catch-all
fn on_stray(t: Tick) { }
}

Next: where loci actually run — Concurrency & placement.