Nautilus Message Bus

The MessageBus is the spine of every Nautilus runtime: a single in-process, single-threaded, deterministically-ordered hub that delivers Data, Events, and Commands between Actors, Strategies, and the kernel’s engines via three messaging patterns (Pub/Sub, Request/Response, Point-to-Point) and three publishing styles (low-level topic pub/sub, Actor-based custom data, Actor-based primitive signals). Every message is immutable post-publish. Optional Redis Streams backing externalizes the bus for cross-process consumers (the dashboard story, the producer/consumer split for shared market data) - Redis writes happen on a Rust MPSC-fed background thread so the trading core never blocks on I/O. Same bus runs in backtest, sandbox, and live with identical dispatch semantics - backtest determinism is a property of the single-threaded kernel + stable-sort by ts_init, not of the bus implementation. For Cortana MK3 this replaces hand-rolled function calls and ad-hoc SQLite writes with one typed pub/sub spine; the multi-tenant fan-out story is Producer/Consumer external streams over Redis - one process holds the upstream UW WebSocket and republishes onto a shared external stream, N tenant TradingNode processes consume that stream as their external_clients data source.

Core claim

The bus is the architecture. Every other component (DataEngine, ExecutionEngine, RiskEngine, Cache, Actor, Strategy) is wired together exclusively by sending messages to or receiving them from the bus. There are no direct method calls between strategy and execution, no shared mutable dictionaries, no callback chains. This is what gives Nautilus deterministic ordering, replayability, and backtest/live parity for free - properties MK2 has tried (and failed) to maintain through discipline alone.

Topology

In-process spine

“The MessageBus is the central hub for all messages in NautilusTrader. It enables a publish/subscribe pattern where components can publish events to named topics, and other components can subscribe to receive those messages. This decouples components, allowing them to interact indirectly via the message bus.”

The bus is thread-local to the kernel (per nautilus-architecture.md “Threading model”). Every component inside the kernel - Actors, Strategies, DataEngine, ExecutionEngine, RiskEngine - calls into the same bus instance. Background services (Tokio I/O, network, persistence) run on their own threads and funnel results back to the kernel’s bus via MPSC channels.

Three messaging patterns

“There are three primary messaging patterns available in NautilusTrader: Point-to-Point, Publish/Subscribe, Request/Response.”

PatternUse caseExamples in framework
Pub/SubOne publisher → N subscribers, fire-and-forgetData dispatch, OrderEvent dispatch, custom signals
Request/ResponseOne requester → one responder, with correlationHistorical data fetches (request_bars), instrument lookups
Point-to-PointDirect delivery to a named targetTargeted command from one component to another

Pub/Sub is the dominant pattern. R/R is what underpins request_bars() / request_quote_ticks() / request_instrument() - strategy issues a request correlated by ID, the data adapter responds with historical data, the bus routes the response back to on_historical_data. P2P is rarely user-facing.

Three message categories

“Messages exchanged via the MessageBus fall into three categories: Data, Events, Commands.”

  • Data - QuoteTick, TradeTick, Bar, OrderBookDelta, custom Data subclasses. Cache-then-publish ordering.
  • Events - OrderFilled, PositionOpened, AccountState, TimeEvent, custom events. Pub/Sub only.
  • Commands - SubmitOrder, CancelOrder, ModifyOrder, etc. Routed to the appropriate engine (RiskEngine → ExecutionEngine → ExecutionClient).

Three publishing styles (Actor/Strategy surface)

The doc enumerates these by capability and tradeoff. Verbatim from the page:

1. MessageBus pub/sub to topics (low-level)

“Low-level, direct access to the message bus.”

Use when:

  • Cross-component communication.
  • Flexibility to define any topic and send any type of payload (any Python object).
  • Decoupling between publishers and subscribers who don’t need to know about each other.
  • Global reach where messages can be received by multiple subscribers.
  • Working with events that don’t fit within the predefined Actor model.
  • Advanced scenarios requiring full control over messaging.

Caveat: “you must track topic names manually (typos could result in missed messages).”

from nautilus_trader.core.message import Event
 
