0021. Intake gates (kill_switch, rate_limit) belong to pre_trade_risk

#Context

Two intake-time gates have lived in execution_management since ADR 0011 first introduced them:

Both gated Trade_intent_approved at saga start in EM's factory. After ADR 0020 extracted the saga into order_management, the gates had no natural home in either BC:

The right home was hiding in plain sight: pre_trade_risk.

#Decision

Move kill_switch and rate_limit (domain + tests + outbound IEs) into pre_trade_risk. PTR's Assess_trade_intent_command_workflow gains a gate check before the per-trade Assessment.assess:

PM   → Trade_intents_planned     → PTR
PTR   ↓
       try_intake?
         tripped (kill_switch) → publish Trade_submission_blocked
         throttled (rate_limit) → publish Trade_submission_blocked
         allowed →
           Assessment.assess →
             Approve → publish Trade_intent_approved → OM saga starts
             Reject  → publish Trade_intent_rejected (per-trade reason)

The gate enforcement at PTR has four advantages over EM:

  1. No wasted reservation. Gate fires before Trade_intent_approved is published; OM never receives an intent the gate would have refused; Account is never asked to reserve cash for a refused intent.

  2. PTR already owns equity-state. PTR subscribes to Reservation_filled (since the Risk_view feature) and its Risk_view domain holds the equity = cash + Σ qty × mark invariant. Adding kill_switch.update_equity to the existing handler is one line per call.

  3. PTR is the gate. ADR 0011 framed PTR as the single approver of trader intents. Per-trade risk limits already live there. Drawdown circuit and intake throttle are conceptually one kind of "approve / refuse" decision — they fit the same role.

  4. EM becomes the clean EMS layer. No intake plumbing, no equity tracking, no Reservation_filled subscription. EM owns the OrderTicket aggregate, the six strategies, and the broker dialog. Single responsibility.

#Two distinct signals

PTR now publishes three independent IE topics for intent outcomes:

Trade_submission_blocked is the new outbound for gate trips (moved over from EM). Trade_intent_rejected keeps its existing semantics — per-trade refusals only. The audit / SSE consumers subscribe to whichever subset they need.

Two semantics, two IEs, no overload. Saga consumers (OM) subscribe to Trade_intent_approved only and behave identically under either form of refusal: no saga is started.

#Mid-batch trip behavior

PM publishes Trade_intents_planned carrying N legs. PTR assesses each leg as a separate Assess_trade_intent_command. If kill_switch trips after leg 3 (because a Reservation_filled arrived between leg 3 and leg 4), legs 4..N are refused with trade_submission_blocked. The earlier-approved legs proceed — they were assessed when the gate was open, and stopping them mid-saga is not the gate's job (running sagas continue under any of the three placement options analysed in the design dialogue).

Kill_switch state is consulted under PTR's factory-level mutex, so the race between assessments and Reservation_filled-driven state updates is serialised.

#rate_limit interpretation

In the original monolith, rate_limit served two purposes simultaneously: "against runaway strategies" (intake throttle) and "respect broker API quotas" (submission throttle). With the new architecture (intent → ticket → fan-out of N broker submissions), these are different metrics and don't compose into one cap.

This ADR moves only the intake throttle to PTR — its historically-correct function. The original code gated at intake in check_gates before reservation, which is the intake-throttle semantic. The broker-API-quota use case is real (Finam, BCS publish rate limits) but belongs in a separate broker-dispatch throttle, plausibly inside EM or below the broker ACL; it is not part of this ADR.

#Consequences

Easier:

Harder:

To watch for:

#References