Event spine

Every write travels one spine

A typed WebSocket request becomes a command. A pure function decides it. One SQLite transaction persists the events, folds the read models, and records the receipt. Only then do reactors perform the side effects. This page walks that path end to end, with the code that enforces it.

trading.execution.requested via dispatchCommand decideOrchestrationCommand pure decision eventStore.append one SQLite transaction streamDomainEvents PubSub fan-out TradingMissionReactor performs the write recordExecutionSettled receipts.settle, last
THE REAL PATH, REAL NAMES: COMMAND IN, EVENT OUT, REACTOR ANSWERS
45
event types in the OrchestrationEvent union, one shared envelope
1
worker fiber drains the command queue, so commits are serial
80
polling queries per execution, replaced by one settle-last latch
16
bytes in the deterministic cloid, derived, never minted randomly

Sources: packages/contracts/src/orchestration.ts:1553, apps/server/src/orchestration/Layers/OrchestrationEngine.ts:341, apps/server/src/trading/TradingExecutionReceipts.ts:4-8, packages/trading-contracts/src/execution.ts:78-80.

One request's journey

Follow one command from the socket to the side effect. Every hop is a named function in the repository, and the order below is the order the code runs in.

HOP 01 · SOCKET

A typed request, not a string

The client sends a member of the ClientOrchestrationCommand union through WsOrchestrationDispatchCommandRpc: the payload is the command, the success type is a single {sequence}, and the errors are a typed union. Every RPC on the socket belongs to one WsRpcGroup, so the wire has one vocabulary, schema-checked at both ends.

packages/contracts/src/rpc.ts:906-913 packages/contracts/src/rpc.ts:1219

HOP 02 · INTAKE

A Deferred and an envelope

The engine's dispatch(command, {origin}) creates a Deferred, offers a CommandEnvelope (the command, the client origin, the result cell, a start timestamp) onto the queue, and awaits. The caller's eventual answer is the persisted sequence number, nothing looser.

apps/server/src/orchestration/Layers/OrchestrationEngine.ts:56-61 apps/server/src/orchestration/Layers/OrchestrationEngine.ts:350-360

HOP 03 · ORDERING

One queue, one worker

The engine creates a Queue.unbounded<CommandEnvelope> for commands and a PubSub.unbounded<OrchestrationEvent> for committed events. One Effect fiber takes commands from the queue, so they commit one at a time in arrival order. The single worker enforces this ordering without a separate application lock.

apps/server/src/orchestration/Layers/OrchestrationEngine.ts:101-102 apps/server/src/orchestration/Layers/OrchestrationEngine.ts:341

HOP 04 · RECEIPT GATE

Idempotency before decision

Before deciding anything, the worker reads commandReceiptRepository.getByCommandId. A receipt for an accepted command returns the original sequence, so a replay is a no-op with the same answer. A commandId that was rejected once fails forever with OrchestrationCommandPreviouslyRejectedError. The code is blunt about scope: "A receipt only proves this exact command was handled", so the same commandId aimed at a different aggregate is a conflict, not a success.

apps/server/src/orchestration/Layers/OrchestrationEngine.ts:149-177 apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts:26-34

HOP 05 · DECISION

A pure function decides

decideOrchestrationCommand({command, readModel}) maps the command to its event or events, and does nothing else: no clock, no exchange, no write. Trading commands become trading.*-requested events. The risk-control case deliberately resolves no target status, because "what the mission becomes depends on what the exchange does, so the reactor decides it after the fact".

apps/server/src/orchestration/decider.ts:218 apps/server/src/orchestration/decider.ts:1475-1478

HOP 06 · ATTRIBUTION

Origin is an engine concern

The engine, not the decider, stamps the dispatching client's origin onto every event the command produced. The comment states the rule: "The decider stays pure; attribution is an engine concern."

apps/server/src/orchestration/Layers/OrchestrationEngine.ts:195-203

HOP 07 · COMMIT

One transaction, four writes

Inside one sql.withTransaction, for each event: eventStore.append persists it, projectEvent folds it onto the in-memory read model, projectionPipeline.projectEvent writes the durable SQLite projections, and finally the accepted receipt is upserted with resultSequence set to the last event's sequence. A command that produced no events is itself an invariant error. Because the receipt shares the transaction, an accepted receipt exists exactly when its events do.

apps/server/src/orchestration/Layers/OrchestrationEngine.ts:204-241 apps/server/src/orchestration/Layers/OrchestrationEngine.ts:218-222

HOP 08 · FAN-OUT AND SIDE EFFECTS

Reactors answer

Committed events publish to the PubSub, and consumers do not share a queue: each access to streamDomainEvents creates a fresh subscription, so the WS push path, provider ingestion, checkpointing, and the trading reactor each receive every event independently. TradingMissionReactor filters the stream down to its eight HANDLED_EVENT_TYPES and enqueues matches into a drainable worker, where the side effects run. What happens inside that worker is the next page, Execution.

apps/server/src/orchestration/Layers/OrchestrationEngine.ts:251-252 apps/server/src/orchestration/Layers/OrchestrationEngine.ts:365-370 apps/server/src/trading/TradingMissionReactor.ts:115-124 apps/server/src/trading/TradingMissionReactor.ts:2922-2930