class Each10thBarEvent(Event):
    TOPIC = "each_10th_bar"
    def __init__(self, bar):
        self.bar = bar
 
# Subscribe
self.msgbus.subscribe(Each10thBarEvent.TOPIC, self.on_each_10th_bar)
 
# Publish
self.msgbus.publish(Each10thBarEvent.TOPIC, Each10thBarEvent(bar))
 
# Handler (any name - wired by the subscribe call)
def on_each_10th_bar(self, event: Each10thBarEvent):
    self.log.info(f"Received 10th bar: {event.bar}")

self.msgbus.publish("MyTopic", "MyMessage") works at the rawest level - arbitrary topic string, arbitrary Python payload.

2. Actor-based custom data pub/sub

Requires a class that subclasses Data or uses @customdataclass.

“Proper event ordering via built-in timestamps (ts_event, ts_init) crucial for backtest accuracy. Data persistence and serialization. Standardized trading data exchange.”

from nautilus_trader.core.data import Data
from nautilus_trader.model.custom import customdataclass
 
@customdataclass
class GreeksData(Data):
    delta: float
    gamma: float
 
# Publish (Actor / Strategy method)
data = GreeksData(delta=0.75, gamma=0.1, ts_event=ns, ts_init=ns)
self.publish_data(GreeksData, data)
 
# Subscribe (Actor / Strategy method)
self.subscribe_data(GreeksData)
 
# Handler (FIXED name: on_data)
def on_data(self, data: Data):
    if isinstance(data, GreeksData):
        self.log.info(f"Delta: {data.delta}, Gamma: {data.gamma}")

The on_data handler name is fixed - type-check inside to dispatch among multiple custom data types subscribed by the same Actor.

3. Actor-based signal pub/sub

Lightweight notifications for primitive values (int, float, str).

“Each signal can contain only single value, and you can only differentiate between signals using signal.value, as the signal name is not accessible in the handler.”

import types
signals = types.SimpleNamespace()
signals.NEW_HIGHEST_PRICE = "NewHighestPriceReached"
signals.NEW_LOWEST_PRICE  = "NewLowestPriceReached"
 
# Subscribe
self.subscribe_signal(signals.NEW_HIGHEST_PRICE)
self.subscribe_signal(signals.NEW_LOWEST_PRICE)
 
# Publish
self.publish_signal(
    name=signals.NEW_HIGHEST_PRICE,
    value=signals.NEW_HIGHEST_PRICE,
    ts_event=bar.ts_event,
)
 
# Handler (FIXED name: on_signal)
def on_signal(self, signal):
    match signal.value:
        case "NewHighestPriceReached":
            self.log.info("New highest price reached")
        case "NewLowestPriceReached":
            self.log.info("New lowest price reached")

The handler sees only signal.value. Encode the signal kind into the value itself, or use richer custom-data subclasses.

Topic taxonomy

The framework derives topic names from data types and identifiers. Examples documented across the architecture and data pages:

SourceTopic shape
Data dispatchdata.quotes.{venue}.{symbol} (e.g. data.quotes.BINANCE.BTCUSDT-PERP)
Trade ticksdata.trades.{venue}.{symbol}
Barsdata.bars.{bar_type}
Custom data via publish_dataderived from DataType(cls, metadata={...}) - different metadata = different topic
User raw topicsarbitrary strings via msgbus.publish(topic, msg)

Practical rule: prefer Actor-based publishing for anything that needs to survive Redis serialization or replay. Raw msgbus.publish is for one-off intra-process glue where you accept the typo risk and don’t need persistence.

Ordering and determinism

The message bus itself does not impose ordering - the single-threaded kernel core does. From nautilus-architecture.md:

“Within a node, the kernel consumes and dispatches messages on a single thread. The kernel encompasses: the MessageBus and actor callback dispatch, strategy logic and order management, risk engine checks and execution coordination, cache reads and writes.”

“This single-threaded core provides deterministic event ordering and helps maintain backtest-live parity, though live inputs and latency can still cause behavioral differences.”

