Skip to main content

Conductor gear

NOTE

The Conductor routes each request across a destination tree, correlates the reply, handles timeouts, and hands off across regions. It is validated by the payment-switch e2e and the Conductor Robot suites. It generalizes the Coat Check gear pattern; Coat Check stays as the lightweight option for single-leg field parking (see Related). A per-destination circuit breaker and peer heartbeats are [Roadmap] refinements, marked where they appear below.

The conductor Gear is the transaction switching layer of a payment mixer: it routes each request to one of several upstream connections, holds transaction state under a ticket while the request is in flight, matches the reply when it returns, and emits a timeout event when it does not.

Like an orchestra conductor, it directs which section plays (connection routing), cues entrances and keeps time (reply correlation and timeouts), and holds the ensemble together (transient transaction state).

Reference

The identity, ports, and configuration below are generated from the gear's manifest, so they stay in lockstep with the code.

Typeconductor
Categorylogic
Statusstable
Terminusopaque

Transaction switch: routes each request to a destination tree, correlates the reply under a ticket, and surfaces timeouts on the error port.

Ports

PortDirectionRoleSummary
ininputrequestRequests to route (from the local terminal path or a peer Conductor).
in_replyinputreplyStructured replies to match to their open tickets.
out_<name>output (dynamic)requestA request toward one destination (an uplink leg or a peer Conductor).
out_responseoutputresponseMatched, restored response toward the local origin.
out_response_<origin>output (dynamic)responseResponse toward the peer Conductor identified by the in-band origin stamp.
erroroutputerrorAbnormal outcomes (no route, no destination, timeout, unmatched reply) as the original message annotated with error.reason.

Configuration

FieldTypeRequiredDefaultDescription
correlation_keyarrayyes-Dotted-path or alias fields forming the unique per-transaction key (e.g. iso8583.field.11).
routesarrayyes-Ordered routing table: the first route whose match predicate holds selects the destination tree. A route with no match is the catch-all.
originstring-This Conductor's name in the mesh; required only when it hands requests to, or serves requests from, other Conductors.
park_fieldsarray-Optional fields to detach from the outbound leg and restore on the reply.
valetobject-Correlation-engine (ticket store) settings: store backend and ticket lifetimes.

Planned capabilities

  • Connection routing: named output ports, each wired to exactly one destination. Each route declares a destination tree: strategy nodes (failover, round_robin, least_loaded) compose freely, and leaves are output ports. A leaf is just a port, whether it leads to a local uplink leg or to another Conductor in a different region, so locality is a wiring concern, invisible to the tree. Arbitrary nesting expresses every policy without special cases: a least_loaded local pool above a cross-region leaf, a round_robin pool of remote regions under a failover node, and so on.
  • Reply correlation (always on): each in-flight request parks a ticket holding its return context, keyed by a configured correlation tuple, so the reply can be matched and the response routed home. Where tickets live is a configurable strategy, not a fixed shared store, see Ticket store strategy and Correlation and tickets.
  • Timeout handling: when a ticket's TTL expires without a matching reply, the Conductor emits the original request on its error port (it never authors a response itself), so a downstream gear can answer the terminal and originate a reversal. See Error handling.
  • Field parking (optional overlay, the Coat Check heritage): detach configured fields from a chosen downstream leg and reattach them on the reply. This is opt-in for flows that must keep specific fields off a specific leg; it is not the primary path (a switch must send the PAN to the scheme to authorize), and it is not tokenization, which substitutes a surrogate value by rule and is a separate, future gear. See Security and compliance.
  • Route matching: per-route predicates on decoded fields, with an explicit default/catch-all; see Error handling.

Ports

The port list and roles are in the generated Reference above. The notes below cover the semantics that table cannot: in and in_reply are fan-in (the local terminal path and a peer Conductor both feed them); out_response_<origin> carries responses for requests another Conductor handed off, keyed by the in-band origin stamp (see Peer response routing); and error carries the original fluxMsg annotated with error.reason and return context, never a synthesized response (a downstream gear authors any decline/reversal, see Error handling).

There is no separate port kind for remote peers. A destination is a destination; whether out_<name> reaches a local codec+IO leg or another Conductor's in is a wiring choice. "Peer" is a wiring pattern (wire one Conductor's out_<name> to another's in, and its response back toward the first), not a feature of the gear.