Requested is a question, not an answer

The decider never pretends a side effect happened. Press pause in the UI and the event on the wire is trading.mission-control-requested, a request, and its contract says so: "the request itself is not a promise that it will be accepted". The reactor then runs the transition through TradingMissionService, where the status legality table and the one-active-mission invariant are enforced, and only after that write succeeds does it dispatch the internal trading.mission.status-set command whose event the projector folds.

"That ordering is the whole point. The UI never sees a status the domain refused, and mission state still reaches clients over T3's ordered WS push path rather than a side channel." apps/server/src/trading/TradingMissionReactor.ts:10-12

Creation works the same way. The mission-create payload's contract states: "Nothing is persisted yet: TradingMissionReactor performs the write and then raises trading.mission.status-set, so the projection only ever reflects state the domain accepted." And a refusal is not an exception path: "A refused control or execution is a normal outcome, not a crash: the projection keeps the state the domain still holds."

This ordering requires every mission event to join an ordered stream. Each trading command names the thread bound to the mission's agent runtime, called the harness in code. The mission events then use the thread's existing WebSocket stream to reach the UI. Manual orders have no thread, so they use a synthetic per-account mission aggregate, manual:{accountId}, which preserves per-account ordering.

REQUESTED vs STATUS-SET, ONE CONTROL PRESS
operator presses pause mission-control-requested a question, persisted first TradingMissionService legality + one-active-mission mission.status-set projector folds, WS pushes accepted refused refused: no status-set, projection keeps the state the domain still holds

The green path is the only path to a status the UI can see. apps/server/src/trading/TradingMissionReactor.ts:1-18 packages/contracts/src/trading.ts:1574-1578

Idempotency, layered four ways

Retries are certain: WebSocket reconnects, provider replays, watchdog ticks. Each layer below makes a specific replay harmless, at the layer where its damage would occur.

Sticky command receipts

A receipt row is {commandId, aggregateKind, aggregateId, acceptedAt, resultSequence, status, error}. Replay an accepted command and you get the original sequence back. Replay a rejected one and it fails again, forever: rejection is sticky, so a bad command cannot be retried into existence. A receipt for aggregate A replayed against aggregate B is a conflict error, because the receipt proves only that exact command on that exact aggregate was handled.

status: accepted | rejected

apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts:26-34 apps/server/src/orchestration/Layers/OrchestrationEngine.ts:149-177

Deterministic cloids

The exchange correlation id is derived from SHA-256 over (missionId, executionSequence, actionType). The code takes the first 16 bytes and encodes them as a 34-character hexadecimal string with a 0x prefix. Retries of the same action reuse the same pair and therefore the same cloid. The prefix is load-bearing: an unprefixed cloid is accepted on submission and then silently stored as null, which is how one entry once ended up unjoinable to its own fill.

SHA-256(missionId, executionSequence, actionType)[0:16]

packages/trading-contracts/src/execution.ts:49-88

The inbox will not wake twice

Observed facts persist as inbox events deduplicated on (missionId, deduplicationKey) under the unique index idx_trading_event_inbox_dedupe, "so reconnects and replays cannot generate a second wake-up". An event moves pending → included_in_run → consumed, and claimPending atomically flips the set a run owns, so a run always sees exactly the events it started with.

apps/server/src/trading/TradingEventInbox.ts:5-12 apps/server/src/trading/TradingEventInbox.ts:58-67

The latch settles last

The tool that requested an execution waits on one in-process latch per (mission, executionSequence). The reactor opens the latch only after every durable update completes. Opening it earlier could make the waiter report a completed execution as still in flight. The latch remains available for 30 seconds for late waiters, but it never replaces the persisted result.

250 ms × 20 s = up to 80 reads1 latch, 1 read

apps/server/src/trading/TradingMissionReactor.ts:2215-2227 apps/server/src/trading/TradingExecutionReceipts.ts:1-23 apps/server/src/trading/TradingExecutionReceipts.ts:64

The event envelope

The OrchestrationEvent union has 45 member types, from project.created to trading.execution-requested, and every member carries the same nine base fields. The envelope is what makes the stream auditable years later: what happened, to what, why, and who asked.

sequence
A global NonNegativeInt, one counter across all aggregates. "Latest" is one number, and the engine's latestSequence reads it straight off the folded read model.
eventId
Identity of this fact. Unique per event, minted once at decision time.
aggregateKind
One of project, thread, or mission. Routing and idempotency receipts are scoped by it.
aggregateId
A branded union of ProjectId | ThreadId | TradingMissionId. Manual orders, which have no thread, ride the synthetic mission aggregate manual:{accountId}.
occurredAt
ISO timestamp of the fact. Commands also carry one; intake canonicalizes client timestamps to server receipt time before the decider ever runs.
commandId
The command that caused this event. Nullable, because server-originated events have no command.
causationEventId
The event this event answers. This is the spine's audit chain: a status-set points back at the requested that caused it.
correlationId
Nullable, typed as CommandId in the schema, tying a related exchange of commands together.
metadata
Provider turn and item ids, adapter key, request id, ingest time, and origin {surface, appVersion}: "Stamped by the orchestration engine on client-dispatched commands; absent on provider/server-originated events and on commands from clients too old to report it."