Concrete ordering guarantees:

  1. No interleaving inside a callback. A handler runs to completion before the next message is dispatched. No re-entrancy, no thread-switch surprises.
  2. Cache-then-publish for data. The DataEngine writes Cache before publishing on the bus, so any subscriber’s first read of cache inside its handler returns the value that triggered it. (Live execution events apply asynchronously and may show “a brief delay before Cache reflects them” - per nautilus-events.md.)
  3. Stable-sort by ts_init in backtest. The DataEngine orders historical replay by ts_init with stable sort (per nautilus-data.md). Identical inputs → identical event sequence across runs.
  4. Immutability post-publish. From the doc: “Once a message is created, its fields must not be mutated. This includes container fields such as params maps.” Replay, debugging, and audit depend on this.

Immutability rationale (verbatim)

“Immutable messages keep every consumer seeing the same input, preserve what was true at emission time, and remove a class of shared-state races. Replay, debugging, and audit all depend on messages remaining stable after dispatch.”

Three ownership rules follow:

  • “Caller-supplied request options stay on the message.”
  • “Response metadata returned to the caller stays on the response.”
  • “Component workflow state (bounded date ranges, grouping state, replay cursors, counters, processing flags) stays in component-owned context keyed by message or request ID.”

“When a component needs a derived message, it creates a new one with the required values instead of rewriting the original.”

This is the key contract MK2 currently violates by mutating in-flight dicts between the scoring engine and the position manager.

Backpressure and queueing

The MessageBus docs do not explicitly document in-process backpressure mechanisms or queue depth limits - because the in-process bus is synchronous dispatch on the single kernel thread. There is no queue between in-process publisher and subscriber: publish() walks the subscriber list and invokes each handler inline. A slow handler blocks the kernel, period.

The two places queueing genuinely happens:

  1. MPSC channel to the Redis writer thread. Outgoing-to-Redis messages are serialized and pushed onto an MPSC channel; a Rust thread drains the channel and writes to Redis Streams. The doc:

    “When a backing database (or any other compatible technology) is configured, all outgoing messages are first serialized, then transmitted via a Multiple-Producer Single-Consumer (MPSC) channel to a separate thread (implemented in Rust). In this separate thread, the message is written to its final destination, which is presently Redis streams.” “Offloading I/O to a separate thread keeps the main thread unblocked.”

  2. autotrim_mins on Redis streams caps stream growth - not backpressure per se, but the storage-side bound. “The current Redis implementation will maintain the autotrim_mins as a maximum width (plus roughly a minute, as streams are trimmed no more than once per minute).”

If a handler is slow on the kernel thread, the only mitigation is “do less work in the handler” or “kick to a background thread + re-enter via the bus.”

Wildcard subscriptions

The MessageBus concept page does not explicitly document an in-process wildcard subscription syntax (e.g. data.quotes.*). What it does say:

“Redis does not support wildcard stream topics. For better compatibility with Redis, it is recommended to set this option [stream_per_topic] to False.”

That comment is about the external Redis stream layout, not in-process subscription patterns. Implication: when external streaming is configured with stream_per_topic=False, the consumer reads one combined stream rather than per-topic streams - no wildcard listening on the Redis side.

For in-process “subscribe to everything” patterns, the supported route is the Actor lifecycle:

  • on_event(event) is the catch-all for any order/position/account/time event the framework emits (per nautilus-events.md).
  • on_data(data) is the catch-all for custom Data subclasses.
  • A combination of these on a logging Actor effectively gives you “subscribe to everything” for audit-log purposes - without requiring a topic-string wildcard.

Practical answer for the audit-log Actor pattern (Cortana MK3): override on_event + on_data on a single AuditLogger(Actor) and you observe every event and every custom data the framework dispatches. No wildcard topic syntax needed; the handler hierarchy provides the catch-all.

External MessageBus integration

This is the cross-process / cross-host story. Redis Streams is the only documented backing. No Kafka integration is documented on this page (the doc explicitly mentions only Redis).

