Skip to main content

ADR-008: Device Identity, Port Resolution, Rule Engine & Action Dispatch

Status

Fully Superseded by ADR-009

All components adopted by ADR-009: DeviceIdentity, DeviceMatcher, PortResolver, BindingState, disambiguation workflow, lock-free MessageProcessor (ArcSwap<CompiledRuleSet>), ActionDispatcher, MidiTransform, CompiledTrigger. Rejected: [[device]]/[[map]] config schema (ADR-009 uses [[devices]] + [[modes.mappings]]). See ADR-009 D17 for full disposition.

Context

Conductor is a multi-protocol input automation system that translates MIDI, HID, and OSC messages into configurable actions ranging from OS automation to forwarded/transformed MIDI output. Users define maps that bind device inputs to actions. These maps must reference devices and output targets in a way that survives hardware reconnection, USB topology changes, and cross-session persistence.

Problem Statement

  1. MIDI Ports Are Volatile: Port indices and names are not stable across sessions, platforms, or USB topologies. A device at port 28:0 on Linux may appear as 32:0 after a reboot. On Windows (WinMM), ports are enumerated by index alone — completely unstable. Even macOS CoreMIDI's MIDIUniqueID can change when USB topology shifts.

  2. Maps Reference Physical Devices: Users configure maps saying "when my KeyStep sends CC 74, forward it as CC 1 to FM8." This requires stable device identification that outlives port reassignment, and the ability to gracefully handle devices that appear, disappear, and reappear.

  3. No Unified Message Processing Pipeline: The current architecture lacks a formalised rule engine that can match incoming messages against configured maps, apply transforms, and dispatch to heterogeneous action targets (MIDI output, OS automation, OSC, HID emulation) with deterministic latency.

  4. Multiple Identical Devices: Users may connect two or more units of the same controller (e.g., two Launchpad Minis). These have identical USB VID:PID, identical port names, and (on Linux/Windows) no platform-provided unique identifier. The system must differentiate them.

  5. Hot-Plug Without Restart: Devices connect and disconnect during a session. Maps targeting absent devices should go dormant and automatically reactivate when the device reappears — without user intervention or daemon restart.

Requirements

R1: Define a stable, platform-agnostic device identity model that decouples map configuration from volatile port indices and names

R2: Implement a runtime port resolver that binds logical device aliases to physical ports at startup and on hot-plug events, using a prioritised chain of matching strategies

R3: Design a lock-free, latency-sensitive rule engine that evaluates incoming messages against map rules and dispatches actions without holding mutexes on the hot path

R4: Support heterogeneous action dispatch: MIDI forwarding (with transform), OS automation (keystrokes, app launch, shell), OSC output, and HID emulation

R5: Handle multiple identical devices through a combination of platform-specific identifiers, USB topology hints, and user-assisted disambiguation

R6: Provide graceful degradation when mapped devices are absent, with automatic reactivation on reconnection

R7: Expose device scanning and identity resolution via both CLI and the existing MCP tool interface (per ADR-007)

Relationship to Other ADRs

ADRRelationshipNotes
ADR-007 (LLM Integration)ExtendsMCP tools conductor_list_devices and conductor_connect_device must use the DeviceIdentity model; MIDI Learn must resolve through the port resolver
ADR-006 (Plugin Architecture)Consumed byPlugins register as action dispatch targets
ADR-005 (Configuration Schema)ExtendsDevice identity and map definitions extend the TOML config schema

Decision

Core Concepts

The architecture introduces three foundational abstractions:

ConceptPurposeLifecycle
DeviceIdentityStable, user-configured reference to a physical devicePersisted in config
PortResolverRuntime binder that matches identities to live portsSession-scoped, reacts to hot-plug
MessagePipelineLock-free rule engine + action dispatch chainRuns on dedicated thread per device

Architecture Overview

┌─────────────────────────────────────────────────────────────────────────────────┐
│ CONDUCTOR MESSAGE PIPELINE │
├─────────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────────────────────┐ │
│ │ CONFIGURATION LAYER │ │
│ │ │ │
│ │ ┌─────────────────┐ ┌─────────────────┐ ┌──────────────────────┐ │ │
│ │ │ DeviceIdentity │ │ Map Rules │ │ Action Definitions │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ alias: "keystep"│ │ source: "keystep"│ │ type: MidiForward │ │ │
│ │ │ matchers: [ │ │ match: CC(74) │ │ target: "fm8" │ │ │
│ │ │ ExactName(..) │ │ action: Forward │ │ transform: CC(1) │ │ │
│ │ │ VidPid(..) │ │ │ │ │ │ │
│ │ │ ] │ │ │ │ │ │ │
│ │ └─────────────────┘ └─────────────────┘ └──────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ Config Load / Hot-Reload │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────────────────┐ │
│ │ PORT RESOLUTION LAYER │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────────────────────────┐ │ │
│ │ │ PortResolver │ │ │
│ │ │ │ │ │
│ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │
│ │ │ │ CoreMIDI │ │ Exact │ │ Contains │ │ USB │ │ │ │
│ │ │ │ UniqueID │───▶│ Name │───▶│ Name │───▶│ VID:PID │ │ │ │
│ │ │ │ (macOS) │ │ Match │ │ Match │ │ Match │ │ │ │
│ │ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │ │
│ │ │ ▲ │ │ │ │ │ │
│ │ │ │ ▼ ▼ ▼ │ │ │
│ │ │ Platform APIs ┌──────────────────────────────────────┐ │ │ │
│ │ │ │ Binding Table │ │ │ │
│ │ │ │ alias → (port, matched_by, state) │ │ │ │
│ │ │ └──────────────────────────────────────┘ │ │ │
│ │ └──────────────────────────────────────────────────────────────────┘ │ │
│ │ │ │ │ │
│ │ Hot-Plug ◄─┤ │ Bindings │ │
│ │ Polling │ ▼ │ │
│ └──────────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────────────────┐ │
│ │ MESSAGE PROCESSING LAYER │ │
│ │ │ │
│ │ ┌────────────┐ ┌────────────────┐ ┌────────────────────────┐ │ │
│ │ │ MIDI In │ │ Rule Engine │ │ Action Dispatch │ │ │
│ │ │ (midir) │────▶│ (lock-free) │────▶│ │ │ │
│ │ ├────────────┤ │ │ │ ┌──────────────────┐ │ │ │
│ │ │ HID In │────▶│ 1. Classify │ │ │ MIDI Forward │ │ │ │
│ │ │ (hidapi) │ │ 2. Match Rule │ │ │ (midir output) │ │ │ │
│ │ ├────────────┤ │ 3. Transform │ │ ├──────────────────┤ │ │ │
│ │ │ OSC In │────▶│ 4. Dispatch │ │ │ OS Automation │ │ │ │
│ │ │ (rosc) │ │ │ │ │ (enigo/arboard) │ │ │ │
│ │ └────────────┘ └────────────────┘ │ ├──────────────────┤ │ │ │
│ │ │ │ OSC Output │ │ │ │
│ │ │ │ (rosc) │ │ │ │
│ │ │ ├──────────────────┤ │ │ │
│ │ │ │ HID Emulation │ │ │ │
│ │ │ │ (platform) │ │ │ │
│ │ │ ├──────────────────┤ │ │ │
│ │ │ │ Plugin Dispatch │ │ │ │
│ │ │ │ (ADR-006) │ │ │ │
│ │ │ └──────────────────┘ │ │ │
│ │ └────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────────────────┐ │
│ │ OBSERVABILITY LAYER │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Latency │ │ Message │ │ Binding │ │ │
│ │ │ Histograms │ │ Counters │ │ State Log │ │ │
│ │ │ (per-rule) │ │ (per-device)│ │ (events) │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ └──────────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────────┘