packages/contracts/src/orchestration.ts:1541-1551 packages/contracts/src/orchestration.ts:1520-1539 apps/server/src/orchestration/Layers/OrchestrationEngine.ts:75-81 apps/server/src/orchestration/Layers/OrchestrationEngine.ts:371-375 apps/server/src/orchestration/Normalizer.ts:24-28 apps/server/src/ws.ts:1454

Two classes of command, two answers

Not every operation belongs on the event spine. Use a direct RPC when the operator needs an immediate result or refusal. For example, preview returns its refusal in the RPC response, while placement uses an event-sourced command. The same reasoning appears throughout the client wiring: reviseTradingPlan is an RPC command because "the operator needs the answer, not an acknowledgement", and a plan-document activation needs its refusal (stale_hash) on screen before the operator believes the plan is active.

When the write must be durable, ordered, and replayable, it is a dispatched command. The §14.7 user controls (pause, resume, revoke, risk control) dispatch straight to the server, and that directness is the point: "no harness turn stands between the press and the action." The dispatch is acknowledged with a sequence, the outcome arrives later as an event on the feed, and the UI tracks the press as pending until the result does. These controls keep working when the provider process is unavailable. Safety invariants explains that independence.

The manual order ticket uses both classes at once: preview and manual close are RPCs, "the user needs the refusal on screen; the place itself is an event-sourced dispatch, answered through the feed." The client builders that mint commandIds for these dispatches are covered in Web & desktop.

SAME SOCKET, TWO CADENCES
RPC: ANSWER NOW, REFUSAL ON SCREEN
  • previewTradingOrder
  • closeTradingManualPosition
  • armTradingWatch / cancelTradingWatch
  • reviseTradingPlan
  • activateTradingPlanDocument

Question answered in the response. Nothing persisted.

COMMAND: EVENT LATER, WRITE SOURCED
  • missionControl (pause, resume, revoke)
  • riskControl (reduce, close, cancel entries)
  • missionCreate
  • trading.order.place

Acknowledged with a sequence, answered by an event the projector folds.

packages/contracts/src/trading.ts:1322-1327 packages/client-runtime/src/state/orchestration.ts:144-186

Built to change

An event store is a permanent record, so schema evolution is a compatibility discipline, not a migration you can run and forget. Three habits keep old bytes readable.

Optional, never sentinel

allocatedCapitalUsd is optional "so the two cases stay distinguishable in the event stream: a mission created without a stated capital records that it was created without one". The decider agrees: "Absent stays absent", because a default injected there would erase the distinction before the reactor ever reads it.

packages/contracts/src/trading.ts:1584-1590 apps/server/src/orchestration/decider.ts:1435-1437

Deprecated, still decodable

thread.workspace-mode-set remains in the union with the comment: "Deprecated: no emitter remains. Kept only so event stores written by older builds still decode; the projector ignores it." Old stores open; nothing new emits; the projector skips.

deletion would corrupt history

packages/contracts/src/orchestration.ts:1634-1640

Tolerance readers

Persisted JSON that predates a schema axis is read leniently: decodeMarketRef treats a bare "ETH" as the default venue, "because that is the only venue there has ever been". The venue enum itself is a one-member literal on purpose, so a second venue is "a data question rather than a rewrite".

packages/trading-contracts/src/primitives.ts:124-142 packages/trading-contracts/src/primitives.ts:75-85

What the spine buys

THE INVARIANT UNDER THE DESIGN

Everything that crosses a boundary is a typed contract, and every safety decision lives in a pure function or a deterministic service. The decider decides, the services enforce, the reactor sequences. No rule lives only in prompt prose or client state, so neither the model nor the UI can bypass a gate.

The same spine carries the rest of the system. The budget gates the reactor consults are pure equations, covered in Risk control. Local tables converge to exchange truth through reconciliation triggers that ride the same event flow, covered in Reconciliation. The schema families and their producers and consumers are catalogued in Contracts, and the wallet permitted to sign what the reactor submits is the subject of Signer & authority.

Go deeper: the spine itself

  • apps/server/src/orchestration/Layers/OrchestrationEngine.ts queue, worker fiber, receipt gate, transaction, fan-out
  • apps/server/src/orchestration/decider.ts the pure command-to-event mapping, trading cases at 1413-1758
  • apps/server/src/orchestration/projector.ts the in-memory fold every command is decided against
  • apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts the receipt contract

Go deeper: reactors and receipts

  • apps/server/src/trading/TradingMissionReactor.ts answers every requested event, settles last (2215-2227)
  • apps/server/src/trading/TradingExecutionReceipts.ts the latch, its 30-second retention, its philosophy
  • apps/server/src/trading/TradingEventInbox.ts deduplicated observed facts, claimed per run
  • packages/trading-contracts/src/execution.ts cloid determinism and the execution intent