Why externalize?

  • Survive process restarts (crash-only recovery - see nautilus-architecture.md Crash-only design).
  • Out-of-process subscribers (dashboards, monitors, replicators, brain-loggers).
  • Producer/Consumer fan-out - the multi-tenant pattern (see below).

Backing technology

“Redis is currently supported for all serializable messages which are published externally. The minimum supported Redis version is 6.2 (required for streams functionality).”

Serialization

Two encodings:

  • msgpack (default) - “optimal serialization and memory performance.”
  • json - “human-readable.”

Built-in serializable types: all Nautilus built-ins (serialized as dict[str, Any]) plus Python primitives (str, int, float, bool, bytes).

For custom types, register via:

def register_serializable_type(
    cls,
    to_dict: Callable[[Any], dict[str, Any]],
    from_dict: Callable[[dict[str, Any]], Any],
):
    ...

(See nautilus-data.md for the parallel register_arrow registration that enables Parquet catalog persistence.)

Stream key format

Structure: trader:{trader_id}:{instance_id}:{streams_prefix}

Each segment is independently toggleable via MessageBusConfig:

OptionDefaultEffect
use_trader_prefixTruePrepend trader:
use_trader_idTrueInclude the node’s trader ID
use_instance_idFalseInclude UUIDv4 per-node instance ID - “useful when you need to track and identify traders across various streams in a multi-node trading system”
streams_prefix"streams"Final segment, application-controlled
stream_per_topicconfigurableIf True, separate stream per topic. Disable for Redis because “Redis does not support wildcard stream topics.”

Configuration shape

from nautilus_trader.config import MessageBusConfig, DatabaseConfig
from nautilus_trader.model.data import QuoteTick, TradeTick
 
message_bus = MessageBusConfig(
    database=DatabaseConfig(),                    # defaults to local Redis
    encoding="msgpack",                           # or "json"
    timestamps_as_iso8601=True,                   # default: nanosecond ints
    buffer_interval_ms=100,
    autotrim_mins=30,                             # max stream age
    use_trader_prefix=True,
    use_trader_id=True,
    use_instance_id=False,
    streams_prefix="streams",
    types_filter=[QuoteTick, TradeTick],          # exclude high-frequency types
)

Types filter - keep high-frequency data off external streams

“When messages are published on the message bus, they are serialized and written to a stream if a backing for the message bus is configured and enabled. To prevent flooding the stream with data like high-frequency quotes, you may filter out certain types of messages from external publication.”

“To enable this filtering mechanism, pass a list of type objects to the types_filter parameter in the message bus configuration, specifying which types of messages should be excluded from external publication.”

Critical for Cortana: UW flow alerts at sub-second cadence will flood Redis if every alert hits the external stream. Either filter out the raw UWFlowAlert and only stream derived ScoringEvent, or accept the firehose and rely on autotrim_mins.

Producer / Consumer pattern (the multi-tenant answer)

This is the page’s most load-bearing section for Cortana’s multi-tenant question. Verbatim concepts:

  • Internal message bus - within a single TradingNode.
  • Producer node - publishes to an external stream.
  • Consumer node - listens to external streams.

Producer node configuration

message_bus = MessageBusConfig(
    database=DatabaseConfig(
        connection_timeout=2,
        response_timeout=2,
    ),
    use_trader_id=False,
    use_trader_prefix=False,
    use_instance_id=False,
    streams_prefix="binance",
    stream_per_topic=False,
    autotrim_mins=30,
)

Rationale (from the doc): “ensure a simple and predictable stream key that the consumer nodes can register for.”

Consumer node configuration

data_engine = LiveDataEngineConfig(
    external_clients=[ClientId("BINANCE_EXT")],
)
 
message_bus = MessageBusConfig(
    database=DatabaseConfig(
        connection_timeout=2,
        response_timeout=2,
    ),
    external_streams=["binance"],
)

“The DataEngine will filter out subscription commands for these clients, ensuring that the external streaming provides the necessary data for any subscriptions to these clients.”

In other words: the consumer node behaves as if it had a local DataClient named BINANCE_EXT, but the data actually arrives via Redis from a separate producer process. Subscriptions to that client ID are silently routed to the external stream consumer.