Component Design

1. Device Identity Model

Location: conductor-core/src/identity.rs

The DeviceIdentity is the stable reference that maps and profiles use to reference physical devices. It is never a port index. It is always serializable to TOML config and survives across sessions, reboots, and platform changes.

use serde::{Deserialize, Serialize};

/// Stable, user-configured reference to a physical device.
/// Maps and profiles reference devices by `alias`, never by port index.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DeviceIdentity {
/// User-friendly name used in maps and profiles.
/// Must be unique within a config. Used as the key everywhere.
/// Examples: "keystep", "launchpad-left", "fm8-virtual"
pub alias: String,

/// Human-readable description (optional, for UI/docs)
pub description: Option<String>,

/// Protocol this device speaks
pub protocol: DeviceProtocol,

/// Direction: input, output, or bidirectional
pub direction: DeviceDirection,

/// Ordered list of matching strategies. Tried in order; first match wins.
/// More specific matchers should come first.
pub matchers: Vec<DeviceMatcher>,
}

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum DeviceProtocol {
Midi,
Hid,
Osc,
Virtual, // IAC Driver, loopback, etc.
}

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum DeviceDirection {
Input,
Output,
Bidirectional,
}

/// Matching strategy for binding a DeviceIdentity to a live port.
/// Strategies are tried in the order listed in the config.
/// More specific strategies (CoreMidiUniqueId, UsbVidPidSerial) should
/// come before less specific ones (NameContains, NameRegex).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum DeviceMatcher {
/// macOS CoreMIDI unique ID. Most stable identifier on macOS.
/// Persists across reboots when the device is on the same USB port.
/// Not available on Linux or Windows.
CoreMidiUniqueId(i32),

/// USB Vendor ID + Product ID + optional serial number.
/// VID:PID is stable per product model (not per unit).
/// Serial number differentiates units but not all devices report one.
UsbIdentifier {
vendor_id: u16,
product_id: u16,
serial: Option<String>,
},

/// Exact string match on the port name reported by the OS/driver.
/// Stable within a single OS but names differ across platforms.
/// Example: "Arturia KeyStep 37" (macOS), "KeyStep 37:KeyStep 37 MIDI 1 28:0" (Linux ALSA)
ExactName(String),

/// Substring match on port name. Useful for devices that append
/// serial numbers or port indices to their name.
/// Example: "KeyStep" matches "Arturia KeyStep 37" and "KeyStep 37 MIDI 1"
NameContains(String),

/// Regex match on port name. Escape hatch for complex naming patterns.
/// Example: r"Launchpad.*MK\d+" matches any Launchpad generation.
NameRegex(String),

/// Platform-specific persistent identifier string.
/// On macOS: CoreMIDI unique ID as string.
/// On Windows (WinRT): Device instance ID.
/// On Linux: ALSA card path (e.g., "/dev/snd/midiC1D0").
PlatformId(String),

/// Manual USB topology hint: bus and device address.
/// Useful for differentiating identical devices on specific USB ports.
/// Fragile — breaks if user moves the USB cable.
UsbTopology {
bus: u8,
address: u8,
},
}

impl DeviceMatcher {
/// Specificity score for prioritising match strategies.
/// Higher = more specific = more trustworthy.
pub fn specificity(&self) -> u8 {
match self {
Self::CoreMidiUniqueId(_) => 100,
Self::UsbIdentifier { serial: Some(_), .. } => 95,
Self::PlatformId(_) => 90,
Self::UsbTopology { .. } => 85,
Self::UsbIdentifier { serial: None, .. } => 70,
Self::ExactName(_) => 60,
Self::NameContains(_) => 40,
Self::NameRegex(_) => 30,
}
}
}

Configuration (TOML):

# ~/.conductor/config.toml

[[device]]
alias = "keystep"
description = "Arturia KeyStep 37 — primary controller"
protocol = "midi"
direction = "bidirectional"
matchers = [
{ type = "usb_identifier", vendor_id = 0x1C75, product_id = 0x0206 },
{ type = "exact_name", value = "Arturia KeyStep 37" },
{ type = "name_contains", value = "KeyStep" },
]

[[device]]
alias = "fm8"
description = "NI FM8 virtual MIDI input (via IAC or loopMIDI)"
protocol = "midi"
direction = "output"
matchers = [
{ type = "exact_name", value = "FM8 Virtual Input" },
{ type = "name_contains", value = "FM8" },
]

[[device]]
alias = "launchpad-left"
description = "Left Launchpad Mini (stage left, USB port A)"
protocol = "midi"
direction = "input"
matchers = [
{ type = "core_midi_unique_id", value = 1847293 },
{ type = "usb_identifier", vendor_id = 0x1235, product_id = 0x0113, serial = "LP-00A1" },
{ type = "usb_topology", bus = 1, address = 3 },
{ type = "name_contains", value = "Launchpad Mini" },
]

[[device]]
alias = "launchpad-right"
description = "Right Launchpad Mini (stage right, USB port B)"
protocol = "midi"
direction = "input"
matchers = [
{ type = "core_midi_unique_id", value = 1847294 },
{ type = "usb_identifier", vendor_id = 0x1235, product_id = 0x0113, serial = "LP-00B2" },
{ type = "usb_topology", bus = 1, address = 4 },
{ type = "name_contains", value = "Launchpad Mini" },
]

2. Port Resolution Engine

Location: conductor-core/src/resolver.rs

The PortResolver is the runtime component that binds DeviceIdentity aliases to live, open port handles. It runs at startup and re-evaluates on hot-plug events.

use std::collections::HashMap;
use std::time::{Duration, Instant};
use tokio::sync::watch;