The port surface follows a general fluxrig principle: outputs scale with destinations, inputs scale with message roles. One output port feeds exactly one destination (wiring an output to several inputs would broadcast), so every destination and return path gets its own out.*. Any number of sources may feed one input port (fan-in merges safely), so the Conductor needs only one input per role: in for "route this request", in_reply for "match this reply", whether the source is a local leg or a neighbouring Conductor. The arrival port tells the gear the message's role; no payload inspection is needed.

Deployment constraints

  • The Conductor and the uplink I/O gears it drives MUST be co-deployed on the same Rack. This keeps the routing hop local and, just as important, is what lets availability sensing bind each destination to its local link-state (conn.up/conn.down): a destination that crosses the Rack boundary is treated as remote. Today that local hop runs over the standard NATS transport; an in-process fast lane (GoChannel) that shortens it from milliseconds to microseconds is on the roadmap and is an optimization, not a requirement. Cross-region traffic is explicit: an out_<name> wired to a Conductor on another Rack transits the Snake.
  • Correlation state is local to each Conductor by default; sharing it across instances is opt-in via the shared ticket store (see Ticket store strategy), not an always-on assumption.

Selection strategies

Each route's destination is a tree. Interior nodes are strategies; leaves are output ports. Evaluation starts at the root: a node picks one of its available children, recursing until a port is reached. A subtree is available when any port beneath it is; a port's availability comes from the sensing model below.

  • failover tries its children strictly in order: the first available child wins. Priority, expressed as list order.
  • round_robin distributes requests equally: available children take turns. Right when children are interchangeable and healthy; blind to how each one is actually performing.
  • least_loaded distributes waiting equally: the transaction goes to the child with the fewest in-flight transactions (for a subtree child, the sum beneath it). The Conductor already knows this number for free: every routed request parks a valet ticket and every matched reply (or timeout) redeems one, so in-flight per port is tickets parked minus tickets redeemed there.
destination:
failover:
- least_loaded: ["out_scheme_a", "out_scheme_a2"] # prefer the local pool
- round_robin: ["out_west", "out_north"] # then balance across regions (each wired to a peer Conductor)

Worked example (figures illustrative, not benchmarks). Region east pools two scheme A endpoints that normally answer in roughly 80 ms. Endpoint 2 degrades and starts answering in roughly 800 ms. Under round_robin, both still receive 50% of the traffic, so half of all transactions queue behind the slow link. Under least_loaded, endpoint 2's in-flight count grows (requests arrive faster than its replies drain), so new transactions drift to endpoint 1: traffic self-balances in proportion to each endpoint's actual current throughput, with no configured weights. In-flight depth rises seconds before timeouts start firing, making least_loaded a leading indicator that steers around a slowing endpoint, while the circuit breaker ([Roadmap], below) removes a dead one and a parent failover node moves on when a whole subtree drains.