This is the canonical pattern for sharing one upstream data source across many trading processes.

Backtest vs live behavior

The bus interface is identical across environments. What differs:

AspectBacktestLive
Dispatch threadSingle kernel threadSingle kernel thread (same)
OrderingStable-sort by ts_init over all dataReal-time arrival; ts_init is local creation time
Cache visibility for dataCache-then-publish (synchronous)Cache-then-publish for data; execution events Cache update is async
Reconciliation eventsNone - simulator owns truthLiveExecutionEngine may synthesize events with reconciliation=True
Redis backingOptional (rarely used in backtest)Recommended for crash-only recovery
request_* historicalServed from BacktestEngine data storeServed by adapter (REST / catalog / cache)

Per nautilus-architecture.md: “Live trading involves real financial risk.” The bus itself doesn’t change - but live introduces non-determinism via wall-clock arrival times and venue-side state.

Performance characteristics

The MessageBus page does not publish benchmark numbers. What it does commit to:

  • Single-threaded dispatch - no lock contention inside the kernel.
  • MPSC offload for I/O - Redis writes never block the trading thread.
  • MessagePack default encoding for “optimal serialization and memory performance.”
  • Buffer interval (buffer_interval_ms, default 100) - coalesces external writes to reduce per-message Redis overhead.
  • Auto-trim (autotrim_mins) keeps Redis memory bounded.

For absolute throughput numbers, the architecture page leans on the LMAX-disruptor lineage:

“Of interest is the LMAX exchange architecture, which achieves award winning performance running on a single thread.”

LMAX hit ~6M txn/sec on a single thread in 2010 hardware. Nautilus inherits the pattern (not the framework). For Cortana’s scale (sub-second UW alerts + SPY 0DTE), throughput is not the bottleneck - ML inference latency is.

Cortana MK3 implications

This is where MK2’s hand-rolled routing meets Nautilus’s typed pub/sub spine.

MK2 today: ad-hoc function calls + SQLite writes

MK2’s “bus” is a tangle of:

  • Direct method invocation between modules (scoring_engine.score()position_manager.handle_score()ibkr_client.submit_order()).
  • SQLite writes to decisions.db as the de facto audit log (which MK2 conflates with state - see nautilus-events.md “What is queryable vs what flows past”).
  • In-memory dicts shared across threads with no immutability guarantees.
  • File-based caches (UW chain snapshots) read by multiple modules with no ordering contract.

The 2026-05-06 stale spy_price bug is a direct consequence: a strategy module read a cached price before the cache had been updated by the data ingest module, because there was no enforced ordering between cache write and publish.

MK3 on Nautilus: bus-spine routing

MK2 routingNautilus equivalent
scoring_engine.score(bar) direct callScoringActor.on_bar(bar)publish_data(ScoringEvent, …)
position_manager.handle_score(score) direct callCortanaStrategy.on_data(ScoringEvent) (subscribed)
ibkr_client.submit_order(order) direct callself.submit_order(order) → RiskEngine → ExecutionClient (all bus-mediated)
decisions.db SQLite audit tableAuditLogger(Actor).on_event/on_data → Parquet / Redis stream
Cross-module dict mutationForbidden - every message immutable post-publish
File-based UW snapshot cacheCache.add(key, bytes) keyed by instrument; updated by DataClient before publish

(a) Cache-then-publish race elimination

Nautilus enforces by construction what MK2 tried (and failed) to enforce by discipline. From nautilus-architecture.md:

“For quotes, trades, and bars the cache-then-publish order means your strategy handler can always read the latest value from the cache.”

In MK3, the spy_price stale-cache bug is structurally impossible: the DataEngine writes Cache before publishing on the bus, the kernel dispatches on a single thread, and the handler runs to completion before the next message is dispatched. There is no race window.

(b) Audit trail via wildcard-equivalent subscriber

MK2 audit trail = SQLite tables, one per row class, written from the module that produced the row. Reconciling these tables back into a causal sequence is a chronic source of drift.

MK3 audit trail = one AuditLogger(Actor) that overrides:

