0017. OrderTicket aggregate + OMS / EMS layering inside execution_management

#Context

execution_management was originally a single layer: the Order_process_manager saga reserved cash with Account, then drove the broker leg through fills and rejections directly. This conflated two concerns at very different levels of detail:

A single saga that owned both ended up sprawling. The slicing concern wants an aggregate with rich invariants; the reservation concern wants a finite state machine. Forcing them into the same shape made each one worse.

The OMS / EMS distinction is the industry vocabulary for this split:

#Decision

#Split execution_management into OMS and EMS layers

Inside the same Bounded Context:

execution_management
├─ OMS layer
│  └─ Order_process_manager (saga)
│       Trade_intent_approved → Reserve → {Done | Compensated}
│
└─ EMS layer
   └─ OrderTicket aggregate + Placement entity + 6 strategies
        Open → Working → {Filled | Cancelled | Failed}

The saga's scope is narrowed to reservation only. Once Amount_reserved lands, the saga reaches a terminal Done state and the OrderTicket aggregate takes over.

#OrderTicket aggregate

OrderTicket.t
├─ ticket_id           (Ticket_id.t, derived from saga reservation_id)
├─ intent              (Trade_intent.t — what to execute)
├─ directive           (Execution_directive.t — how to execute)
├─ strategy            (Strategies.Strategy.t — closed variant)
├─ placements          (Placement.t list — fan-out of the strategy)
├─ progress            (Progress.t — Σ filled, Σ fees, remaining)
└─ lifecycle           (Working | Cancelling | Filled | Cancelled | Failed)

Operations are pure functions returning t * event list. The aggregate enforces the global invariants — the strategy proposes and the aggregate disposes (see ADR 0016).

Placement is an Entity within OrderTicket (id + linear status lifecycle), not a Value Object — it accumulates state through its lifetime.

#Hand-off via in-process function port

The saga's terminal transition emits a Dispatch_open_ticket command. This does not go on the bus. The project's rule (ADR 0001) forbids a model inside a BC from publishing a Command to itself — a future Transactional Outbox would otherwise split the saga commit and the aggregate-open work into two transactions whose interleaving has no benefit and whose failure mode is recovery debt.

Instead, the factory wires a closure: on Dispatch_open_ticket, invoke Open_order_ticket_command_workflow.execute directly, in-process, under the same lock as the saga's transition. This mirrors the established ACL pattern where an inbound IE handler calls an own command_workflow directly.

saga.transition (Amount_reserved ev)
    → Done + [Dispatch_open_ticket{...}]
factory.dispatch (Dispatch_open_ticket cmd)
    → Open_order_ticket_command_workflow.execute cmd
        → OrderTicket.open_ticket
        → Strategy.init
        → Placement_dispatched events
        → publish via bus → broker.Submit_order_command

#Bridge: placement_id wire encoding

The aggregate mints local sequence ids per ticket (1, 2, 3, ...). The broker needs globally unique ids. The factory encodes:

wire_placement_id = ticket_id * 1_000_000 + local_seq

The encoding is reversible: inbound broker IEs are decoded back to ticket_id at the ACL boundary, and the apply_* command workflow finds the right aggregate by that key. The aggregate itself never sees the wire form.

#Broker-IE → aggregate command routing

Every broker IE that affects placement state crosses the ACL as an apply_placement_* command:

Inbound IE ACL handler Aggregate operation
Order_accepted order_accepted_..._handler on_placement_acknowledged
Order_filled order_filled_..._handler on_placement_fill
Order_rejected order_rejected_..._handler on_placement_rejection
Order_unreachable order_unreachable_..._handler on_placement_unreachable
Order_cancelled order_cancelled_..._handler on_placement_cancelled

The aggregate's terminal events fan out: Ev_ticket_completed, Ev_ticket_cancelled, Ev_ticket_failed each trigger Release_command to Account; the corresponding outbound IE is published independently for telemetry consumers.

#Consequences

Easier:

Harder:

To watch for:

#References