Defined semantics:

  • Ties (including cold start, when all counts are zero): round-robin among the tied destinations, so least_loaded degrades gracefully into fair distribution while everything is healthy.
  • Timeouts decrement: an expired ticket redeems like a reply, so a destination's count reflects genuinely outstanding work. A zombie endpoint (accepting requests, never answering) therefore oscillates between looking idle and accumulating timeouts: detecting and removing it is the circuit breaker's job ([Roadmap]), not the load counter's.
  • Remote peers: when an out_<name> leads to a Conductor on another Rack, its in-flight count covers the whole remote path (Snake transit, the peer's routing, its endpoint), which is what should be compared when balancing across regions under one node.

Availability sensing

A destination is "available" when the Conductor believes a request emitted on that port can currently reach its endpoint. Three mechanisms feed that belief, layered from fastest to most conservative. Only the first is implemented today; the other two are [Roadmap], and until they ship a remote or unbound destination is treated as unconditionally available.

  1. Link-state signals (local uplink legs) (implemented): each uplink I/O gear publishes its connection state (conn.up / conn.down, with conn.id) on the Control Plane (flux.ctrl.>): the same channel that today carries the conn.close kill switch, in the opposite direction. The Conductor subscribes and marks the leg's destination port available exactly while its socket is up. Reaction is immediate on disconnect.
  2. Peer heartbeats (remote destinations) [Roadmap]: peer Conductors heartbeat over a shared liveness channel (the same pattern the Registry uses for Rack liveness, independent of the ticket-store choice). A stale heartbeat marks every port toward that peer unavailable. Loss of the Snake itself surfaces locally as a bus disconnect and has the same effect.
  3. Outcome circuit breaker (both, safety net) [Roadmap]: link-state cannot detect a zombie endpoint (socket up, scheme dead). Per destination, consecutive ticket timeouts open a circuit breaker that marks the port unavailable; after a cool-down, a limited number of probe transactions (half-open) either close it or keep it open. This also covers anything the first two mechanisms miss.

least_loaded uses the Conductor's own in-flight ticket count per destination: no extra instrumentation is required. Emitting availability transitions as flux. metrics and Control Plane events is [Roadmap] (see Operations).

The Conductor never guesses (and is never told in its own config) which I/O gear serves a destination: the scenario already knows. A destination port is typically wired to a codec, not to the I/O gear itself, so at scenario activation the Rack runtime walks the wire graph downstream from each Conductor output port, through transparent single-path gears (codecs), until it reaches a terminus:

  • A local I/O gear: the port is bound to that gear's conn.up / conn.down signals. A client-mode I/O gear owns exactly one connection, so gear-level state is connection state.
  • The Rack boundary (an out_<name> leading to another Conductor): there is no local socket to watch; the port is bound to the peer-heartbeat mechanism ([Roadmap]). Crossing the boundary during the walk is precisely what makes a destination remote.
  • No unambiguous terminus (a fork mid-path, or a leg that does not end in an I/O gear): the port falls back to circuit-breaker-only sensing ([Roadmap]), and activation logs a warning.

The binding is recomputed on every activation and delivered through the gear context, so it always matches the live wiring: including after a scenario reload. Keeping this derivation in the runtime, rather than duplicating topology in Conductor configuration, removes an entire class of drift: rewiring a leg can never leave the Conductor watching the wrong socket. An explicit per-port override remains available as an escape hatch for exotic paths, not as the primary mechanism.

Correlation and tickets

A ticket is the state the Conductor parks for an in-flight transaction so it can match the reply and route the response home. Two things must be defined precisely:

  • Correlation key. The key that links a reply to its request is configured, not assumed. A single field (e.g. STAN, DE 11) is never unique on its own: STAN wraps at 999999 and is reused within a day. The key is a tuple of fields (correlation_key: ["iso8583.field.11", "iso8583.field.7", "iso8583.field.32", "iso8583.field.41"]: STAN, transmission date/time, acquirer ID, terminal ID) chosen so it is unique for the lifetime of a ticket. Each entry is a dotted path (or codec alias such as stan) resolved against the structured message, not a bare wire field number. On the reply, the same tuple is extracted; a reply whose key matches no open ticket is emitted on error (a late or unsolicited reply).
  • Duplicate / retransmission. A request whose key matches a ticket that is still open is a retransmission. The Conductor does not double-route it: it attaches to the existing ticket (the terminal receives whatever the first attempt eventually yields). A request whose key matches a ticket already closed within the retain_after_close window (default 5s) is answered from the retained outcome. This is the switch equivalent of an idempotency key.
  • What the ticket holds. By default the ticket holds only return context: the origin (which terminal connection / conn.id), the chosen destination, the deadline, and the correlation key. Sensitive fields are not parked by default (see Security). Field parking is a separate, optional overlay.

Ticket store strategy

Where tickets are stored is declared in the scenario (valet.store), not fixed in the binary, and the right choice depends on the protocol and deployment, not preference. There is one store per Conductor.

storeFast pathAutonomous (main region down)Survives Conductor crashAny instance can redeemFits
memory (default)RAMyesnonoconnection-bound replies, clients that retransmit, short TTLs (e.g. ISO 8583 terminals)
local_durable [Roadmap]local WALyesyesnolong-lived, no-retry, or side-effectful correlation (async callbacks, job/command acks)
shared [Roadmap]NATS KVno (needs the KV)yesyesan external load balancer without sticky affinity, or replies that arrive out-of-band on a different connection

Choose by asking: is the reply carried back on the same connection as the request, or can it arrive out-of-band? Is there an external load balancer without stickiness? Does the client retransmit on timeout, or would an orphaned reply hang forever? Is the correlation window seconds or hours? Are there irreversible upstream side effects? A connection-bound reply with a retransmitting client (the classic terminal switch) needs nothing more than memory; out-of-band replies or cross-instance redemption need shared.

One store per Conductor, keyed by the correlation tuple. On a reply the Conductor has only the reply in hand: it extracts the key, finds the ticket, and learns the route from the ticket. The route is therefore an output of the lookup and cannot select the store (that would be circular). The store may be partitioned only by a function of the key, a hash for scale, or a key-embedded attribute such as tenant/region for residency, never by content route. If two legs genuinely need different stores, use two Conductors: each reply arrives on a wired, deterministic path to its Conductor.

Cross-region handoff is in-band. When a request crosses to a Conductor on another Rack, its return context travels with it (fluxMsg metadata over the Snake); the receiving Conductor treats it like any other ingress and routes its response home. This context is internal and is stripped before any external hop: an external system must never see internal routing state and would not preserve it anyway. Each external leg parks its own ticket keyed by whatever the external system echoes back (STAN/RRN/...), and that leg's store governs its durability and sharing.

Peer response routing (the origin stamp)

The concrete mechanism behind out_response_<origin> is one optional config key and one metadata key:

  • origin (config, optional): this Conductor's name in the mesh (e.g. "east"). Required only when other Conductors hand requests to this one, or this one hands requests to others.
  • conductor.origin (metadata): on every routed request, a Conductor with a configured origin stamps conductor.origin: <origin> into the outbound metadata. The stamp costs nothing on local legs (metadata never crosses the external socket) and is what a peer needs on remote ones.

On redemption, the responding Conductor looks at the ticket's return context (the metadata the request arrived with): if it carries a conductor.origin different from its own, the finished response leaves on out_response_<that-origin>; otherwise on plain out_response. Because each hop re-stamps with its own name, multi-hop handoffs unwind naturally: every response walks back exactly one hop to the Conductor that forwarded it, which redeems its own local ticket and repeats the decision.

WARNING

Encryption at rest is not yet implemented. Use memory for cardholder-data flows until it ships. memory keeps tickets in RAM only (nothing on disk), but RAM-only is not automatically safe: for regulated data the process must be hardened by the operator (ticket memory locked against swap with mlock, core dumps disabled). Buffer zeroization on release is [Roadmap] and not performed yet, so a redeemed ticket's request and outcome remain in RAM for the retention window before being dropped. The persistent backends (local_durable, shared) currently store data unencrypted and must not hold regulated data (PAN, SAD) until at-rest encryption ships.

Error handling

The Conductor routes and correlates; it does not author messages. It never synthesizes an ISO 8583 decline, a response code, or a reversal: fabricating protocol messages is a codec/logic concern, not a switching-layer one. Every abnormal outcome is instead emitted on the error port as the original fluxMsg annotated with an error reason and the ticket's return context (origin conn.id, correlation key, chosen destination, elapsed time). A downstream error-handling gear decides what to do: synthesize a decline for the terminal, originate a reversal to the scheme, queue for stand-in, or alert. A payment switch must never leave a terminal hanging, but how it answers is a policy the operator wires, not behavior baked into the Conductor.

Error reasons (error.reason metadata):

  • no_route: no match predicate matched and no catch-all route is defined. Define a catch-all route (a match that always matches, last in the list) to route these deliberately instead.
  • no_destination: every destination in the matched route's tree is unavailable. No ticket is parked; the request leaves on error so downstream can decline, queue, or stand in.
  • no_key: the message is missing one of the configured correlation_key fields, so it cannot be tracked at all. No ticket is parked (a request) or matched (a reply that fails key extraction is reported as unmatched_reply instead). This usually indicates a codec/spec mismatch on the leg.
  • timeout: a ticket's TTL expired with no matching reply. The parked request leaves on error carrying its return context, so a downstream gear can both answer the terminal and originate the reversal toward the scheme.
  • unmatched_reply: a reply arrived whose correlation key matches no open ticket (typically a late reply after the request already timed out). It leaves on error for reconciliation and to drive a compensating reversal, so scheme and switch do not disagree on the outcome.

Successful, matched, restored responses are the only thing that leaves on out_response. This keeps the happy path and the exception path on separate wires, each handled by gears suited to it.

NOTE

A single transaction can produce two error emissions: a timeout (the original request) when the TTL fires, and later an unmatched_reply (the late reply) if the scheme answers after expiry. The downstream error-handling gear must therefore be idempotent per correlation key, so it does not both decline and reverse the same transaction twice. The error port carries the full original fluxMsg (PAN and, before decode strips it, potentially SAD), so its consumers inherit CDE/PCI scope: keep them inside the CDE and do not log the payload.

Security and compliance

The Conductor sits inside the Cardholder Data Environment (CDE) and its ticketing touches sensitive data, so the design is explicit about it:

  • Sensitive Authentication Data (SAD). PIN blocks, full track data, and CVV/CVV2 MUST NOT be persisted after authorization (PCI DSS Req. 3.2). The Conductor therefore never parks SAD in durable ticket state. The correlation key must not include SAD and should avoid the full PAN (use a surrogate: PAN + STAN hashed, or the acquirer's RRN).
  • Field parking (optional overlay). For flows that must keep certain fields out of a specific downstream leg (for example an analytics tap, or a scheme that does not require track data), the Coat Check parking capability detaches those fields and reattaches them on the reply. In a normal switch the PAN does flow to the scheme (it is required to authorize), so parking is not on the primary path: it is an opt-in tool, not the default.
  • At rest. At-rest encryption is not yet implemented (see Ticket store strategy). Until it ships, cardholder-data flows must use the default memory store (RAM only, nothing persisted); the persistent stores hold plaintext and must not carry regulated data. On redemption a ticket's outcome is retained in RAM for the retain_after_close window (for idempotent replay) and then dropped; it is not wiped until buffer zeroization lands. Durable buckets will be encrypted and short-TTL'd once encryption ships.
  • In transit. Terminal-facing sockets should require TLS (the same tls block as uplink legs); the Snake is already mTLS. Cross-region handoff carries the full request, PAN included.
  • Data residency. Cross-region failover moves cardholder data across regions and possibly jurisdictions. Some card-scheme and privacy regimes require domestic processing; a route that must not leave a region simply omits the remote peer from its destination tree. Residency is therefore expressed in the same place as routing, per route.
  • Ticket integrity. Tickets are namespaced per switch and carry the signed provenance already used for .flux state, so a compromised participant cannot forge or redeem another's ticket.

Protocol independence

The Conductor operates entirely on structured fluxMsg (post-decode) and never parses wire bytes. match, correlation_key, and field parking all reference structured field paths or codec aliases (e.g. iso8583.field.11 or stan), not ISO 8583 wire specifics. An ISO 20022 uplink (or any other protocol) is therefore a codec change on the leg, not a Conductor change: the switching layer is protocol-agnostic, which matters as the industry migrates from ISO 8583 to ISO 20022 and runs both concurrently during the transition.

Operations

  • Draining is mandatory, not best-effort. On Drain, the Conductor stops accepting new requests on in but keeps matching replies until open tickets clear or time out. In-flight payments are never dropped on a clean stop.
  • Scenario reload is disruptive today. A scenario change currently triggers a full Gear restart, which closes uplink sockets and drops in-flight tickets. For a live switch this is an outage-class event; schedule reloads in a maintenance window until zero-downtime reload (roadmap) lands. This is the single most important operational caveat.
  • Key metrics [Roadmap] (all flux. prefixed): per-route and per-destination transaction rate, approval rate, response latency, timeout rate, in-flight depth, tier-failover events, and circuit-breaker state transitions. These map cleanly to RED (rate/errors/duration) dashboards and to per-scheme SLAs. Today the path emits only the generic runtime gear metrics (messages in/out, processing time, errors); the enumerated switch-specific metrics are not yet implemented.
  • Circuit breaker [Roadmap]: intended to be tunable per route or destination, breaker: { trip_after: 5, cooldown: "10s", half_open_probes: 3 }.
  • Overload [Roadmap]: in-flight tickets will be bounded (max_in_flight); beyond the bound the Conductor sheds load with a deterministic decline rather than growing unboundedly. Not yet implemented, so today a stuck destination accumulates tickets in RAM until they time out.
  • Reconciliation [Roadmap]: timeouts, reversals, and unmatched replies will be written to an immutable CBOR archive keyed by correlation key, so post-incident reconciliation against scheme records is exact.

Example

See the full two-region walkthrough: Building a multi-region payment switch.

  • Coat Check gear: the lightweight field-parking gear whose pattern the Conductor generalizes. Both ship: reach for Coat Check when you only need to strip/stash/reattach fields on one leg with no routing, tickets-as-timeout, or peer mesh; reach for the Conductor when you need switching. Coat Check also runs in the lean -tags nobento Rack.
  • ISO 8583 I/O gear: the single-connection uplink legs the Conductor routes across.
  • Correlator gear [Roadmap]: unrelated differential-analysis gear; despite the similar vocabulary, the Conductor's reply matching is a different mechanism for a different purpose.
  • Tokenization (surrogate substitution / vault): a separate, future gear, not the Conductor's parking overlay. Parking here holds and restores the same value under a ticket; tokenization replaces a value with a surrogate by rule and keeps a persistent vault. The two are orthogonal and composable on the wire.