class AuditLogger(Actor):
    def on_start(self):
        # Subscribe to every Cortana-defined custom data type
        self.subscribe_data(ScoringEvent)
        self.subscribe_data(GateDecision)
        self.subscribe_data(RegimeChange)
        # … all custom Cortana types
 
    def on_event(self, event):
        # Catches every framework event - orders, positions, accounts, timers
        self._sink.write(event)
 
    def on_data(self, data):
        # Catches every custom Cortana event we subscribed to above
        self._sink.write(data)

on_event is the framework’s catch-all dispatch level (see nautilus-events.md “Handler dispatch - specific to generic”). The handler hierarchy is the wildcard - no topic-string * needed.

The _sink.write(...) target is ParquetDataCatalog for permanent storage (per nautilus-data.md) or a Redis stream for live out-of-process consumers.

This collapses MK2’s N audit tables into one Parquet file partitioned by event type and date, with replay determinism inherited from the framework.

(c) Multi-tenant fan-out - Producer/Consumer external streams

This is the Cortana spike Step 7.5 question, and the doc gives a direct answer.

Architecture:

                    ┌───────────────────────────────┐
                    │ UW WebSocket (single upstream)│
                    └───────────────┬───────────────┘
                                    ▼
                    ┌───────────────────────────────┐
                    │  Producer TradingNode         │
                    │  - UWDataClient → DataEngine  │
                    │  - ScoringActor               │
                    │  - publishes ScoringEvent     │
                    │    onto external Redis stream │
                    │  streams_prefix="cortana_uw"  │
                    │  stream_per_topic=False       │
                    └───────────────┬───────────────┘
                                    ▼
                            ┌───────────────┐
                            │  Redis Stream │
                            │  cortana_uw   │
                            └───────┬───────┘
                                    │
            ┌───────────────────────┼───────────────────────┐
            ▼                       ▼                       ▼
    ┌──────────────┐        ┌──────────────┐        ┌──────────────┐
    │ Tenant A     │        │ Tenant B     │        │ Tenant C     │
    │ TradingNode  │        │ TradingNode  │        │ TradingNode  │
    │ external_    │        │ external_    │        │ external_    │
    │ streams=     │        │ streams=     │        │ streams=     │
    │ ["cortana_uw"]│       │ ["cortana_uw"]│       │ ["cortana_uw"]│
    │ + own IBKR   │        │ + own IBKR   │        │ + own IBKR   │
    │ ExecClient   │        │ ExecClient   │        │ ExecClient   │
    └──────────────┘        └──────────────┘        └──────────────┘

Each tenant runs its own TradingNode process (per nautilus-architecture.md: “Running multiple TradingNode or BacktestNode instances concurrently in the same process is not supported due to global singleton state”). Each tenant has its own IBKR credentials in its own process. The shared upstream UW WebSocket is held by one producer process and republished onto a single external Redis stream that all tenants consume.

Why this answers the multi-tenant question

  • One upstream UW subscription, N downstream tenant strategies. The producer pays the WebSocket cost once. Tenants pay only Redis read.
  • Each tenant’s bus is its own. The producer’s ScoringEvent lands on every tenant’s internal bus as if produced by a local DataClient (BINANCE_EXT style - except renamed CORTANA_UW_EXT). Tenant strategies subscribe with the framework’s normal subscribe_data(ScoringEvent) call.
  • No bus forking. Nautilus’s core does not need modification - the Producer/Consumer pattern is documented and supported.
  • Per-tenant isolation is at the process boundary. Each tenant’s TradingNode owns its own Cache, its own ExecutionEngine, its own IBKR credentials, its own Redis cache database keyspace. A bug in tenant A’s strategy cannot affect tenant B’s positions.

