Skip to content

Event Flow

Gordon's IPC spine is a single NATS JetStream stream (gordon-bus) covering all hot paths between services. Producers write event rows to a Postgres outbox in the same transaction as their domain state change; a leader-elected publisher loop drains the outbox to NATS. Consumers attach to durable JetStream consumers keyed by stable per-service names. Postgres remains the durable state store; the bus is the delta channel.

Stream configuration

ParameterValue
Stream namegordon-bus
Subject filtermarket.klines.>, intents.>, risk.>, trading.>
Retentionlimits (age + size)
Max age168 h (7 days)
Max bytes768 MiB (local) / 1 GiB (production)
Storagefile
Discardold (drop oldest on cap hit; never block producers)

A single stream covers all subject hierarchies. Sharding by domain is a future operator decision — not the current topology.

Subject table

Subject patternProducerConsumer(s)Durable name
market.klines.binance.{spot|perp}.{symbol}.{tf}gordon-datagordon-botbot-{bot_id}-{symbol}-{tf}-klines
intents.executorgordon-botgordon-executorexecutor-default
risk.commandsgordon-riskgordon-executor, gordon-botexecutor-risk, bot-{bot_id}-risk
risk.events.{breaker}gordon-riskgordon-managermanager-risk
trading.fills.{bot_id}gordon-executor(consumers TBD)

Subject tokens are lowercase. Symbols stay uppercase inside the payload ("symbol": "BTCUSDT"); the subject token lower-cases the symbol (market.klines.binance.spot.btcusdt.1m).

Wire types

All bus payloads live in gordon_protocol::bus::* (crate gordon-protocol).

TypeSubjectSchema version
KlineEventmarket.klines.>1
OrderIntentEventintents.executor1
RiskCommandEventrisk.commands1
BreakerEventrisk.events.{breaker}1

Schema rules (load-bearing):

  1. First field is schema_version: u16 — numeric, never String.
  2. Every other field carries #[serde(default)] — a v_n consumer decodes a v_n+1 payload with the new field defaulted.
  3. Additive only — never rename, never remove. Renames go through one full major-release deprecation window.
  4. Adding a field bumps gordon-protocol minor; renaming or removing bumps major.

Event flow diagram

End-to-end trace: kline to console

  1. Binance pushes a 1m candle on the spot WebSocket to gordon-data.
  2. In a single transaction: INSERT market_data.spot_klines + NatsPublisher::publish_within(&mut tx, "market.klines.binance.spot.btcusdt.1m", ...) appends a bus.outbox row.
  3. The outbox publisher loop (leader via pg_try_advisory_lock) drains the row, publishes to JetStream with broker ack, then flips published_to_nats = TRUE.
  4. The bot's bot-{bot_id}-BTCUSDT-1m-klines consumer delivers to the strategy loop.
  5. Strategy emits an OrderIntent. The bot writes it in one transaction: INSERT trading.order_intents + publish_within("intents.executor", ...).
  6. gordon-executor's executor-default consumer reads, validates the lease fence, submits to Binance.
  7. Binance fills. Executor INSERT trading.trades (idempotent on trade_fingerprint). pg_notify('bot_events', id) fires.
  8. gordon-manager's LISTEN multiplexer hydrates the row, pushes a LiveEvent to the broadcaster, fans out to gordon-console over /ws.

Steps 1–6 traverse the bus. Step 7 stays on pg LISTEN/NOTIFY (envelope channels are row-hydration; they are intentionally not on the bus — same-process producer/consumer).

Outbox pattern

Producers never publish to NATS directly. Every emission is mediated by bus.outbox.

The pure NATS publish path has an unsolvable failure mode: the domain row commits, the publish fails, the world sees a state change with no event. The outbox writes the event row in the same transaction as the domain change — either both commit or both roll back. The publisher loop drains pg to NATS asynchronously and only flips published_to_nats = TRUE after the broker's ack. At-least-once end-to-end; consumer-side idempotency closes the loop.

Entry point: NatsPublisher::publish_within(&mut tx, ...). Using Publisher::publish for a domain dual-write is a correctness bug (ghost-event class).

Leader election

The outbox publisher loop uses pg_try_advisory_lock(0x0B05_0010_2026_0508):

  • Any service may run the loop. gordon-data is the production leader today.
  • Losers sleep 30 s and retry.
  • Drain batch: 100 rows. Active poll: 100 ms. Idle poll: 1 s. LISTEN bus_outbox_appended short-circuits the idle wait.
  • NATS publish failures back off exponentially (1 s to 30 s); after 5 minutes of sustained failure the leader releases the lock so a peer can take over.
  • The advisory lock is session-scoped — the loop pins a PoolConnection for the lock's lifetime.

Consumer contract

Every durable consumer follows the same contract:

  • ack_policy = explicit, ack_wait = 30s.
  • deliver_policy = all on first attach (replays the retention window); resumes from the saved cursor on restart.
  • max_deliver = -1 — at-least-once, with consumer-side idempotency (intent_id PK, breaker hydration idempotent, kline (open_time, symbol, tf) PK).
  • Outer reconnect loop with capped exponential backoff (500 ms to 30 s) wraps the inner pull loop.

What is NOT on the bus

Three categories are intentionally kept off the bus:

Manager-internal channels (10). Channels where both producer and consumer are gordon-manager (runs, equity_points, stack_health, source_freshness, etc.) stay on pg LISTEN/NOTIFY. Moving them to the bus adds infrastructure mass for zero architectural benefit — the producer and consumer are in the same process.

Browser WebSocket. gordon-manager fans out over a single multiplexed /ws to gordon-console. Browsers do not speak NATS. Manager is also the auth boundary: cookies, tokens, and rate limits live there.

gordon-data REST endpoints. /warmup, /klines, /healthz, /sources/health are operator and orchestration surfaces, not event flow.

Invariants

  • Every producer dual-writes: domain row + bus.outbox row in the same transaction.
  • schema_version: u16 is always the first field of every bus payload.
  • Schema evolution is additive-only — no renames, no removals.
  • Consumer names are stable and lowercase-hyphenated. Renaming abandons the JetStream cursor.
  • The pg backend in gordon-bus 2.x is a compiled-in rollback path. It is not a parity layer. Rollback is a full redeploy, not a hot swap.
  • Architecture — full topology diagram.
  • BFF Boundary — which traffic is stateful (bus / manager) vs stateless (NATS-WS direct).
  • Execution — intent-to-fill flow in detail.

Gordon — keep compounding without blowing up