/// The state of a device binding
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum BindingState {
/// Device is connected and the port handle is live
Bound {
port_name: String,
port_index: usize,
matched_by: DeviceMatcher,
bound_at: Instant,
},
/// Device is configured but not currently connected.
/// Maps targeting this device are dormant.
Unbound {
last_seen: Option<Instant>,
last_port_name: Option<String>,
},
/// Multiple ports matched — requires user disambiguation.
/// Resolution is paused until the user selects one.
Ambiguous {
candidates: Vec<PortCandidate>,
},
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PortCandidate {
pub port_name: String,
pub port_index: usize,
pub matched_by: DeviceMatcher,
pub specificity: u8,
}

/// A resolved binding: alias → live port state
#[derive(Clone, Debug)]
pub struct BoundPort {
pub alias: String,
pub state: BindingState,
pub identity: DeviceIdentity,
}

/// Port resolution events for the rest of the system to react to
#[derive(Clone, Debug)]
pub enum PortEvent {
/// A device was successfully bound to a port
DeviceBound { alias: String, port_name: String },
/// A device was lost (disconnected)
DeviceLost { alias: String, last_port_name: String },
/// A device returned after being lost
DeviceReturned { alias: String, port_name: String },
/// Multiple candidates found — needs user input
AmbiguousDevice { alias: String, candidates: Vec<PortCandidate> },
/// A new unconfigured device appeared (not in any identity)
UnknownDeviceAppeared { port_name: String, port_index: usize },
}

pub struct PortResolver {
/// Configured device identities
identities: Vec<DeviceIdentity>,

/// Current binding state: alias → binding
bindings: HashMap<String, BoundPort>,

/// Channel to broadcast port events
event_tx: watch::Sender<Vec<PortEvent>>,

/// Platform-specific port enumerator
enumerator: Box<dyn PortEnumerator>,

/// Polling interval for hot-plug detection
poll_interval: Duration,

/// Snapshot of last-known port list (for diff-based hot-plug detection)
last_port_snapshot: Vec<PortInfo>,
}

/// Platform-abstracted port information
#[derive(Clone, Debug, PartialEq)]
pub struct PortInfo {
pub name: String,
pub index: usize,
pub protocol: DeviceProtocol,
pub direction: DeviceDirection,
pub platform_id: Option<String>,
pub usb_info: Option<UsbDeviceInfo>,
}

#[derive(Clone, Debug, PartialEq)]
pub struct UsbDeviceInfo {
pub vendor_id: u16,
pub product_id: u16,
pub serial: Option<String>,
pub bus: Option<u8>,
pub address: Option<u8>,
}

/// Platform-specific port enumeration
#[async_trait]
pub trait PortEnumerator: Send + Sync {
/// List all currently available ports with metadata
async fn enumerate(&self) -> Vec<PortInfo>;

/// Get platform-specific unique ID for a port (if available)
async fn get_persistent_id(&self, port: &PortInfo) -> Option<String>;
}

impl PortResolver {
/// Resolve all configured identities against currently available ports.
/// Called at startup and on each hot-plug detection cycle.
pub async fn resolve_all(&mut self) -> Vec<PortEvent> {
let available_ports = self.enumerator.enumerate().await;
let mut events = Vec::new();

// Track which ports have been claimed to prevent double-binding
let mut claimed_ports: HashMap<usize, String> = HashMap::new();

// Sort identities by highest-specificity matcher (most specific first)
let mut sorted_identities = self.identities.clone();
sorted_identities.sort_by(|a, b| {
let a_max = a.matchers.iter().map(|m| m.specificity()).max().unwrap_or(0);
let b_max = b.matchers.iter().map(|m| m.specificity()).max().unwrap_or(0);
b_max.cmp(&a_max)
});

for identity in &sorted_identities {
let candidates: Vec<PortCandidate> = identity
.matchers
.iter()
.flat_map(|matcher| {
available_ports
.iter()
.filter(|port| {
port.protocol == identity.protocol
&& !claimed_ports.contains_key(&port.index)
&& self.matches(matcher, port)
})
.map(|port| PortCandidate {
port_name: port.name.clone(),
port_index: port.index,
matched_by: matcher.clone(),
specificity: matcher.specificity(),
})
})
.collect();

// Deduplicate candidates by port_index, keeping highest specificity
let mut deduped: HashMap<usize, PortCandidate> = HashMap::new();
for candidate in candidates {
deduped
.entry(candidate.port_index)
.and_modify(|existing| {
if candidate.specificity > existing.specificity {
*existing = candidate.clone();
}
})
.or_insert(candidate);
}
let unique_candidates: Vec<PortCandidate> = deduped.into_values().collect();

let previous_state = self.bindings.get(&identity.alias).map(|b| &b.state);

let new_state = match unique_candidates.len() {
0 => {
// No match — device is absent
if matches!(previous_state, Some(BindingState::Bound { .. })) {
let last_name = match previous_state {
Some(BindingState::Bound { port_name, .. }) => port_name.clone(),
_ => "unknown".to_string(),
};
events.push(PortEvent::DeviceLost {
alias: identity.alias.clone(),
last_port_name: last_name.clone(),
});
BindingState::Unbound {
last_seen: Some(Instant::now()),
last_port_name: Some(last_name),
}
} else {
BindingState::Unbound {
last_seen: None,
last_port_name: None,
}
}
}
1 => {
// Unique match — bind it
let candidate = &unique_candidates[0];
claimed_ports.insert(candidate.port_index, identity.alias.clone());

let was_unbound = !matches!(previous_state, Some(BindingState::Bound { .. }));
if was_unbound && previous_state.is_some() {
events.push(PortEvent::DeviceReturned {
alias: identity.alias.clone(),
port_name: candidate.port_name.clone(),
});
} else if previous_state.is_none() {
events.push(PortEvent::DeviceBound {
alias: identity.alias.clone(),
port_name: candidate.port_name.clone(),
});
}

BindingState::Bound {
port_name: candidate.port_name.clone(),
port_index: candidate.port_index,
matched_by: candidate.matched_by.clone(),
bound_at: Instant::now(),
}
}
_ => {
// Ambiguous — multiple ports matched the same identity
events.push(PortEvent::AmbiguousDevice {
alias: identity.alias.clone(),
candidates: unique_candidates.clone(),
});
BindingState::Ambiguous {
candidates: unique_candidates,
}
}
};

self.bindings.insert(
identity.alias.clone(),
BoundPort {
alias: identity.alias.clone(),
state: new_state,
identity: identity.clone(),
},
);
}

// Detect unknown devices (connected but not in any identity)
for port in &available_ports {
if !claimed_ports.contains_key(&port.index) {
events.push(PortEvent::UnknownDeviceAppeared {
port_name: port.name.clone(),
port_index: port.index,
});
}
}

// Broadcast events
if !events.is_empty() {
let _ = self.event_tx.send(events.clone());
}

events
}

/// Hot-plug detection loop. Polls for port changes at the configured interval.
/// Prefer platform-native callbacks where available (CoreMIDI notifications
/// on macOS, udev on Linux) but fall back to polling universally.
pub async fn hot_plug_loop(&mut self, mut shutdown: tokio::sync::watch::Receiver<bool>) {
loop {
tokio::select! {
_ = tokio::time::sleep(self.poll_interval) => {
let current_ports = self.enumerator.enumerate().await;
if current_ports != self.last_port_snapshot {
self.last_port_snapshot = current_ports;
self.resolve_all().await;
}
}
_ = shutdown.changed() => break,
}
}
}

fn matches(&self, matcher: &DeviceMatcher, port: &PortInfo) -> bool {
match matcher {
DeviceMatcher::ExactName(name) => port.name == *name,
DeviceMatcher::NameContains(substr) => port.name.contains(substr.as_str()),
DeviceMatcher::NameRegex(pattern) => {
regex::Regex::new(pattern)
.map(|re| re.is_match(&port.name))
.unwrap_or(false)
}
DeviceMatcher::UsbIdentifier { vendor_id, product_id, serial } => {
port.usb_info.as_ref().map_or(false, |usb| {
usb.vendor_id == *vendor_id
&& usb.product_id == *product_id
&& match serial {
Some(s) => usb.serial.as_deref() == Some(s.as_str()),
None => true, // VID:PID match is sufficient if no serial specified
}
})
}
DeviceMatcher::UsbTopology { bus, address } => {
port.usb_info.as_ref().map_or(false, |usb| {
usb.bus == Some(*bus) && usb.address == Some(*address)
})
}
DeviceMatcher::CoreMidiUniqueId(id) => {
port.platform_id.as_deref() == Some(&id.to_string())
}
DeviceMatcher::PlatformId(id) => {
port.platform_id.as_deref() == Some(id.as_str())
}
}
}
}

3. Rule Engine (Lock-Free Message Processing)

Location: conductor-core/src/engine.rs

The rule engine is the performance-critical hot path. It receives raw messages from device input threads, evaluates them against compiled rule sets, and dispatches actions. No locks are held during message evaluation.

use crossbeam::channel::{Receiver, Sender, bounded};
use std::sync::Arc;

/// A compiled, immutable rule set. Created from config at load time.
/// Swapped atomically via Arc when config changes — the hot path
/// never locks or waits.
#[derive(Clone, Debug)]
pub struct CompiledRuleSet {
/// Rules indexed by source device alias for O(1) lookup
rules_by_source: HashMap<String, Vec<CompiledRule>>,

/// Global rules (match any source)
global_rules: Vec<CompiledRule>,

/// Version counter for cache invalidation
version: u64,
}

/// A single compiled rule: pre-validated, pre-optimised for fast matching
#[derive(Clone, Debug)]
pub struct CompiledRule {
/// Unique rule ID for tracing and debugging
pub id: u64,

/// Human-readable name from config
pub name: Option<String>,

/// Pre-compiled trigger matcher
pub trigger: CompiledTrigger,

/// Pre-compiled action to dispatch
pub action: CompiledAction,

/// Optional condition (mode, profile, time-of-day, etc.)
pub condition: Option<RuleCondition>,

/// Priority for conflict resolution (higher wins)
pub priority: i32,

/// Whether to consume the message (prevent further rule matching)
pub consume: bool,
}

/// Pre-compiled trigger for fast matching without allocation
#[derive(Clone, Debug)]
pub enum CompiledTrigger {
/// Match a specific MIDI note on/off
MidiNote {
channel: Option<u8>, // None = any channel
note: u8,
velocity_min: u8, // 0 = any
velocity_max: u8, // 127 = any
event_type: NoteEventType, // NoteOn, NoteOff, Both
},

/// Match a MIDI CC message
MidiCC {
channel: Option<u8>,
cc: u8,
value_min: u8,
value_max: u8,
},

/// Match a range of MIDI notes (e.g., drum pads 36-51)
MidiNoteRange {
channel: Option<u8>,
note_min: u8,
note_max: u8,
velocity_min: u8,
velocity_max: u8,
},

/// Match MIDI program change
MidiProgramChange {
channel: Option<u8>,
program: Option<u8>, // None = any program
},

/// Match MIDI pitch bend
MidiPitchBend {
channel: Option<u8>,
},

/// Match any MIDI message (passthrough)
MidiAny,

/// Match an HID button press/release
HidButton {
usage_page: u16,
usage_id: u16,
pressed: bool,
},

/// Match an HID axis value
HidAxis {
usage_page: u16,
usage_id: u16,
value_min: i32,
value_max: i32,
},

/// Match an OSC message by address pattern
OscAddress {
pattern: String, // OSC address pattern with wildcards
},
}

#[derive(Clone, Debug)]
pub enum NoteEventType {
NoteOn,
NoteOff,
Both,
}

/// Pre-compiled action ready for dispatch
#[derive(Clone, Debug)]
pub enum CompiledAction {
/// Forward MIDI to another device with optional transform
MidiForward {
target_alias: String,
transform: Option<MidiTransform>,
},

/// Send a specific MIDI message (not forwarding — generating)
MidiSend {
target_alias: String,
message: Vec<u8>, // Raw MIDI bytes
},

/// OS automation: keystroke
Keystroke {
keys: Vec<String>,
modifiers: Vec<String>,
},

/// OS automation: launch application
Launch {
app: String,
args: Vec<String>,
},

/// OS automation: shell command
Shell {
command: String,
args: Vec<String>,
working_dir: Option<String>,
},

/// Send OSC message
OscSend {
target: String, // host:port
address: String, // OSC address
args: Vec<OscArg>, // OSC arguments
},

/// Switch Conductor mode
ModeChange {
mode: String,
},

/// Execute a sequence of actions
Sequence {
actions: Vec<CompiledAction>,
delay_between_ms: u64,
},

/// Dispatch to a plugin (ADR-006)
PluginAction {
plugin_name: String,
action_name: String,
params: serde_json::Value,
},

/// No-op (consume the message but do nothing)
Suppress,
}

/// MIDI message transform applied during forwarding
#[derive(Clone, Debug)]
pub struct MidiTransform {
/// Remap channel (None = keep original)
pub channel: Option<u8>,
/// Remap CC number (for CC messages)
pub cc: Option<u8>,
/// Remap note number (for Note messages)
pub note: Option<u8>,
/// Scale velocity: output = (input * scale) + offset, clamped to 0-127
pub velocity_scale: Option<f32>,
pub velocity_offset: Option<i8>,
/// Invert value (127 - value) — useful for fader direction correction
pub invert_value: bool,
/// Value curve: linear, logarithmic, exponential, or custom LUT
pub curve: Option<ValueCurve>,
}

#[derive(Clone, Debug)]
pub enum ValueCurve {
Linear,
Logarithmic,
Exponential,
/// Custom 128-entry lookup table for arbitrary curves
Lut(Box<[u8; 128]>),
}

/// Condition that must be true for the rule to fire
#[derive(Clone, Debug)]
pub enum RuleCondition {
/// Only fire in a specific mode
InMode(String),
/// Only fire when a specific profile is active
InProfile(String),
/// Boolean combination
And(Box<RuleCondition>, Box<RuleCondition>),
Or(Box<RuleCondition>, Box<RuleCondition>),
Not(Box<RuleCondition>),
}

Message Processing Loop (the hot path):

use std::sync::atomic::{AtomicU64, Ordering};
use arc_swap::ArcSwap;

/// The core message processor. Runs on a dedicated thread per input device.
/// Uses `arc_swap` for lock-free rule set swapping on config reload.
pub struct MessageProcessor {
/// Current compiled rule set — swapped atomically on config reload
rule_set: Arc<ArcSwap<CompiledRuleSet>>,

/// Current active mode
active_mode: Arc<ArcSwap<String>>,

/// Action dispatch channel (bounded, non-blocking send)
action_tx: Sender<ActionEnvelope>,

/// Message counter for observability
message_count: AtomicU64,

/// Rule evaluation latency tracker (lock-free ring buffer)
latency_tracker: Arc<LatencyTracker>,
}

/// An action ready for dispatch, with full provenance
#[derive(Clone, Debug)]
pub struct ActionEnvelope {
/// The action to execute
pub action: CompiledAction,
/// Which rule triggered this action
pub rule_id: u64,
/// Source device alias
pub source_alias: String,
/// Original message (for debugging/logging)
pub original_message: RawMessage,
/// Timestamp of the original message
pub timestamp: Instant,
}

impl MessageProcessor {
/// Process a single incoming message. Called from the midir/hidapi callback.
///
/// CRITICAL: This runs on the MIDI input callback thread.
/// - No allocations (rule set is pre-compiled)
/// - No locks (ArcSwap is wait-free for readers)
/// - No blocking I/O (action dispatch is via bounded channel try_send)
pub fn process(&self, source_alias: &str, message: &RawMessage) {
self.message_count.fetch_add(1, Ordering::Relaxed);
let start = Instant::now();

// Load current rule set (wait-free)
let rule_set = self.rule_set.load();
let active_mode = self.active_mode.load();

// Look up rules for this source device
let device_rules = rule_set.rules_by_source.get(source_alias);
let rule_chains = device_rules
.into_iter()
.flatten()
.chain(rule_set.global_rules.iter());

for rule in rule_chains {
// Check mode/profile condition
if let Some(ref condition) = rule.condition {
if !self.evaluate_condition(condition, &active_mode) {
continue;
}
}

// Match trigger
if self.matches_trigger(&rule.trigger, message) {
// Build action envelope
let envelope = ActionEnvelope {
action: rule.action.clone(),
rule_id: rule.id,
source_alias: source_alias.to_string(),
original_message: message.clone(),
timestamp: Instant::now(),
};

// Dispatch (non-blocking: drop if channel is full rather than block)
let _ = self.action_tx.try_send(envelope);

// If rule consumes the message, stop evaluating further rules
if rule.consume {
break;
}
}
}

// Record latency
self.latency_tracker.record(start.elapsed());
}

fn matches_trigger(&self, trigger: &CompiledTrigger, message: &RawMessage) -> bool {
match (trigger, message) {
(
CompiledTrigger::MidiNote {
channel,
note,
velocity_min,
velocity_max,
event_type,
},
RawMessage::Midi { status, data1, data2 },
) => {
let msg_type = status & 0xF0;
let msg_channel = status & 0x0F;
let type_match = match event_type {
NoteEventType::NoteOn => msg_type == 0x90 && *data2 > 0,
NoteEventType::NoteOff => msg_type == 0x80 || (msg_type == 0x90 && *data2 == 0),
NoteEventType::Both => msg_type == 0x90 || msg_type == 0x80,
};
type_match
&& channel.map_or(true, |ch| ch == msg_channel)
&& *data1 == *note
&& *data2 >= *velocity_min
&& *data2 <= *velocity_max
}
(
CompiledTrigger::MidiCC {
channel,
cc,
value_min,
value_max,
},
RawMessage::Midi { status, data1, data2 },
) => {
let msg_type = status & 0xF0;
let msg_channel = status & 0x0F;
msg_type == 0xB0
&& channel.map_or(true, |ch| ch == msg_channel)
&& *data1 == *cc
&& *data2 >= *value_min
&& *data2 <= *value_max
}
// Additional trigger matchers...
_ => false,
}
}

fn evaluate_condition(&self, condition: &RuleCondition, active_mode: &str) -> bool {
match condition {
RuleCondition::InMode(mode) => active_mode == mode,
RuleCondition::InProfile(_profile) => true, // TODO: profile resolution
RuleCondition::And(a, b) => {
self.evaluate_condition(a, active_mode)
&& self.evaluate_condition(b, active_mode)
}
RuleCondition::Or(a, b) => {
self.evaluate_condition(a, active_mode)
|| self.evaluate_condition(b, active_mode)
}
RuleCondition::Not(inner) => !self.evaluate_condition(inner, active_mode),
}
}
}

4. Action Dispatch

Location: conductor-core/src/dispatch.rs

The action dispatcher runs on a separate async task, receiving ActionEnvelope messages from the rule engine via a bounded channel. This isolates I/O latency (shell commands, network OSC) from the message processing hot path.

use tokio::task::JoinSet;

/// Action dispatcher: receives envelopes from the rule engine,
/// executes actions on the appropriate backend.
pub struct ActionDispatcher {
/// Receive channel from rule engine(s)
action_rx: Receiver<ActionEnvelope>,

/// MIDI output connections indexed by device alias
midi_outputs: Arc<RwLock<HashMap<String, MidiOutputHandle>>>,

/// OS automation backend
os_backend: Arc<dyn OsAutomationBackend>,

/// OSC output sockets
osc_sockets: Arc<RwLock<HashMap<String, OscOutputHandle>>>,

/// Plugin dispatcher (ADR-006)
plugin_dispatcher: Arc<dyn PluginDispatcher>,

/// Port resolver reference (for resolving target aliases to live ports)
resolver: Arc<RwLock<PortResolver>>,

/// Concurrent action execution pool
task_pool: JoinSet<()>,
}

/// OS automation trait — platform-specific implementations
#[async_trait]
pub trait OsAutomationBackend: Send + Sync {
async fn keystroke(&self, keys: &[String], modifiers: &[String]) -> Result<(), ActionError>;
async fn launch_app(&self, app: &str, args: &[String]) -> Result<(), ActionError>;
async fn shell_command(
&self,
command: &str,
args: &[String],
working_dir: Option<&str>,
) -> Result<(), ActionError>;
}

impl ActionDispatcher {
/// Main dispatch loop. Runs as a long-lived async task.
pub async fn run(&mut self) {
while let Ok(envelope) = self.action_rx.recv() {
let latency = envelope.timestamp.elapsed();
tracing::trace!(
rule_id = envelope.rule_id,
source = %envelope.source_alias,
latency_us = latency.as_micros(),
"Dispatching action"
);

// Spawn action execution to avoid blocking the dispatch loop
let midi_outputs = Arc::clone(&self.midi_outputs);
let os_backend = Arc::clone(&self.os_backend);
let osc_sockets = Arc::clone(&self.osc_sockets);
let plugin_dispatcher = Arc::clone(&self.plugin_dispatcher);
let resolver = Arc::clone(&self.resolver);

self.task_pool.spawn(async move {
if let Err(e) = Self::execute_action(
&envelope.action,
&midi_outputs,
&os_backend,
&osc_sockets,
&plugin_dispatcher,
&resolver,
&envelope.original_message,
)
.await
{
tracing::error!(
rule_id = envelope.rule_id,
error = %e,
"Action execution failed"
);
}
});
}
}

async fn execute_action(
action: &CompiledAction,
midi_outputs: &Arc<RwLock<HashMap<String, MidiOutputHandle>>>,
os_backend: &Arc<dyn OsAutomationBackend>,
osc_sockets: &Arc<RwLock<HashMap<String, OscOutputHandle>>>,
plugin_dispatcher: &Arc<dyn PluginDispatcher>,
resolver: &Arc<RwLock<PortResolver>>,
original: &RawMessage,
) -> Result<(), ActionError> {
match action {
CompiledAction::MidiForward {
target_alias,
transform,
} => {
let outputs = midi_outputs.read().await;
let output = outputs
.get(target_alias.as_str())
.ok_or_else(|| ActionError::TargetNotBound(target_alias.clone()))?;

let message = match transform {
Some(t) => Self::apply_midi_transform(original, t)?,
None => original.to_midi_bytes(),
};

output.send(&message)?;
Ok(())
}

CompiledAction::Keystroke { keys, modifiers } => {
os_backend.keystroke(keys, modifiers).await
}

CompiledAction::Launch { app, args } => {
os_backend.launch_app(app, args).await
}

CompiledAction::Shell {
command,
args,
working_dir,
} => {
os_backend
.shell_command(command, args, working_dir.as_deref())
.await
}

CompiledAction::OscSend {
target,
address,
args,
} => {
let sockets = osc_sockets.read().await;
let socket = sockets
.get(target.as_str())
.ok_or_else(|| ActionError::OscTargetNotFound(target.clone()))?;
socket.send(address, args)?;
Ok(())
}

CompiledAction::ModeChange { mode } => {
// TODO: emit mode change event to the engine
tracing::info!(mode = %mode, "Mode change requested");
Ok(())
}

CompiledAction::Sequence {
actions,
delay_between_ms,
} => {
for sub_action in actions {
Self::execute_action(
sub_action,
midi_outputs,
os_backend,
osc_sockets,
plugin_dispatcher,
resolver,
original,
)
.await?;

if *delay_between_ms > 0 {
tokio::time::sleep(Duration::from_millis(*delay_between_ms)).await;
}
}
Ok(())
}

CompiledAction::PluginAction {
plugin_name,
action_name,
params,
} => {
plugin_dispatcher
.dispatch(plugin_name, action_name, params)
.await
}

CompiledAction::Suppress => Ok(()),

CompiledAction::MidiSend {
target_alias,
message,
} => {
let outputs = midi_outputs.read().await;
let output = outputs
.get(target_alias.as_str())
.ok_or_else(|| ActionError::TargetNotBound(target_alias.clone()))?;
output.send(message)?;
Ok(())
}
}
}

fn apply_midi_transform(
original: &RawMessage,
transform: &MidiTransform,
) -> Result<Vec<u8>, ActionError> {
match original {
RawMessage::Midi { status, data1, data2 } => {
let msg_type = status & 0xF0;
let channel = transform.channel.unwrap_or(status & 0x0F);
let new_status = msg_type | channel;

let new_data1 = match msg_type {
0xB0 => transform.cc.unwrap_or(*data1), // CC: remap CC number
0x90 | 0x80 => transform.note.unwrap_or(*data1), // Note: remap note
_ => *data1,
};

let mut new_data2 = *data2;

// Apply velocity/value transform
if let Some(scale) = transform.velocity_scale {
let scaled = (new_data2 as f32 * scale) + transform.velocity_offset.unwrap_or(0) as f32;
new_data2 = scaled.round().clamp(0.0, 127.0) as u8;
}

if transform.invert_value {
new_data2 = 127 - new_data2;
}

// Apply curve LUT
if let Some(ValueCurve::Lut(lut)) = &transform.curve {
new_data2 = lut[new_data2 as usize];
}

Ok(vec![new_status, new_data1, new_data2])
}
_ => Err(ActionError::TransformNotApplicable),
}
}
}

5. Map Configuration (TOML Schema)

Location: ~/.conductor/config.toml (extends existing schema)

# ─── DEVICE IDENTITIES ───────────────────────────────────────────────────

[[device]]
alias = "keystep"
description = "Arturia KeyStep 37"
protocol = "midi"
direction = "bidirectional"
matchers = [
{ type = "usb_identifier", vendor_id = 0x1C75, product_id = 0x0206 },
{ type = "exact_name", value = "Arturia KeyStep 37" },
{ type = "name_contains", value = "KeyStep" },
]

[[device]]
alias = "fm8"
description = "NI FM8 virtual MIDI port"
protocol = "midi"
direction = "output"
matchers = [
{ type = "exact_name", value = "FM8 Virtual Input" },
]

[[device]]
alias = "ableton"
description = "Ableton Live via IAC"
protocol = "midi"
direction = "output"
matchers = [
{ type = "exact_name", value = "IAC Driver Bus 1" },
]

# ─── MAP RULES ────────────────────────────────────────────────────────────

# CC forwarding with transform
[[map]]
name = "KeyStep filter cutoff → FM8 mod wheel"
source = "keystep"
priority = 10
consume = true

[map.trigger]
type = "cc"
channel = 0
cc = 74

[map.action]
type = "midi_forward"
target = "fm8"

[map.action.transform]
cc = 1 # Remap CC 74 → CC 1 (mod wheel)
curve = "logarithmic"

# Drum pad → OS automation
[[map]]
name = "Pad 36 → focus Ableton"
source = "keystep"
condition = { type = "in_mode", mode = "daw" }

[map.trigger]
type = "note"
channel = 9
note = 36

[map.action]
type = "launch"
app = "Ableton Live 12 Suite"

# Pad range → mode-dependent actions
[[map]]
name = "Pads 37-39 → transport controls"
source = "keystep"
condition = { type = "in_mode", mode = "daw" }

[map.trigger]
type = "note_range"
channel = 9
note_min = 37
note_max = 39

[map.action]
type = "keystroke"
keys = ["space"] # Play/pause (note 37 example)

# Velocity-sensitive layers
[[map]]
name = "Soft hit → quiet note, hard hit → accent"
source = "keystep"

[map.trigger]
type = "note"
channel = 0
note = 60
velocity_min = 100
velocity_max = 127

[map.action]
type = "midi_forward"
target = "fm8"

[map.action.transform]
velocity_scale = 1.2
velocity_offset = 10

# OSC output
[[map]]
name = "CC 16 → OSC fader"
source = "keystep"

[map.trigger]
type = "cc"
cc = 16

[map.action]
type = "osc_send"
target = "127.0.0.1:9000"
address = "/fader/1"

# Action sequence
[[map]]
name = "Mode switch + visual feedback"
source = "keystep"

[map.trigger]
type = "note"
channel = 0
note = 48

[map.action]
type = "sequence"
delay_between_ms = 50

[[map.action.steps]]
type = "mode_change"
mode = "performance"

[[map.action.steps]]
type = "midi_send"
target = "keystep"
message = [0xB0, 0x00, 0x7F] # Send CC 0 = 127 (LED feedback)

6. CLI: conductor devices Subcommand

Location: conductor-cli/src/commands/devices.rs

A first-class CLI command for device discovery and identity management:

$ conductor devices scan

╔══════════════════════════════════════════════════════════════════════════════╗
║ AVAILABLE DEVICES ║
╠══════════════════════════════════════════════════════════════════════════════╣
║ ║
║ MIDI Input Ports ║
║ ───────────────────────────────────────────────────────────────────────── ║
║ [0] IAC Driver Bus 1 ║
║ Platform ID: 1738462 ║
║ Binding: → "ableton" (matched by: ExactName) ║
║ ║
║ [1] Komplete Audio 6 MK2 ║
║ Platform ID: 2847193 ║
║ USB: VID=0x17CC PID=0x1220 ║
║ Binding: (unconfigured) ║
║ ║
║ [2] Arturia KeyStep 37 ║
║ Platform ID: 9182734 ║
║ USB: VID=0x1C75 PID=0x0206 ║
║ Binding: → "keystep" (matched by: UsbIdentifier) ║
║ ║
║ MIDI Output Ports ║
║ ───────────────────────────────────────────────────────────────────────── ║
║ [0] IAC Driver Bus 1 ║
║ Binding: → "ableton" (matched by: ExactName) ║
║ ║
║ Dormant Devices (configured but not connected) ║
║ ───────────────────────────────────────────────────────────────────────── ║
║ ⏸ "fm8" — last seen: 2 hours ago on "FM8 Virtual Input" ║
║ ⏸ "launchpad-left" — never seen ║
║ ║
╚══════════════════════════════════════════════════════════════════════════════╝

$ conductor devices identify "keystep"

DeviceIdentity "keystep":
Protocol: MIDI
Direction: Bidirectional
Status: BOUND → port [2] "Arturia KeyStep 37"
Matched by: UsbIdentifier(VID=0x1C75, PID=0x0206)
Matchers:
1. UsbIdentifier(VID=0x1C75, PID=0x0206) [specificity: 70] ✓ MATCHED
2. ExactName("Arturia KeyStep 37") [specificity: 60] ✓ would match
3. NameContains("KeyStep") [specificity: 40] ✓ would match

$ conductor devices generate

# Scans all ports and generates TOML identity blocks for unconfigured devices
# User can paste into config.toml

[[device]]
alias = "komplete-audio" # suggested from port name
protocol = "midi"
direction = "input"
matchers = [
{ type = "usb_identifier", vendor_id = 0x17CC, product_id = 0x1220 },
{ type = "exact_name", value = "Komplete Audio 6 MK2" },
]

7. MCP Tool Integration (per ADR-007)

The device identity and port resolution systems expose MCP tools for LLM interaction:

/// Extended MCP tools for device management (supplements ADR-007)
pub fn device_mcp_tools() -> Vec<McpTool> {
vec![
McpTool {
name: "conductor_list_devices",
description: "List all configured device identities and their current binding state",
risk_tier: ToolRiskTier::ReadOnly,
parameters: json!({}),
},
McpTool {
name: "conductor_scan_ports",
description: "Scan for all available MIDI/HID/OSC ports with full metadata \
including USB VID:PID, platform IDs, and binding status",
risk_tier: ToolRiskTier::ReadOnly,
parameters: json!({}),
},
McpTool {
name: "conductor_create_device_identity",
description: "Create a new device identity with alias and matchers. \
Returns a Plan for user approval.",
risk_tier: ToolRiskTier::ConfigChange,
parameters: json!({
"type": "object",
"properties": {
"alias": { "type": "string" },
"protocol": { "type": "string", "enum": ["midi", "hid", "osc"] },
"direction": { "type": "string", "enum": ["input", "output", "bidirectional"] },
"matchers": { "type": "array" }
},
"required": ["alias", "protocol", "direction", "matchers"]
}),
},
McpTool {
name: "conductor_resolve_device",
description: "Attempt to resolve a device alias to a live port and return \
the binding result with match details",
risk_tier: ToolRiskTier::ReadOnly,
parameters: json!({
"type": "object",
"properties": {
"alias": { "type": "string" }
},
"required": ["alias"]
}),
},
McpTool {
name: "conductor_disambiguate_device",
description: "When a device has ambiguous matches, select which physical \
port should be bound to the alias. Returns a Plan.",
risk_tier: ToolRiskTier::ConfigChange,
parameters: json!({
"type": "object",
"properties": {
"alias": { "type": "string" },
"selected_port_index": { "type": "integer" }
},
"required": ["alias", "selected_port_index"]
}),
},
]
}

Platform-Specific Considerations

PlatformPort Name StabilityUnique IDUSB Info AccessHot-Plug Detection
macOS (CoreMIDI)Good (display names stable)MIDIUniqueID (i32) — persists across reboots on same USB portVia IOKitCoreMIDI notifications (native callback)
Linux (ALSA)Poor (client:port numbers change)None built-in; ALSA card path partially stableVia udev/sysfsudev monitor (native callback)
Windows (WinMM)Very poor (enumeration index)NoneVia SetupDIPolling only (no native callback for MIDI)
Windows (WinRT)Better (device instance IDs)Device Instance ID stringVia WinRT DeviceInformationDeviceWatcher (native callback)

Implementation priority: macOS first (primary developer platform), Linux second (server/Pi use cases), Windows third (broadest user base but most complex).

Handling Multiple Identical Devices

This is the hardest problem in device identity. The strategy is layered:

StrategyReliabilityPlatformNotes
USB serial numberHighAllNot all devices report serial numbers. Launchpads do; some cheap controllers don't.
CoreMIDI UniqueIDHighmacOS onlyDifferentiates identical units reliably if they stay on the same USB port.
USB topology (bus:address)MediumAllWorks as long as user doesn't move USB cables. Fragile but deterministic.
Connection order + user promptLowAllFallback: "I see two Launchpad Minis. Please press a pad on the LEFT one."

Disambiguation workflow (when two identical devices are found):

PortResolver detects 2 candidates for alias "launchpad-left"


PortEvent::AmbiguousDevice emitted

┌──────────┴──────────┐
▼ ▼
GUI shows CLI shows
disambiguation "conductor devices disambiguate
modal with --alias launchpad-left"
MIDI Learn │
│ │
▼ ▼
User presses User presses
pad on target pad on target
device device
│ │
└──────────┬────────┘

System records which port sent the event
Adds UsbTopology or PlatformId matcher to config
Re-resolves with unique binding

Consequences

Positive

  1. Stable References: Maps survive reboots, USB topology changes, and device reconnection without user intervention
  2. Zero-Downtime Hot-Plug: Devices can connect and disconnect freely; maps go dormant and reactivate automatically
  3. Sub-Millisecond Processing: Lock-free rule engine with pre-compiled rule sets ensures negligible processing latency on the MIDI callback thread
  4. Heterogeneous Output: A single map rule can target MIDI, OS automation, OSC, or plugins — the dispatch layer abstracts the target
  5. Multi-Protocol Ready: The identity model and rule engine work identically for MIDI, HID, and OSC — new protocols require only a new PortEnumerator implementation and trigger types
  6. Observable: Per-rule latency tracking, message counters, and binding state logs enable performance debugging and reliability monitoring
  7. LLM-Friendly: Device scanning, identity management, and map creation are exposed as MCP tools with appropriate risk tiers

Negative

  1. Configuration Complexity: Users must define device identities before creating maps. Mitigated by conductor devices generate CLI command and LLM-assisted setup
  2. Polling Overhead: Hot-plug detection requires periodic port enumeration (1-2s interval). On macOS and Linux, native callbacks reduce this, but Windows WinMM requires polling
  3. Identical Device Edge Case: Two identical devices without serial numbers require manual disambiguation. This is an inherent hardware limitation, not a software design flaw
  4. Platform Divergence: Each platform has different port enumeration APIs, USB info access methods, and hot-plug notification mechanisms. The PortEnumerator trait abstracts this but each platform needs a separate implementation

Neutral

  1. Backward Compatible: Existing configurations that use port indices can be auto-migrated to identity-based references via conductor migrate-config
  2. Optional Complexity: Simple setups (one device, one output) can use the name_contains matcher alone and skip USB identifiers entirely

Alternatives Considered

Alternative 1: Port Index Based (Current Implicit Behaviour)

Rejected: Port indices are volatile on all platforms. A map referencing "port 2" breaks when a USB hub is added or a device is unplugged and re-plugged. This is the core problem ADR-008 solves.

Alternative 2: Port Name Only (No Identity Abstraction)

Rejected: Port names differ across platforms for the same device. "Arturia KeyStep 37" on macOS becomes "KeyStep 37:KeyStep 37 MIDI 1 28:0" on Linux ALSA. Maps would not be portable. Additionally, identical devices have identical names.

Alternative 3: Centralized Device Registry (Database)

Rejected: Adds unnecessary infrastructure (SQLite or similar) for a problem that's better solved with declarative config. Device identity is inherently a configuration concern, not a runtime data concern. Config files are version-controllable, shareable, and human-readable.

Alternative 4: Auto-Discovery Only (No Manual Configuration)

Rejected: Auto-discovery cannot differentiate identical devices, assign semantic aliases, or express user intent about which device plays which role. A degree of user configuration is irreducible for multi-device setups.

Alternative 5: Shared-Mutex Rule Engine

Rejected: Holding a mutex during MIDI callback processing introduces unbounded latency. If the config reload thread holds the lock while the MIDI thread needs to evaluate a rule, the MIDI thread blocks — potentially dropping messages. The ArcSwap approach provides wait-free reads with atomic rule set replacement.

Implementation Plan

Phase 1: Device Identity & Port Resolution (v4.11.0)

  • DeviceIdentity and DeviceMatcher types with TOML serde
  • PortEnumerator trait with macOS CoreMIDI implementation
  • PortResolver with startup resolution and binding table
  • conductor devices scan CLI command
  • conductor devices generate CLI command (auto-generate identity TOML)
  • conductor devices identify <alias> CLI command
  • Hot-plug detection loop (polling-based, 1.5s interval)
  • Integration with existing daemon startup flow

Phase 2: Rule Engine & Action Dispatch (v4.12.0)

  • CompiledRuleSet compiler from TOML config
  • MessageProcessor with ArcSwap-based lock-free rule evaluation
  • ActionDispatcher with MIDI forwarding (with transform)
  • ActionDispatcher with OS automation (keystroke, launch, shell)
  • MidiTransform with channel remap, CC remap, velocity scaling
  • Value curves: linear, logarithmic, exponential, custom LUT
  • Bounded channel between rule engine and dispatcher
  • Per-rule latency tracking (lock-free ring buffer)
  • Config hot-reload via atomic rule set swap

Phase 3: Platform Expansion & Advanced Features (v4.13.0)

  • Linux ALSA PortEnumerator implementation
  • Linux udev-based hot-plug detection (native callback)
  • Windows WinRT PortEnumerator implementation
  • RuleCondition evaluation (mode, profile, boolean combinators)
  • CompiledAction::Sequence with inter-step delays
  • CompiledAction::OscSend with OSC output backend
  • Plugin dispatch integration (ADR-006)
  • conductor devices disambiguate CLI command (MIDI Learn based)

Phase 4: MCP Tools & LLM Integration (v4.14.0)

  • conductor_list_devices MCP tool
  • conductor_scan_ports MCP tool
  • conductor_create_device_identity MCP tool (Plan/Apply per ADR-007)
  • conductor_resolve_device MCP tool
  • conductor_disambiguate_device MCP tool
  • Agent skill: conductor-device-setup/SKILL.md update for identity model
  • Agent skill: conductor-midi-mapping/SKILL.md update for target aliases

Phase 5: Polish & Observability (v4.15.0)

  • conductor migrate-config for upgrading port-index configs to identity-based
  • Prometheus/OpenTelemetry metrics export (message throughput, rule latency, binding state)
  • GUI device status panel with real-time binding state
  • GUI disambiguation modal with MIDI Learn integration
  • Config validation: warn on overlapping matchers across identities

TDD Test Plan

Unit Tests

Device Identity

  • test_matcher_specificity_ordering — Verify specificity scores produce correct priority order
  • test_exact_name_match — ExactName matches exact string, rejects substrings
  • test_name_contains_match — NameContains matches substrings case-sensitively
  • test_name_regex_match — NameRegex matches valid patterns, handles invalid regex gracefully
  • test_usb_vid_pid_match_without_serial — VID:PID matches regardless of serial when serial is None
  • test_usb_vid_pid_serial_match — VID:PID+serial requires all three to match
  • test_usb_topology_match — Bus+address matches exact topology
  • test_identity_serialization_roundtrip — TOML serialize → deserialize produces identical identity

Port Resolution

  • test_single_device_unique_match — One identity, one matching port → Bound
  • test_single_device_no_match — One identity, no matching port → Unbound
  • test_ambiguous_match — One identity, two matching ports → Ambiguous
  • test_matcher_priority_order — First matching strategy wins (more specific first)
  • test_claimed_port_exclusion — A port bound to one identity is excluded from others
  • test_device_lost_event — Previously Bound device disappears → DeviceLost event
  • test_device_returned_event — Previously Unbound device appears → DeviceReturned event
  • test_unknown_device_detection — Port not matching any identity → UnknownDeviceAppeared
  • test_identity_sorting_by_specificity — Identities with more specific matchers are resolved first
  • test_hot_plug_no_change — Unchanged port list produces no events

Rule Engine

  • test_midi_note_trigger_exact — Note trigger matches correct note/channel/velocity
  • test_midi_note_trigger_any_channel — None channel matches all channels
  • test_midi_cc_trigger_value_range — CC trigger respects value_min/value_max bounds
  • test_midi_note_range_trigger — NoteRange matches notes within range, rejects outside
  • test_rule_priority_ordering — Higher priority rules fire first
  • test_consume_flag_stops_evaluation — Consuming rule prevents subsequent rule matching
  • test_condition_in_mode — Rule only fires when active mode matches condition
  • test_condition_boolean_combinators — And/Or/Not conditions evaluate correctly
  • test_rule_set_swap_during_processing — ArcSwap doesn't block ongoing message processing

Action Dispatch

  • test_midi_transform_channel_remap — Channel remap produces correct status byte
  • test_midi_transform_cc_remap — CC number remap produces correct data1
  • test_midi_transform_velocity_scale — Velocity scaling with clamp to 0-127
  • test_midi_transform_invert_value — Value inversion (127 - value)
  • test_midi_transform_lut_curve — Custom LUT applies correct mapping
  • test_action_sequence_execution_order — Sequence actions execute in order with delays
  • test_action_dispatch_target_not_bound — Forwarding to unbound device returns ActionError
  • test_action_dispatch_nonblocking — Dispatch doesn't block on slow action execution

Integration Tests

  • test_full_pipeline_midi_cc_forward — CC message in → rule match → MIDI forward out (end-to-end)
  • test_full_pipeline_note_to_keystroke — Note message in → rule match → OS keystroke out
  • test_hot_plug_reactivates_maps — Device disconnect → maps dormant → reconnect → maps active
  • test_config_reload_swaps_rules — Config file change → rules recompiled → new rules active without message loss
  • test_multiple_devices_independent_processing — Two input devices process messages on independent threads

Property Tests (proptest)

  • test_all_valid_midi_messages_parse — Any 3-byte MIDI message with valid status byte parses without panic
  • test_transform_preserves_value_range — Any transform output is within 0-127 for all inputs 0-127
  • test_resolver_deterministic — Same port list + same identities → same bindings every time

Requirements Traceability

RequirementComponentTest Coverage
R1: Stable device identityDeviceIdentity, DeviceMatchertest_matcher_*, test_identity_serialization_*
R2: Runtime port resolutionPortResolver, PortEnumeratortest_single_device_*, test_hot_plug_*
R3: Lock-free rule engineMessageProcessor, ArcSwap<CompiledRuleSet>test_rule_set_swap_*, test_rule_*
R4: Heterogeneous action dispatchActionDispatcher, CompiledActiontest_action_dispatch_*, test_full_pipeline_*
R5: Multiple identical devicesPortResolver ambiguity detection, UsbIdentifier serialtest_ambiguous_match, test_usb_vid_pid_serial_*
R6: Graceful degradationBindingState::Unbound, PortEvent::DeviceLost/Returnedtest_device_lost_*, test_hot_plug_reactivates_*
R7: CLI + MCP exposureconductor devices commands, MCP toolstest_full_pipeline_* (CLI), MCP tool tests (Phase 4)

Open Questions

  1. Config Migration: What's the migration path for users with existing port-index-based configs? Auto-migrate with conductor migrate-config, or require manual update?

  2. Identity Persistence of Disambiguation: When a user disambiguates two identical Launchpads via MIDI Learn, should the system automatically add a usb_topology or platform_id matcher to their config? This makes it "just work" next time but modifies user config without explicit editing.

  3. Virtual MIDI Ports: Should virtual ports (IAC Driver, loopMIDI, JACK) be treated differently from hardware ports? They're always available and don't hot-plug, but their names can be user-configured and may change.

  4. Cross-Platform Config Sharing: If a user shares their config between macOS and Linux, matchers like CoreMidiUniqueId are irrelevant on Linux. Should the resolver silently skip inapplicable matchers (current design) or warn?

  5. Rule Engine Bounded Channel Overflow: If the action dispatch channel fills up (slow shell commands, network timeouts), the rule engine drops messages via try_send. Should there be a configurable overflow policy (drop, log, backpressure)?

  6. Latency Budget: What's the acceptable end-to-end latency from MIDI input to action dispatch? Music applications typically require < 5ms. OS automation can tolerate 10-50ms. Should there be per-action-type latency budgets?

References

Crates

  • midir — Cross-platform MIDI I/O for Rust
  • hidapi — Cross-platform HID device access
  • rosc — OSC protocol implementation
  • arc-swap — Wait-free atomic pointer swap for lock-free data structures
  • crossbeam — Lock-free channels and data structures
  • enigo — Cross-platform keyboard/mouse automation
  • notify — Cross-platform file system notification (config watch)

Platform APIs

  • ADR-005: Configuration Schema (planned, not published — ADR-005 was reassigned to Bundle ID User Experience) — Base TOML schema this ADR extends
  • ADR-006: Plugin Architecture (planned, not published — ADR-006 was reassigned to Profile Manager Security) — Plugin dispatch target for CompiledAction::PluginAction
  • ADR-007: LLM Integration — MCP tools and Plan/Apply pattern this ADR integrates with

LLM Council Review

Pending — submit for review with:

conductor council review docs/adrs/ADR-008-device-identity-port-resolution-message-pipeline.md

Review Checklist

  • Security: Regex matchers in NameRegex — DoS risk from pathological patterns? Consider regex crate timeout or fancy-regex avoidance
  • Performance: Lock-free claims validated under contention (multiple devices connecting simultaneously)
  • Correctness: TOCTOU in disambiguation flow — device could disconnect between detection and user selection
  • Completeness: HID and OSC port enumeration design (deferred to Phase 3 but architecture must accommodate)
  • Testability: Property tests for matcher specificity ordering and transform value bounds