Open questions for the spike

  1. Per-tenant trader_id/streams_prefix keying. Each tenant’s own internal events (orders, positions, fills) need a stream key that distinguishes them. The default trader:{trader_id}:{instance_id}:streams format does this - set trader_id = tenant_id per tenant.
  2. External stream filtering. The producer should publish only the events tenants care about (e.g., ScoringEvent, RegimeChange) - not the raw UWFlowAlert firehose. Use types_filter to drop high-frequency raw types.
  3. Replay scope. When tenant A wants to replay yesterday, do they replay from the shared external Parquet catalog (producer-written) or from their own per-tenant catalog of decisions? Both are workable; the spike should pick one.
  4. Producer redundancy. If the producer process dies, all tenants stop getting new signals. Need a supervisor or hot-standby producer for production deployment.

Single bus or per-tenant bus?

Each tenant has its own internal bus (one per TradingNode process). The Producer/Consumer pattern bridges between the producer’s internal bus and each consumer’s internal bus via Redis Streams. There is no “single global bus” across tenants - that would violate Nautilus’s process-singleton constraint.

The mental model: every TradingNode is a complete kernel-with-bus. The external Redis stream is the integration seam between them, not a shared bus.

Wildcard subscriptions - answer for audit-log Actor

The doc does not document an in-process topic-string wildcard subscription syntax. The framework’s catch-all dispatch is achieved through the handler hierarchy:

  • on_event catches any framework Event (orders, positions, accounts, timers).
  • on_data catches any custom Data subclass the Actor explicitly subscribed to.

For a true “subscribe to every topic,” the only documented surface is the raw self.msgbus.subscribe(topic, handler) API, which requires an exact topic string. The audit-log Actor pattern works because on_event + on_data together are sufficient - no wildcard needed.

If future Cortana needs require true wildcard pub/sub (e.g., debug topic streaming for development), that’s a Codex investigation into the underlying MessageBus.subscribe Rust implementation, not a documented public API.

Caveats and gotchas

  • Manual topic tracking. Low-level pub/sub on string topics has no type-system protection - typos silently drop messages. Use Actor-based publishing (Style 2 or 3) for anything that matters.
  • on_data and on_signal handler names are fixed. You cannot name them arbitrarily and have them auto-dispatch. Multiple custom data types in the same Actor share one on_data and discriminate via isinstance.
  • on_save / on_load are Strategy-only. Actors persist via Cache.add
    • Redis backing, not via lifecycle save/load.
  • MessageBus is thread-local. Cross-thread delivery uses MPSC channels. Don’t try to share an MessageBus reference across threads.
  • Live execution events lag Cache. “you might see a brief delay between an event and its appearance in the Cache.” Use the event payload directly; don’t re-read Cache mid-handler for execution-event-driven state.
  • External streaming is Redis-only. No documented Kafka, NATS, or RabbitMQ backing. If Cortana needs Kafka for SaaS, it’s a Codex/contributor build.
  • stream_per_topic=True breaks Redis listening. Redis Streams have no wildcard topic match; multi-stream listening requires separate consumer groups per stream. Keep stream_per_topic=False for the producer/consumer pattern.
  • Buffered Redis writes (default 100ms). External consumers see events with up to ~100ms additional latency vs the producer’s internal bus. Tune buffer_interval_ms for latency-sensitive consumers, accept higher Redis load.

When this concept applies

  • Designing the inter-component routing layer for MK3.
  • Wiring custom Cortana events (ScoringEvent, GateDecision, etc.) onto a typed pub/sub spine.
  • Architecting multi-tenant fan-out (Producer/Consumer external streams).
  • Replacing MK2’s decisions.db SQLite audit trail with a single catch-all Actor + Parquet sink.
  • Wiring the dashboard or any out-of-process consumer.

When it breaks / does not apply

  • Bus-bridging Kafka or NATS. Not supported out of the box. Custom Rust work required.
  • Cross-tenant atomic operations. The bus is per-process; cross-tenant coordination requires application-level logic (e.g., a coordinator service on the Redis stream side).
  • Hard wildcard subscriptions on arbitrary topic strings. Use on_event / on_data catch-all dispatch instead.
  • Multi-million-msg/sec benchmarks. Not the target - the kernel is single-threaded by design. If you need that, you’re outside Nautilus’s scope.

See Also


Timeline

  • 2026-05-07 | Cody - Filed during pre-spike concept mastery sweep batch 2.