ADR-009: Multi-Device Listening Architecture
Status
Proposed (4-Review LLM Council Deliberation Complete — see Council Review)
Context
Problem Statement
Conductor currently supports one MIDI input device at a time. The architecture enforces this at every layer:
- Config:
Config.device: DeviceConfigis singular (conductor-core/src/config/types.rs) - Input Manager:
InputManager.midi_manager: Option<MidiDeviceManager>holds one slot (conductor-daemon/src/input_manager.rs) - Event Processor: Global state —
HashMap<u8, Instant>for note press times, chord buffers, etc. — collides when multiple devices send the same note number (conductor-core/src/event_processor.rs) - Mapping Engine: No device awareness —
trigger_matches_processed()cannot distinguish which device produced an event (conductor-core/src/mapping.rs) - GUI:
DeviceList.svelteshows available MIDI ports with "Connect" and "Disconnect" buttons; only one device may be connected at a time
This means:
- Users cannot use a pad controller and a keyboard controller simultaneously
- The same note number (e.g., note 36) from two different devices cannot map to different actions
- Gesture detection state (chords, double-tap timers, hold detection) is global — events from one device interfere with timing state from another
- Hot-swapping devices requires manual reconnection via the GUI
Why the "Connect" Model is Wrong for MIDI
MIDI devices appear and disappear naturally as USB cables are plugged and unplugged. The current model requires users to manually click "Connect" in the GUI after plugging in a device. This is unnecessary friction. The daemon should simply listen to all available MIDI ports. When a device appears, the daemon opens it. When it disappears, the daemon closes it. No user action required.
Relationship to ADR-008
ADR-008 defines a comprehensive DeviceIdentity model with alias-based stable references, DeviceMatcher chains for port matching, a PortResolver with binding table, and a lock-free MessageProcessor using ArcSwap<CompiledRuleSet>.
ADR-009 adopts ADR-008's identity model for stable device references but takes a fundamentally different stance on the connection model:
| Aspect | ADR-008 | ADR-009 |
|---|---|---|
| Philosophy | Opt-in: Define identity first, then listen | Listen-first: Hear everything, optionally label |
| Unconfigured devices | Ignored | Listened to with raw port name as device_id |
| Configuration required | Yes, before any events are processed | No, zero-config works for single-device users |
| Rule engine | Lock-free MessageProcessor with ArcSwap<CompiledRuleSet> | Adopted: Lock-free CompiledRuleSet via ArcSwap replaces MappingEngine; EventProcessor retained for gesture detection |
| Action dispatch | ActionDispatcher trait with heterogeneous backends | Adopted: Trait-based dispatch replaces monolithic ActionExecutor |
| MIDI transforms | MidiTransform for forwarding (channel/CC/velocity remap, curves) | Adopted: Full transform pipeline for MIDI-to-MIDI forwarding |
| Config format | New [[map]] and [[device]] tables | Extends existing [[modes.mappings]] with device filter (rejects [[map]]) |
ADR-009 fully supersedes ADR-008. It adopts all components — DeviceIdentity, DeviceMatcher, PortResolver, BindingState, disambiguation workflow, lock-free CompiledRuleSet/MessageProcessor, ActionDispatcher, and MidiTransform — but uses ADR-009's listen-first connection model and existing [[modes.mappings]] config format (rejecting ADR-008's [[map]]/[[device]] schema). See D17 for the full component disposition.
Relationship to Other ADRs
| ADR | Relationship | Notes |
|---|---|---|
| ADR-007 (LLM Integration) | Extended by | MCP tools updated for multi-device; MIDI Learn gains device attribution |
| ADR-008 (Device Identity) | Fully Supersedes | All components adopted (Identity, Resolution, BindingState, lock-free engine, ActionDispatcher, MidiTransform); Config schema rejected. See D17 |
Decision
Core Principles
-
The daemon opens ALL available MIDI input ports simultaneously on startup. No user action required. No connect/disconnect buttons.
-
Every event is wrapped in
DeviceEvent<T>carrying aDeviceId(newtype aroundArc<str>) identifying which port produced it. For ports matched to aDeviceIdentity, this is the alias (e.g.,"keystep"). For unmatched ports, this is the raw port name (e.g.,"Arturia KeyStep 37").InputEventandProcessedEventthemselves remain unchanged — device attribution is a cross-cutting concern handled at the routing layer via theDeviceEvent<T>wrapper. -
Each device gets its own
EventProcessorinstance, isolating chord buffers, double-tap timers, and hold detection per-device. This is the Hybrid Option C fromdocs/multi-device-architecture.md. -
Triggers gain an optional
device: Option<String>filter.Nonemeans "match events from any device" (backward-compatible default). A specific string like"keystep"means "only match events from this device." -
ADR-008's
PortResolveris used for stable identity resolution. When the daemon opens a port, it checks against configuredDeviceIdentityentries to assign an alias. Unknown ports get their raw port name as the device_id. -
Hot-plug is automatic. A polling loop (1.5s interval, or platform-native callbacks where available) detects new ports and opens them, detects removed ports and cleans up.
Architecture Overview
┌──────────────────────────────────────────────────────────────┐
│ CONFIGURATION │
│ │
│ [[devices]] [[modes.mappings]] │
│ alias = "pads" trigger = { Note, note=36, │
│ matchers = [...] device = "pads" } │
│ action = { Keystroke, ... } │
│ [[devices]] │
│ alias = "keys" trigger = { Note, note=36, │
│ matchers = [...] device = "keys" } │
│ action = { Keystroke, ... } │
└────────────────────────────┬─────────────────────────────────┘
│ Config Load / Hot-Reload
▼
┌──────────────────────────────────────────────────────────────┐
│ PORT RESOLUTION │
│ │
│ PortResolver │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Available Ports Identity Matching │ │
│ │ ───────────── ────────────────── │ │
│ │ [0] IAC Driver ──→ (unconfigured) │ │
│ │ [1] Komplete Audio ──→ (unconfigured) │ │
│ │ [2] Maschine Mikro ──→ alias: "pads" (NameContains) │ │
│ │ [3] Keystation ──→ alias: "keys" (NameContains) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ All ports opened simultaneously │
│ Hot-plug: 1.5s polling detects add/remove │
└────────────────────────────┬─────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ INPUT MANAGER │
│ │
│ midi_managers: HashMap<DeviceId, MidiDeviceManager> │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ IAC │ │ Komplete │ │ "pads" │ │ "keys" │ │
│ │ Driver │ │ Audio │ │ (Mikro) │ │ (Keystn) │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │ │
│ └──────────┬──┴────────────┴─────────────┘ │
│ │ All events wrapped in DeviceEvent<T> │
│ ▼ │
│ Shared mpsc::channel<DeviceEvent<InputEvent>> │
└────────────────────────────┬─────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ EVENT PROCESSING │
│ │
│ event_processors: DashMap<DeviceId, EventProcessor> │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ "IAC Driver" │ │ "pads" │ │ "keys" │ │
│ │ chord_buf: []│ │ chord_buf: []│ │ chord_buf: []│ │
│ │ hold_times │ │ hold_times │ │ hold_times │ │
│ │ tap_times │ │ tap_times │ │ tap_times │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ └────────────────┼────────────────┘ │
│ │ DeviceEvent<ProcessedEvent> │
│ ▼ │
│ Lock-Free Rule Engine (ArcSwap<CompiledRuleSet>) │
│ ┌────────────────────────────────────────────────────┐ │
│ │ 1. Load rule_set (wait-free ArcSwap::load) │ │
│ │ 2. Lookup rules_by_device[device_id] + global │ │
│ │ 3. Match CompiledTrigger (zero-alloc) │ │
│ │ 4. Apply MidiTransform if forwarding │ │
│ │ 5. Emit ActionEnvelope to dispatch channel │ │
│ └────────────────────────────────────────────────────┘ │
└────────────────────────────┬─────────────────────────────────┘
│ tokio::sync::mpsc (try_send sync→async)
▼
┌──────────────────────────────────────────────────────────────┐
│ ACTION DISPATCH (trait-based) │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌──────────┐ │
│ │ MIDI Fwd │ │ OS Auto │ │ OSC Output │ │ Plugin │ │
│ │ (midir out)│ │ (enigo) │ │ (rosc) │ │ (ADR-006)│ │
│ │ +Transform │ │ Keystroke │ │ host:port │ │ WASM/ │ │
│ │ ch/cc/vel │ │ Shell, │ │ addr/args │ │ Native │ │
│ │ curves,LUT │ │ Launch │ │ │ │ │ │
│ └────────────┘ └────────────┘ └────────────┘ └──────────┘ │
└──────────────────────────────────────────────────────────────┘
Component Design
1. Config Changes
File: conductor-core/src/config/types.rs
pub struct Config {
/// DEPRECATED: singular device config (auto-migrated to devices[])
#[serde(default, skip_serializing_if = "Option::is_none")]
pub device: Option<DeviceConfig>,
/// Device identity definitions (ADR-009)
/// If empty AND device is None, daemon listens to all ports with
/// auto-generated device_ids from port names.
#[serde(default)]
pub devices: Vec<DeviceIdentityConfig>,
pub modes: Vec<Mode>,
#[serde(default)]
pub global_mappings: Vec<Mapping>,
// ... rest unchanged
}
New DeviceIdentityConfig (simplified from ADR-008 for TOML ergonomics):
/// Device identity for configuration (ADR-009, based on ADR-008 model)
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DeviceIdentityConfig {
/// Unique alias used in triggers (e.g., "keystep", "pads")
pub alias: String,
/// Human-readable description
#[serde(default)]
pub description: Option<String>,
/// Protocol: "midi" (default), "hid", "osc"
#[serde(default = "default_midi")]
pub protocol: String,
/// Direction: "input" (default), "output", "bidirectional"
#[serde(default = "default_input")]
pub direction: String,
/// Ordered matcher chain (ADR-008 model)
#[serde(default)]
pub matchers: Vec<DeviceMatcherConfig>,
/// Whether this device is enabled (default: true)
#[serde(default = "default_true")]
pub enabled: bool,
/// Legacy: port name for simple matching
/// Auto-wraps to NameContains matcher if matchers is empty
#[serde(default)]
pub name: Option<String>,
}
New DeviceMatcherConfig (TOML-friendly representation):
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum DeviceMatcherConfig {
CoreMidiUniqueId { value: i32 },
UsbIdentifier { vendor_id: u16, product_id: u16, serial: Option<String> },
ExactName { value: String },
NameContains { value: String },
NameRegex { value: String },
PlatformId { value: String },
UsbTopology { bus: u8, address: u8 },
}
File: conductor-core/src/identity.rs (NEW)
Contains runtime identity types and matching logic from ADR-008:
DeviceMatcherenum withmatches(&self, port_name: &str) -> boolDeviceMatcher::specificity() -> u8for priority ordering- Conversion from
DeviceMatcherConfigtoDeviceMatcher
2. Device Identity Types
File: conductor-core/src/identity.rs (NEW)
Per Council feedback, device attribution uses a newtype wrapper rather than adding device_id: String to every event variant. This prevents string allocation in the hot path and avoids cross-cutting concern pollution of event types.
/// Typed device identifier — represents a unique port *instance*, not
/// just a model name. Allocated once per device at connection time,
/// shared cheaply via Arc<str> across all events from that device.
///
/// For aliased devices: the alias string (e.g., "pads")
/// For unconfigured devices: the raw port name (e.g., "Arturia KeyStep 37")
/// For duplicate hardware: disambiguated with instance suffix (e.g., "nanoKONTROL2 #2")
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct DeviceId(Arc<str>);
impl DeviceId {
pub fn from_alias(alias: &str) -> Self { Self(Arc::from(alias)) }
pub fn from_port_name(name: &str) -> Self { Self(Arc::from(name)) }
pub fn from_port_instance(name: &str, instance: usize) -> Self {
if instance == 0 {
Self(Arc::from(name))
} else {
Self(Arc::from(format!("{} #{}", name, instance + 1)))
}
}
pub fn as_str(&self) -> &str { &self.0 }
}
/// Wraps any event with device attribution. Avoids adding device_id
/// to every InputEvent/ProcessedEvent variant (Council recommendation).
#[derive(Clone, Debug)]
pub struct DeviceEvent<T> {
pub device_id: DeviceId,
pub event: T,
}
Twin Device Handling (Council critical finding): If two identical controllers are connected (e.g., two "Korg nanoKONTROL2"), they may report the same port name. The InputManager::resolve_device_id() method tracks already-assigned names and appends an instance suffix (#2, #3, etc.) to prevent DeviceId collisions and EventProcessor state corruption. If both match a configured DeviceIdentity alias, the first match claims the alias; the second falls back to its raw port name with instance suffix per D7.
Rationale (from Council review): Adding device_id to all 8 InputEvent variants and all 12 ProcessedEvent variants is a massive surface area change where every pattern match throughout the codebase must change, and every future variant must remember to include device_id. The DeviceEvent<T> wrapper handles device attribution at a single architectural layer.
3. Event Pipeline with DeviceEvent
File: conductor-core/src/events.rs
InputEvent and ProcessedEvent remain unchanged — no per-variant device_id. Instead, the pipeline uses DeviceEvent<InputEvent> and DeviceEvent<ProcessedEvent>:
// Event channel carries DeviceEvent<InputEvent>
type DeviceInputEvent = DeviceEvent<InputEvent>;
// EventProcessor accepts InputEvent, returns Vec<ProcessedEvent>
// The EngineManager wraps/unwraps DeviceEvent at the routing layer
The From<MidiEvent> for InputEvent impl remains as-is. Device attribution is added at the InputManager boundary where the device_id is known:
// In InputManager::listen_to_all_ports(), per-port callback:
let device_event = DeviceEvent {
device_id: device_id.clone(), // DeviceId (Arc<str>), cheap clone
event: InputEvent::from(midi_event),
};
event_tx.send(device_event).await;
This means EventProcessor does not need to know about devices — the EngineManager routes events to per-device processors based on DeviceEvent.device_id and unwraps the inner event for processing.
4. Trigger: Device Filter
File: conductor-core/src/config/types.rs
Every Trigger variant gains an optional device field:
#[serde(tag = "type")]
pub enum Trigger {
Note {
note: u8,
#[serde(default)]
velocity_range: Option<(u8, u8)>,
#[serde(default)]
device: Option<String>, // NEW: None = any device
},
CC {
cc: u8,
#[serde(default)]
device: Option<String>, // NEW
},
// ... same pattern for all trigger variants
}
5. Rule Matching: Lock-Free Replacement of MappingEngine
File: conductor-core/src/mapping.rs (MODIFIED)
The existing MappingEngine with RwLock is replaced by the lock-free MessageProcessor (see section 9). The CompiledRuleSet is pre-indexed by device alias for O(1) lookup, then rules within each device bucket are matched by CompiledTrigger.
The EngineManager event loop becomes:
async fn process_input_event(&self, device_event: DeviceEvent<InputEvent>) -> Result<()> {
let DeviceEvent { device_id, event } = device_event;
// 1. Per-device gesture detection (DashMap, lock-free read)
let processed_events = {
let mut processor = self.event_processors
.entry(device_id.clone())
.or_insert_with(EventProcessor::new);
processor.process_input(event)
};
// 2. Lock-free rule matching (ArcSwap, wait-free read)
for processed_event in processed_events {
self.message_processor.process(&device_id, &processed_event);
}
// Actions dispatched via bounded channel to ActionDispatcher (async)
Ok(())
}
Key change: EventProcessor (gesture detection) and MessageProcessor (rule matching) are now separate concerns. EventProcessor detects holds, double-taps, chords from raw input. MessageProcessor matches processed gestures against rules. Neither holds a lock on the hot path.
6. InputManager: Multi-Device
File: conductor-daemon/src/input_manager.rs
pub struct InputManager {
/// Active MIDI device managers, keyed by DeviceId
midi_managers: HashMap<DeviceId, MidiDeviceManager>,
/// Gamepad manager (unchanged — single device via gilrs)
gamepad_manager: Option<HidDeviceManager>,
/// Input mode selection
mode: InputMode,
/// Configured device identities for alias resolution
device_identities: Vec<DeviceIdentityConfig>,
/// Muted devices (runtime-only, not persisted)
muted_devices: HashSet<DeviceId>,
}
New method listen_to_all_ports():
impl InputManager {
/// Open ALL available MIDI input ports simultaneously (ADR-009)
///
/// For each available port:
/// 1. Resolve DeviceId via identity matching or raw port name
/// 2. Open the port with a MidiDeviceManager
/// 3. Wrap all events in DeviceEvent<InputEvent>
/// 4. Send to shared event channel
pub fn listen_to_all_ports(
&mut self,
event_tx: mpsc::Sender<DeviceEvent<InputEvent>>,
command_tx: mpsc::Sender<DaemonCommand>,
) -> Result<Vec<DeviceId>, String> { ... }
/// Resolve a port name to a DeviceId using configured identities
fn resolve_device_id(&self, port_name: &str) -> DeviceId { ... }
/// Enable/disable listening on a specific device (mute/unmute)
pub fn set_device_enabled(
&mut self, device_id: &DeviceId, enabled: bool
) -> Result<(), String> { ... }
}
7. EngineManager: Per-Device EventProcessors
File: conductor-daemon/src/daemon/engine_manager.rs
pub struct EngineManager {
// CHANGED: Single EventProcessor -> per-device HashMap
// Uses DashMap for lock-free concurrent reads (Council recommendation)
event_processors: Arc<DashMap<DeviceId, EventProcessor>>,
// ... rest unchanged
}
Event processing routes through per-device processors using the DeviceEvent<T> wrapper, then through the lock-free MessageProcessor for rule matching:
pub struct EngineManager {
// Per-device gesture detection (DashMap: lock-free concurrent reads)
event_processors: Arc<DashMap<DeviceId, EventProcessor>>,
// Lock-free rule matching (ArcSwap: wait-free reads)
message_processor: Arc<MessageProcessor>,
// ... shutdown, command channels
}
async fn process_input_event(&self, device_event: DeviceEvent<InputEvent>) -> Result<()> {
let DeviceEvent { device_id, event } = device_event;
// 1. Per-device gesture detection (DashMap: lock-free read, shard-lock on insert)
let processed_events = {
let mut processor = self.event_processors
.entry(device_id.clone()) // DeviceId clone = Arc::clone (~1ns)
.or_insert_with(EventProcessor::new);
processor.process_input(event) // EventProcessor unchanged — no device_id awareness
};
// 2. Lock-free rule matching → ActionEnvelope dispatched via bounded channel
for processed_event in processed_events {
self.message_processor.process(&device_id, &processed_event);
}
Ok(())
}
Heartbeat loop for time-based events (Council critical finding: hold detection and double-tap timers require periodic wake-up even when no new MIDI input arrives):
/// Ticks all active EventProcessors every 50ms to flush pending
/// time-based events (Hold, DoubleTap timeout, Chord timeout).
/// Without this, a held note would never fire until the next input event.
async fn timer_tick_loop(
event_processors: Arc<DashMap<DeviceId, EventProcessor>>,
mapping_engine: Arc<RwLock<MappingEngine>>,
current_mode: Arc<RwLock<usize>>,
mut shutdown_rx: broadcast::Receiver<()>,
) {
let mut interval = tokio::time::interval(Duration::from_millis(50));
loop {
tokio::select! {
_ = interval.tick() => {
for mut entry in event_processors.iter_mut() {
let device_id = entry.key().clone();
let timed_events = entry.value_mut().tick(Instant::now());
// ... dispatch timed_events through mapping_engine
}
}
_ = shutdown_rx.recv() => break,
}
}
}
New hot-plug detection loop (spawned as async task):
/// Hot-plug detection: polls for port changes every 1.5 seconds.
/// New ports are automatically opened. Removed ports are cleaned up.
async fn hot_plug_loop(
input_manager: Arc<Mutex<Option<InputManager>>>,
event_processors: Arc<DashMap<DeviceId, EventProcessor>>,
event_tx: mpsc::Sender<DeviceEvent<InputEvent>>,
command_tx: mpsc::Sender<DaemonCommand>,
mut shutdown_rx: broadcast::Receiver<()>,
) { ... }
macOS CoreMIDI persistent watcher (v4.26.1): On macOS, CoreMIDI caches
the list of MIDI sources. Device removal is push-based (IOKit), but device
addition requires an active MIDIClient to receive notifications. The
hot-plug loop's transient MidiInput instances are too short-lived to receive
these notifications. A persistent watcher thread (midi_watcher.rs) keeps a
long-lived MidiInput + CFRunLoopRun() alive for the daemon's lifetime,
ensuring CoreMIDI's device cache stays up to date. The watcher stores its
CFRunLoopRef via AtomicPtr so the handle can call CFRunLoopStop for
clean shutdown. On non-macOS platforms, the watcher is a no-op.
8. IPC Changes
File: conductor-daemon/src/daemon/types.rs
pub enum IpcCommand {
// Unchanged
Ping, Status, Reload, Stop, ValidateConfig,
ListDevices, // ENHANCED: returns all listening ports with binding info
GetDevice, // ENHANCED: returns multi-device status
// DEPRECATED (kept as compatibility shims per Council recommendation)
SetDevice, // Maps to SetDeviceEnabled("main", true) + log deprecation warning
DisconnectDevice, // Maps to SetDeviceEnabled("main", false) + log deprecation warning
// NEW (ADR-009)
SetDeviceEnabled, // { device_id: String, enabled: bool } — mute/unmute
// Unchanged
StartMidiLearn, StopMidiLearn, GetMidiLearnEvents,
ApplyPlan, RejectPlan, ListPendingPlans, ExecuteMcpTool,
}
IPC Backward Compatibility: SetDevice and DisconnectDevice are kept as deprecated shims for one release cycle. SetDevice { port: N } maps to enabling the device on that port and disabling others (emulating single-device behavior). DisconnectDevice maps to SetDeviceEnabled("main", false). Both emit deprecation warnings in the response and logs.
DeviceStatus becomes multi-device:
pub struct DeviceStatus {
/// Whether ANY device is listening (backward compat)
pub connected: bool,
/// Number of devices currently being listened to
pub device_count: usize,
/// Per-device status entries
pub devices: Vec<DeviceEntry>,
}
pub struct DeviceEntry {
pub device_id: String,
pub port_name: String,
pub port_index: usize,
pub alias: Option<String>,
pub listening: bool,
pub events_count: u64,
}
MidiLearnEvent gains device attribution:
pub struct MidiLearnEvent {
pub device_id: Option<String>, // NEW: which device produced the event
pub event_type: EventType,
pub note: Option<u8>,
// ... rest unchanged
}
9. Lock-Free Rule Engine (from ADR-008)
File: conductor-core/src/engine.rs (NEW)
The MappingEngine (with RwLock on every config reload) is replaced by a lock-free CompiledRuleSet swapped atomically via ArcSwap. The hot path — MIDI callback → rule match → action dispatch — holds no locks.
Why: MappingEngine uses RwLock which blocks readers during config reload (rare but unbounded). With multiple devices producing events concurrently, even a brief write-lock blocks all processing threads. ArcSwap provides wait-free reads: readers see either the old or new rule set, never block.
use arc_swap::ArcSwap;
use std::sync::Arc;
/// Pre-compiled, immutable rule set. Created from config at load time.
/// Swapped atomically on config reload — the hot path never locks.
#[derive(Clone, Debug)]
pub struct CompiledRuleSet {
/// Rules indexed by source device alias for O(1) lookup
rules_by_device: HashMap<String, Vec<CompiledRule>>,
/// Global rules (device filter = None, match any source)
global_rules: Vec<CompiledRule>,
/// Version counter for cache invalidation / observability
version: u64,
}
/// A single compiled rule: pre-validated, pre-optimised for matching
#[derive(Clone, Debug)]
pub struct CompiledRule {
pub id: u64,
pub name: Option<String>,
pub trigger: CompiledTrigger,
pub action: CompiledAction,
pub condition: Option<RuleCondition>,
/// Priority for conflict resolution (higher wins)
pub priority: i32,
/// If true, stop evaluating further rules after this match
pub consume: bool,
}
/// Pre-compiled trigger for zero-allocation matching on the hot path
#[derive(Clone, Debug)]
pub enum CompiledTrigger {
/// Match a specific MIDI note on/off
MidiNote {
channel: Option<u8>,
note: u8,
velocity_min: u8,
velocity_max: u8,
event_type: NoteEventType,
},
/// 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>,
},
/// Match MIDI pitch bend
MidiPitchBend { channel: Option<u8> },
/// Match any MIDI message (passthrough for forwarding)
MidiAny,
/// Gesture events from EventProcessor
Hold { note: u8 },
DoubleTap { note: u8 },
Chord { notes: Vec<u8> },
/// HID triggers (existing)
GamepadButton { button: u8 },
GamepadAnalogStick { stick_id: u8 },
GamepadTrigger { trigger_id: u8 },
}
/// The core message processor. Lock-free rule evaluation.
pub struct MessageProcessor {
/// Current compiled rule set — swapped atomically on config reload
rule_set: Arc<ArcSwap<CompiledRuleSet>>,
/// Current active mode (swapped atomically)
active_mode: Arc<ArcSwap<String>>,
/// Action dispatch channel (bounded tokio mpsc — Sender::try_send is sync-safe
/// for use in the lock-free hot path; Receiver is async-friendly for the dispatcher)
action_tx: tokio::sync::mpsc::Sender<ActionEnvelope>,
}
/// An action ready for dispatch, with full provenance
#[derive(Clone, Debug)]
pub struct ActionEnvelope {
pub action: CompiledAction,
pub rule_id: u64,
pub source_device: DeviceId,
pub original_event: ProcessedEvent,
pub timestamp: Instant,
}
impl MessageProcessor {
/// Process a DeviceEvent<ProcessedEvent>. Called from EngineManager event loop.
///
/// ZERO LOCKS: ArcSwap::load() is wait-free for readers.
/// ZERO ALLOCATIONS: CompiledTrigger matching is pure comparison.
pub fn process(&self, device_id: &DeviceId, event: &ProcessedEvent) {
// Load current rule set (wait-free — ~1ns)
let rule_set = self.rule_set.load();
let active_mode = self.active_mode.load();
// O(1) lookup: rules for this specific device + global rules
let device_rules = rule_set.rules_by_device.get(device_id.as_str());
let rule_chains = device_rules
.into_iter()
.flatten()
.chain(rule_set.global_rules.iter());
for rule in rule_chains {
if let Some(ref condition) = rule.condition {
if !evaluate_condition(condition, &active_mode) {
continue;
}
}
if matches_trigger(&rule.trigger, event) {
let envelope = ActionEnvelope {
action: rule.action.clone(),
rule_id: rule.id,
source_device: device_id.clone(),
original_event: event.clone(),
timestamp: Instant::now(),
};
// Non-blocking: drop if channel full (D9 rate limiting upstream)
let _ = self.action_tx.try_send(envelope);
if rule.consume { break; }
}
}
}
}
Config reload: When config_watcher detects a file change, a new CompiledRuleSet is compiled from the updated config and swapped in via ArcSwap::store(). In-flight rule evaluations using the old rule set complete uninterrupted. The old Arc<CompiledRuleSet> is dropped when the last reference is released.
Compilation: CompiledRuleSet is built from Config at load time by a RuleCompiler that:
- Iterates
modes[].mappings[]andglobal_mappings[] - Compiles each
Trigger→CompiledTrigger(validates ranges, precompiles regexes) - Compiles each
Action→CompiledAction(resolves device aliases, builds transforms) - Indexes rules by
devicefilter for O(1) per-device lookup - Sorts rules by priority within each device bucket
10. Action Dispatcher (from ADR-008)
File: conductor-daemon/src/dispatch.rs (NEW)
The monolithic ActionExecutor is replaced by a trait-based ActionDispatcher that routes actions to heterogeneous backends via a bounded channel. This isolates I/O latency (shell commands, network OSC, MIDI output) from the rule evaluation hot path.
use tokio::task::JoinSet;
/// 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 (generated, not forwarded)
MidiSend {
target_alias: String,
message: Vec<u8>,
},
/// 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>,
},
/// Switch mode
ModeChange { mode: String },
/// Execute a sequence of actions with delays
Sequence {
actions: Vec<CompiledAction>,
delay_between_ms: u64,
},
/// Plugin dispatch (ADR-006)
PluginAction {
plugin_name: String,
action_name: String,
params: serde_json::Value,
},
/// Suppress (consume message, do nothing)
Suppress,
// Existing action types retained:
// VolumeControl, MouseClick, Text, Repeat, Delay, Conditional
}
/// OS automation backend — 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, cmd: &str, args: &[String], cwd: Option<&str>) -> Result<(), ActionError>;
}
/// Action dispatcher: receives envelopes from the rule engine,
/// executes actions on the appropriate backend.
pub struct ActionDispatcher {
/// Receives from tokio mpsc — async-friendly, no thread blocking
action_rx: tokio::sync::mpsc::Receiver<ActionEnvelope>,
/// MIDI output connections indexed by device alias
midi_outputs: Arc<DashMap<String, MidiOutputHandle>>,
/// OS automation backend (platform-specific: enigo on macOS/Linux/Windows)
os_backend: Arc<dyn OsAutomationBackend>,
/// OSC output sockets
osc_sockets: Arc<DashMap<String, OscOutputHandle>>,
/// Plugin dispatcher (ADR-006 WASM/native)
plugin_dispatcher: Arc<dyn PluginDispatcher>,
/// Port resolver reference (for resolving target aliases to live output ports)
resolver: Arc<PortResolver>,
/// Concurrent action execution pool
task_pool: JoinSet<()>,
}
impl ActionDispatcher {
/// Main dispatch loop. Runs as a long-lived async task.
pub async fn run(&mut self) {
while let Some(envelope) = self.action_rx.recv().await {
// Spawn action execution to avoid blocking the dispatch loop
self.task_pool.spawn(async move {
if let Err(e) = execute_action(&envelope).await {
tracing::error!(rule_id = envelope.rule_id, error = %e, "Action failed");
}
});
}
}
}
Separation of concerns: The MessageProcessor (lock-free, on event loop) never performs I/O. The ActionDispatcher (async, on tokio) handles all I/O. The bounded tokio::sync::mpsc channel between them provides the sync/async bridge: Sender::try_send() is sync-safe (usable from the lock-free hot path without blocking the tokio runtime), while Receiver::recv().await is async-friendly on the dispatcher side. Overflow drops via try_send per D9 rate limiting.
Channel overflow safety (Council finding): Dropping a NoteOff during overflow causes stuck notes. Mitigation: CC messages (high-frequency, disposable intermediate values) are coalesced by the rate limiter (D9) upstream of the channel. NoteOn/NoteOff and other discrete events have higher effective priority because they pass through infrequently. If the channel is full for a NoteOff, the EventProcessor's reconnect-reset logic (D10) provides a fallback safety net.
MIDI output resolution: MidiForward and MidiSend actions reference output devices by alias. The ActionDispatcher resolves aliases to live output port handles via the PortResolver. If the target device is Unbound, the action fails with ActionError::TargetNotBound and is logged (not retried — the hot-plug loop will re-resolve when the device appears).
11. MIDI Transforms (from ADR-008)
File: conductor-core/src/transform.rs (NEW)
MIDI transforms are applied during MidiForward actions. They allow remapping channels, CC numbers, notes, scaling velocity, inverting values, and applying custom response curves — all without writing code.
/// MIDI message transform applied during forwarding
#[derive(Clone, Debug, Serialize, Deserialize)]
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, Serialize, Deserialize)]
pub enum ValueCurve {
Linear,
Logarithmic,
Exponential,
/// Custom 128-entry lookup table for arbitrary response curves
Lut(Box<[u8; 128]>),
}
/// Typed MIDI message for transform input/output.
/// Handles all MIDI message lengths (Council finding: [u8; 3] is insufficient
/// for 2-byte messages like ProgramChange, Channel Pressure, and variable-length SysEx).
#[derive(Clone, Debug)]
pub enum MidiMessage {
/// 3-byte: NoteOn/Off, CC, PitchBend, Aftertouch
Short { status: u8, data1: u8, data2: u8 },
/// 2-byte: ProgramChange, Channel Pressure
TwoByte { status: u8, data1: u8 },
/// Variable-length: SysEx (F0 ... F7)
SysEx(Vec<u8>),
}
impl MidiTransform {
/// Apply transform to a MIDI message. Returns transformed message.
/// Zero-allocation for Short messages. SysEx passes through untransformed.
pub fn apply(&self, msg: &MidiMessage) -> MidiMessage {
match msg {
MidiMessage::Short { status, data1, data2 } => {
let msg_type = status & 0xF0;
let channel = self.channel.unwrap_or(status & 0x0F);
let new_status = msg_type | channel;
let new_data1 = match msg_type {
0xB0 => self.cc.unwrap_or(*data1), // CC: remap CC number
0x90 | 0x80 => self.note.unwrap_or(*data1), // Note: remap note
_ => *data1,
};
let mut new_data2 = *data2;
if let Some(scale) = self.velocity_scale {
let scaled = (new_data2 as f32 * scale)
+ self.velocity_offset.unwrap_or(0) as f32;
new_data2 = scaled.round().clamp(0.0, 127.0) as u8;
}
if self.invert_value {
new_data2 = 127 - new_data2;
}
if let Some(ref curve) = self.curve {
new_data2 = curve.apply(new_data2);
}
MidiMessage::Short { status: new_status, data1: new_data1, data2: new_data2 }
}
MidiMessage::TwoByte { status, data1 } => {
let channel = self.channel.unwrap_or(status & 0x0F);
MidiMessage::TwoByte { status: (status & 0xF0) | channel, data1: *data1 }
}
// SysEx: pass through untransformed (transforms operate on channel messages)
MidiMessage::SysEx(data) => MidiMessage::SysEx(data.clone()),
}
}
}
impl ValueCurve {
pub fn apply(&self, value: u8) -> u8 {
match self {
Self::Linear => value,
Self::Logarithmic => {
// ln(1 + x) / ln(128) * 127, mapped to 0-127
let norm = value as f32 / 127.0;
((1.0 + norm * 127.0).ln() / 128.0_f32.ln() * 127.0) as u8
}
Self::Exponential => {
// (e^(x/127) - 1) / (e - 1) * 127
let norm = value as f32 / 127.0;
(((norm).exp() - 1.0) / (1.0_f32.exp() - 1.0) * 127.0) as u8
}
Self::Lut(lut) => lut[value as usize],
}
}
}
Config schema for MIDI forwarding:
# ─── MIDI FORWARDING WITH TRANSFORMS ─────────────────────────
# Forward CC 74 from KeyStep → CC 1 on FM8 with logarithmic curve
[[modes.mappings]]
trigger = { type = "CC", cc = 74, device = "keystep" }
action = { type = "MidiForward", target = "fm8", transform = { cc = 1, curve = "logarithmic" } }
# Forward all notes from KeyStep → FM8, remapping channel 0→9
[[modes.mappings]]
trigger = { type = "Note", note = 60, device = "keystep" }
action = { type = "MidiForward", target = "fm8", transform = { channel = 9, velocity_scale = 1.2 } }
# Invert a fader (127→0 becomes 0→127)
[[modes.mappings]]
trigger = { type = "CC", cc = 7, device = "pads" }
action = { type = "MidiForward", target = "ableton", transform = { invert_value = true } }
# ─── OSC OUTPUT ───────────────────────────────────────────────
# Send CC value as OSC float to a lighting controller
[[modes.mappings]]
trigger = { type = "CC", cc = 16, device = "keystep" }
action = { type = "OscSend", target = "127.0.0.1:9000", address = "/fader/1" }
Output device identities: Output devices use the same [[devices]] config with direction = "output" or "bidirectional". The PortResolver resolves output aliases to live output port handles, which the ActionDispatcher uses for MidiForward/MidiSend.
[[devices]]
alias = "fm8"
description = "NI FM8 virtual MIDI input"
protocol = "midi"
direction = "output"
matchers = [
{ type = "exact_name", value = "FM8 Virtual Input" },
{ type = "name_contains", value = "FM8" },
]
[[devices]]
alias = "ableton"
description = "Ableton Live via IAC Driver"
protocol = "midi"
direction = "output"
matchers = [
{ type = "exact_name", value = "IAC Driver Bus 1" },
]
GUI Changes
DeviceList.svelte
Remove Connect/Disconnect buttons. All devices show as "Listening":
- Activity dot: Green pulse when device sent events in last 2 seconds; gray when idle
- Event counter: Total events received from this device
- Mute button: Silences a device without disconnecting; events are dropped before processing
- Device name: Shows alias (if identity matches) with raw port name in smaller text
DevicesView.svelte
- Status card: "Listening on N MIDI ports" instead of "Connected: Yes/No"
- Identity bindings table: Shows configured
[[devices]]entries with current binding state (Bound/Unbound/Ambiguous)
stores.js
devicesStore: Removeconnect()anddisconnect()methods- Add
toggleMute(deviceId)method - Store state:
{ devices: [], mutedDevices: [], loading: false, error: null }
commands.rs (Tauri)
connect_midi_device: Deprecated (no-op or removed)disconnect_midi_device: Deprecated (no-op or removed)list_midi_devices: Returns all listening ports with device_id, alias, activity data- New:
set_device_enabled(alias, enabled)— mute/unmute via IPC
MCP Tool Changes
Updated Tools
| Tool | Risk Tier | Change |
|---|---|---|
conductor_list_devices | ReadOnly | Returns all listening ports with device_id, alias, events_count, binding status |
conductor_get_status | ReadOnly | Returns device_count + devices[] instead of single connected boolean |
New Tools
| Tool | Risk Tier | Description |
|---|---|---|
conductor_scan_ports | ReadOnly | Scan all MIDI/HID ports with metadata (name, index, platform ID) for identity creation |
conductor_create_device_identity | ConfigChange | Create a [[devices]] entry with alias and matchers (returns Plan for approval) |
conductor_set_device_enabled | Stateful | Enable/disable (mute/unmute) listening on a specific device |
Config Schema (TOML)
# ─── DEVICE IDENTITIES (optional, for stable references) ──────────
[[devices]]
alias = "pads"
description = "Maschine Mikro MK3 — pad controller"
protocol = "midi"
direction = "input"
matchers = [
{ type = "name_contains", value = "Maschine Mikro" },
]
[[devices]]
alias = "keys"
description = "M-Audio Keystation — keyboard"
protocol = "midi"
direction = "input"
matchers = [
{ type = "usb_identifier", vendor_id = 0x0763, product_id = 0x0192 },
{ type = "name_contains", value = "Keystation" },
]
# ─── MODE MAPPINGS (with optional device filter) ──────────────────
[[modes]]
name = "Default"
color = "blue"
# Device-specific: only triggers from "pads"
[[modes.mappings]]
trigger = { type = "Note", note = 36, device = "pads" }
action = { type = "Keystroke", keys = ["cmd", "space"] }
# Device-specific: same note, different device, different action
[[modes.mappings]]
trigger = { type = "Note", note = 36, device = "keys" }
action = { type = "Keystroke", keys = ["a"] }
# Any device (no filter): backward-compatible default
[[modes.mappings]]
trigger = { type = "Note", note = 48 }
action = { type = "Keystroke", keys = ["space"] }
Backward Compatibility
The old [device] format auto-migrates in memory at config load time:
// In Config::load()
if let Some(old_device) = config.device.take() {
if config.devices.is_empty() {
config.devices.push(DeviceIdentityConfig {
alias: "main".to_string(),
name: Some(old_device.name),
matchers: vec![], // name field used as fallback matcher
enabled: true,
..Default::default()
});
}
}
The config file on disk is not automatically rewritten. A separate conductor migrate-config CLI command can update the file explicitly.
Existing triggers without device field match any device (None = wildcard). This ensures all current configs work unchanged.
Implementation Plan
Phase 1: Core Types and Config Migration
Goal: All types compile, config migrates, tests pass.
| File | Change |
|---|---|
conductor-core/src/config/types.rs | Config.device → optional, add Config.devices, add DeviceIdentityConfig, DeviceMatcherConfig, add device: Option<String> to all Trigger variants, add listen_mode to AdvancedSettings, add ignore_ports list |
conductor-core/src/identity.rs | New: DeviceId newtype, DeviceEvent<T> wrapper, DeviceMatcher runtime types with matches() and specificity(), BindingState enum |
conductor-core/src/resolver.rs | New: PortResolver pure logic — (Vec<PortInfo>, Vec<DeviceIdentityConfig>) → Vec<BindingResult> (D16) |
conductor-core/src/config/loader.rs | [device] → [[devices]] migration in Config::load() |
conductor-core/src/events.rs | Unchanged — InputEvent/ProcessedEvent keep current structure. Device attribution via DeviceEvent<T> wrapper |
conductor-core/src/event_processor.rs | Unchanged — EventProcessor processes raw events, unaware of devices. Per-device isolation handled at EngineManager layer |
conductor-core/src/mapping.rs | Add device: Option<String> to CompiledTrigger, device filter in trigger_matches_processed() |
conductor-core/src/lib.rs | Export identity module |
Phase 2: Daemon Multi-Device Manager
Goal: Daemon opens all ports, tags events, per-device EventProcessors.
| File | Change |
|---|---|
conductor-daemon/src/input_manager.rs | Option<MidiDeviceManager> → HashMap<DeviceId, MidiDeviceManager>, listen_to_all_ports(), resolve_device_id(), set_device_enabled() |
conductor-daemon/src/midi_device.rs | Add device_id field, device-specific reconnection commands |
conductor-daemon/src/daemon/engine_manager.rs | event_processor → event_processors: DashMap<DeviceId, EventProcessor>, per-device routing in process_input_event(), timer_tick_loop() for time-based events, startup calls listen_to_all_ports() |
conductor-daemon/src/daemon/types.rs | Deprecate SetDevice/DisconnectDevice IPC (keep as shims), add SetDeviceEnabled, update DeviceStatus to multi-device, device-specific DaemonCommand variants |
Phase 3: Lock-Free Rule Engine and Action Dispatch
Goal: Replace MappingEngine/ActionExecutor with lock-free pipeline.
| File | Change |
|---|---|
conductor-core/src/engine.rs | New: CompiledRuleSet, CompiledRule, CompiledTrigger, MessageProcessor with ArcSwap, ActionEnvelope |
conductor-core/src/transform.rs | New: MidiTransform, ValueCurve (Linear, Logarithmic, Exponential, Lut) |
conductor-core/src/dispatch.rs | New: CompiledAction, OsAutomationBackend trait, PluginDispatcher trait |
conductor-daemon/src/dispatch.rs | New: ActionDispatcher implementation with MIDI output, OS automation, OSC, plugin backends |
conductor-daemon/src/daemon/engine_manager.rs | Replace mapping_engine: RwLock<MappingEngine> with message_processor: Arc<MessageProcessor>, wire ActionDispatcher to bounded channel |
conductor-core/src/config/types.rs | Add MidiForward, MidiSend, OscSend action types; add MidiTransform config |
conductor-core/src/mapping.rs | Retain MappingEngine as deprecated compatibility shim; RuleCompiler compiles Config → CompiledRuleSet |
Phase 4: Hot-Plug and GUI
Goal: Automatic port detection, GUI shows all listening devices.
| File | Change |
|---|---|
conductor-daemon/src/daemon/engine_manager.rs | Add hot_plug_loop() async task |
conductor-gui/ui/src/lib/components/DeviceList.svelte | Remove Connect/Disconnect, show "Listening" with activity dots and mute |
conductor-gui/ui/src/lib/views/DevicesView.svelte | Multi-device status display, identity binding table |
conductor-gui/ui/src/lib/stores.js | Remove connect()/disconnect(), add toggleMute() |
conductor-gui/src-tauri/src/commands.rs | Deprecate connect_midi_device/disconnect_midi_device, update list_midi_devices return type, add set_device_enabled |
Phase 5: MCP Tools and Identity Management
Goal: LLM tools work with multi-device, identity CRUD.
| File | Change |
|---|---|
| MCP tool definitions | Update conductor_list_devices, conductor_get_status; add conductor_scan_ports, conductor_create_device_identity, conductor_set_device_enabled |
| Agent skill files | Update for device identity model and multi-device awareness |
Phase 6: Polish, Testing, Documentation
Goal: Comprehensive test coverage, documentation.
- Config validation: unique device aliases, valid matcher references
conductor migrate-configCLI for explicit file migration- Property tests: device filter determinism, matcher specificity ordering, transform value bounds
- Integration test: 2+ simulated MIDI ports with device-specific mappings
- End-to-end test: MIDI input → rule match → MIDI forward output with transform
- Benchmark: lock-free rule engine latency under concurrent multi-device load
- CHANGELOG, CLAUDE.md, docs-site updates
Performance
Memory Impact
| Scenario | Memory |
|---|---|
| 1 device (current) | ~12KB (1 EventProcessor + 1 channel) |
| 3 devices | ~26KB (3 EventProcessors + 1 shared channel + HashMap overhead) |
| 5 devices | ~40KB (5 EventProcessors + 1 shared channel + HashMap overhead) |
Verdict: Negligible. ~40KB against 10-15MB resident memory.
Latency Impact
| Operation | Overhead |
|---|---|
| Device ID resolution (HashMap lookup) | +0.01ms |
| Per-device EventProcessor dispatch (DashMap) | +0.05ms |
| Rule set load (ArcSwap, wait-free) | +0.001ms |
| Rule matching (CompiledTrigger, zero-alloc) | +0.01ms |
| MidiTransform apply (in-place) | +0.005ms |
| Action dispatch (bounded channel try_send) | +0.01ms |
| Total per event | ~0.09ms |
Well within the <1ms latency budget. The lock-free pipeline (ArcSwap + DashMap + bounded channel) means no event blocks waiting for another. Config reloads swap the entire CompiledRuleSet atomically — in-flight rule evaluations see either the old or new rules, never an inconsistent state.
Event ordering is preserved within a single device (guaranteed by midir's callback). Cross-device ordering is non-deterministic but irrelevant since EventProcessors are isolated.
Config Reload Latency
| Operation | Time |
|---|---|
| Parse TOML config | ~1ms |
Compile Config → CompiledRuleSet | ~0.5ms (50 rules) |
ArcSwap::store() (atomic pointer swap) | ~10ns |
| Old rule set drop (after last reader exits) | Deferred, non-blocking |
Total config reload: ~1.5ms, zero impact on in-flight events.
Hot-Plug Overhead
- Polling interval: 1.5 seconds
- Port enumeration: <1ms (midir
MidiInput::new()+ports()) - Runs on separate async task, does not block event processing
Consequences
Positive
- Zero-config for single device: Existing users change nothing — daemon auto-listens to their one device
- Multi-device just works: Plug in two controllers and both are heard immediately
- Same note, different actions: Note 36 from "pads" can map differently than note 36 from "keys"
- No manual connect/disconnect: Devices appear and disappear naturally; daemon follows
- Isolated gesture detection: Per-device EventProcessors prevent cross-device chord/timing interference
- Backward compatible: Old
[device]config auto-migrates; old triggers withoutdevicefilter match any device - Stable references via ADR-008: Device identities with alias and matcher chains survive reboots and USB topology changes
- Lock-free hot path:
ArcSwapprovides wait-free rule evaluation — no locks held during MIDI callback processing, even during config reload - MIDI routing:
MidiForwardwithMidiTransformenables device-to-device routing (e.g., "remap KeyStep CC 74 → FM8 CC 1 with logarithmic curve") without external tools - Heterogeneous dispatch: Trait-based
ActionDispatchercleanly separates I/O-bound action execution from latency-sensitive rule matching, and supports MIDI output, OS automation, OSC, and plugins through the same interface
Negative
- All ports opened: On systems with many virtual MIDI ports (e.g., DAWs with 20+ IAC channels), the daemon opens them all. Mitigated by
enabled: falseon unwanted identities and the mute button - Port enumeration overhead: Opening N ports takes N * ~1ms at startup. For typical setups (1-5 devices), this is <5ms
- Deprecated IPC commands:
SetDeviceandDisconnectDeviceare kept as backward-compat shims for one release cycle, then removed. Clients should migrate toSetDeviceEnabled - Device ID instability for unconfigured devices: Without a
DeviceIdentity, the raw port name is used as device_id. Port names can change across platforms. Mitigated by encouraging users to create identities for devices they want stable references to - New dependency:
arc-swapcrate added for lock-free rule set swapping. Well-maintained, widely used in the Rust ecosystem, minimal transitive dependencies. Channel infrastructure uses existingtokio::sync::mpsc - Increased architectural surface: The pipeline now has 4 layers (InputManager → EventProcessor → MessageProcessor → ActionDispatcher) vs the current 3. Mitigated by clear trait boundaries and single-responsibility per layer
Neutral
- Gamepad unchanged: HID devices remain single-device via gilrs (which handles multi-gamepad internally). Future work may extend
device_idto gamepads - No cross-device chords: By design, chord detection is per-device. A chord spanning two devices is not detected. This is correct behavior for the vast majority of use cases
Decisions on Council-Identified Concerns
The following decisions close issues raised during LLM Council review.
D1: Max Simultaneous Ports
Decision: Soft limit of 32 ports. Beyond 32, new ports are logged at WARN level and skipped. Each port costs ~8KB memory + one converter task. The limit is configurable via max_midi_ports in [advanced_settings].
D2: Regex DoS in NameRegex Matcher
Decision: Use the Rust regex crate (guaranteed linear-time, NFA-based — no catastrophic backtracking). Cap pattern length at 256 characters. Precompile regexes at config load, not per-event. Regexes are only evaluated during port resolution (startup + hot-plug), never in the event hot path. USB device name descriptors are capped at 255 bytes by spec, providing a natural input bound.
D3: Config File Migration Strategy
Decision: Config files are never auto-rewritten on disk. The [device] → [[devices]] migration happens in memory at load time via serde deserialization. The original file is preserved. An explicit conductor migrate-config CLI command is provided for users who want to update the file. This avoids destroying comments, formatting, and git history.
D4: Virtual MIDI Ports and Feedback Loops
Decision: Virtual ports (IAC Driver, loopMIDI) are treated as normal input ports. However, the daemon's own virtual output ports (if any) are excluded by default to prevent feedback loops. A new ignore_ports config list allows users to exclude specific ports by name pattern:
[advanced_settings]
ignore_ports = ["IAC Driver*"] # Glob patterns for ports to skip
D5: MIDI Learn Device Attribution
Decision: Yes. MIDI Learn events include device_id. The GUI prompts "Assign to [device alias] only, or any device?" when creating a mapping from a Learn event. This enables device-specific mappings from Learn.
D6: Windows Port Exclusivity (Council Critical)
Decision: Windows MIDI ports are often single-client. The "listen to all" model must be opt-out per port. The ignore_ports config (D4) addresses this. On Windows, the default config template includes common DAW virtual ports in ignore_ports. Additionally, if a port fails to open (already locked by another app), the daemon logs a warning and skips it — no crash, no retry.
D7: Duplicate Alias Resolution
Decision: First-match-wins. Identities are resolved in config order (top to bottom). If two physical ports match the same alias, the first match claims the alias; the second port falls back to its raw port name as device_id. A warning is logged: "Port X also matches alias 'pads' but alias is already bound to port Y." The GUI shows this in the identity bindings table as an "Ambiguous" state.
D8: Mute Persistence
Decision: Mute state is runtime-only (not persisted to config). To permanently disable a device, set enabled = false in the [[devices]] config. The GUI clearly labels mute as "temporary (until restart)" vs the config enabled field as "permanent."
D9: Per-Device Rate Limiting (Council Security)
Decision: Each device has a rate limit of 10,000 events/second. Events beyond this are dropped with a WARN log. This prevents a malfunctioning or malicious USB device from flooding the event bus and starving legitimate devices. The limit is configurable per-device via max_events_per_sec in [[devices]].
D10: Error and Failure Handling
Decision: The daemon uses partial success semantics:
- If a port fails to open (permissions, exclusive lock, driver error), log a warning and skip it. Other ports continue normally.
- The GUI shows failed ports with a red status indicator and the error reason.
- If a device disconnects mid-chord, the per-device EventProcessor retains state for 5 seconds (allowing for brief USB glitches), then resets.
- If a device reconnects, it gets a fresh EventProcessor (previous state is discarded).
- Config parse errors on the new schema cause the daemon to refuse to start with a clear error message (not silent fallback).
D11: Twin Device / Duplicate Hardware (Council Critical)
Decision: Two-layer approach combining ADR-008's deterministic binding with runtime observability labels:
-
Runtime labels: When two ports have the same name,
DeviceId::from_port_instance()appends a suffix ("nanoKONTROL2","nanoKONTROL2 #2") so logs, UI, andDashMapkeys remain unique. These are observability labels only, not stable identity. -
Binding policy (from ADR-008): If both ports match the same configured
[[devices]]alias, the binding entersAmbiguousstate. Mappings targeting that alias become inert until the user disambiguates via MIDI Learn or adds a more specific matcher (e.g.,usb_topology,core_midi_unique_id, USB serial). The disambiguation workflow persists a stronger matcher to config, making future resolution deterministic. -
Why suffixes alone are insufficient (cross-ADR Council finding): OS enumeration order is nondeterministic — ports can swap positions across reboots. Instance suffixes based on enumeration order would silently reassign mappings to the wrong controller. ADR-008's
Ambiguousstate blocks this hazard.
DeviceId::from_port_instance(name, index) handles runtime label construction. BindingState::Ambiguous (adopted from ADR-008) handles identity policy.
D12: Heartbeat Tick Loop for Timer Events (Council Critical)
Decision: The EngineManager spawns a timer_tick_loop async task that calls EventProcessor::tick(now) on all active processors every 50ms. This flushes pending time-based events (Hold timeout, DoubleTap timeout, Chord timeout) even when no new MIDI input arrives. Without this, a held note would never fire the Hold action until the next input event wakes the processor. The 50ms interval provides sub-100ms timing accuracy while adding negligible CPU overhead (~0.01% at 5 devices).
D13: Listen Mode Configuration (Council Recommendation)
Decision: A listen_mode setting controls which ports the daemon opens:
[advanced_settings]
listen_mode = "configured" # Default: only listen to ports matching [[devices]] entries (secure)
# listen_mode = "all" # Listen to all ports (convenience for single-device / debugging)
"configured"(default): Only open ports that match a[[devices]]entry. Events from unconfigured devices never reach the mapping engine, even for mappings without adevicefilter. Best for production setups. This is ADR-008's opt-in model."all": Listen-first; unconfigured ports use raw port name as DeviceId. All mappings withoutdevicefilter apply to all devices. Best for single-device users, quick setup, and debugging.
Why "configured" is default (cross-ADR Council finding): In "all" mode, plugging in an unexpected device (e.g., a friend's controller) would immediately trigger all global mappings (CC1 → system volume, etc.). The "configured" default prevents this security/UX hazard.
Adaptive default (Review 3, full 4-model critical finding): When no [[devices]] are defined and no legacy [device] is present, "configured" mode would result in silence — breaking zero-config single-device users on upgrade. Therefore: if devices is empty and device is None, the daemon falls back to "all" mode regardless of the listen_mode setting, with a log message: "No [[devices]] configured; falling back to listen_mode=all". This preserves zero-config behavior while keeping "configured" as the secure default for users who have defined identities.
When [device] (legacy singular) is present, the migrated identity is treated as configured.
MIDI Learn exception (Review 3, critical finding): In "configured" mode, ports in Ambiguous binding state are normally inert (D11). However, the MIDI Learn subsystem must be able to receive events from Ambiguous ports to allow users to disambiguate twin devices. When MIDI Learn is active, Ambiguous ports are temporarily opened to the Learn event capture pipeline only — their events still do not reach the mapping engine. This prevents a deadlock where users cannot resolve ambiguity because the system blocks the very input needed for disambiguation.
D14: Concurrency Strategy (Council Performance)
Decision: Use DashMap<DeviceId, EventProcessor> instead of RwLock<HashMap<...>> for the per-device processor map. DashMap provides lock-free concurrent reads and shard-level locking on writes (only needed when a new device first appears). This avoids the per-event write-lock contention that would serialize all event processing under multi-device load. The MappingEngine retains its RwLock since it changes only on config reload (rare).
D15: Global vs Per-Device Mode Switching
Decision: Mode switching remains global (all devices share the same active mode). This matches the existing UX model where modes represent "contexts" (e.g., "DJ mode", "Production mode") rather than per-device states. A mode-switch trigger from any device changes the mode for all devices. Per-device modes are deferred as a future enhancement if user demand arises — the DeviceEvent<T> wrapper and per-device EventProcessor pattern supports it structurally.
D16: PortResolver Ownership — Core vs Daemon (Cross-ADR Council)
Decision: Port resolution logic lives in conductor-core, not conductor-daemon. The daemon handles I/O (port enumeration via platform APIs), but the pure matching/scoring/binding logic is in core:
conductor-core/src/identity.rs:DeviceId,DeviceEvent<T>,DeviceMatcher,specificity(),BindingState(Bound/Unbound/Ambiguous)conductor-core/src/resolver.rs:PortResolver— pure logic that takes(Vec<PortInfo>, Vec<DeviceIdentityConfig>) → Vec<BindingResult>conductor-daemon/src/input_manager.rs: ImplementsPortEnumeratortrait (platform-specific enumeration), callsPortResolver, opens/closes ports based on results
This separation keeps resolution testable (no platform dependencies in core), reusable, and aligned with ADR-008's original placement.
D17: ADR-008 Reconciliation (Cross-ADR Council, Updated)
Decision: ADR-008 is fully superseded by ADR-009. All components are adopted or rejected:
| ADR-008 Component | Status | Notes |
|---|---|---|
DeviceIdentity + DeviceMatcher model | Adopted | Used by ADR-009 for stable device references |
BindingState (Bound/Unbound/Ambiguous) | Adopted | D11 uses Ambiguous for duplicate devices |
PortResolver (binding logic) | Adopted | Moved to conductor-core (D16) |
| Disambiguation workflow (MIDI Learn) | Adopted | D11 integrates for twin device resolution |
Lock-free MessageProcessor (ArcSwap<CompiledRuleSet>) | Adopted | Replaces MappingEngine with RwLock. See Component 9 |
ActionDispatcher (heterogeneous output) | Adopted | Replaces monolithic ActionExecutor. See Component 10 |
CompiledTrigger / MidiTransform | Adopted | Zero-alloc trigger matching + MIDI forwarding with transforms. See Component 11 |
[[device]] / [[map]] config schema | Rejected | ADR-009's [[devices]] + [[modes.mappings]] wins |
ADR-008's status: "Fully Superseded by ADR-009 — All components adopted; Config schema rejected."
D18: Action Dispatch and MIDI Forwarding (Cross-ADR Council, Updated)
Decision: The monolithic ActionExecutor is replaced by a trait-based ActionDispatcher with heterogeneous backends (see Component 10). New action types MidiForward (with MidiTransform) and OscSend are added alongside existing types. The OsAutomationBackend trait abstracts platform-specific keystroke/shell/launch execution, making the dispatch layer testable and extensible.
ADR-008's MidiTransform (channel remap, CC remap, note remap, velocity scaling/offset, value inversion, logarithmic/exponential/custom LUT curves) is adopted in full (see Component 11). This enables MIDI-to-MIDI routing workflows (e.g., "remap KeyStep CC 74 → FM8 CC 1 with logarithmic curve") without external tools.
D19: DeviceId Semantics — Bound vs Unbound (Review 3, Full 4-Model)
Decision: DeviceId values follow explicit semantic rules depending on binding state:
| Binding State | DeviceId Value | Example |
|---|---|---|
| Bound (alias match) | Alias string | "pads" |
| Unbound (no match, unique name) | Raw port name | "Arturia KeyStep 37" |
| Unbound (no match, duplicate name) | Port name + instance suffix | "nanoKONTROL2 #2" |
| Ambiguous (multiple ports match alias) | First: alias; others: raw port name + suffix | "pads" / "Maschine Mikro MK3 #2" |
Trigger device filters match against the DeviceId value. This means:
device = "pads"matches the bound alias (stable across reboots)device = "Arturia KeyStep 37"matches the raw port name (fragile but works without config)- Unbound devices with duplicate names use instance suffixes that are not stable across reboots — users should create
[[devices]]entries for any device they want to target reliably
D20: Event Processing Model — Push vs Tick (Review 3, Full 4-Model)
Decision: Conductor uses a push-based event model with a tick-only timer supplement:
-
Push-based: MIDI events arrive via midir callbacks, are wrapped in
DeviceEvent<InputEvent>, and pushed into anmpsc::channel. TheEngineManagerevent loop processes them as they arrive. There is no polling for MIDI events. -
Tick supplement (50ms): The
timer_tick_loop(D12) exists solely to flush time-dependent state inEventProcessor— specifically, Hold timeout (default 2000ms), DoubleTap timeout (default 300ms), and Chord timeout (default 50ms). The tick does not poll for MIDI events; it callsEventProcessor::tick(now)to check if any pending timers have expired. -
Why not pure push?:
EventProcessoraccumulates state (e.g., "note 36 was pressed at time T, waiting for hold threshold"). Without a periodic tick, this state only advances when the next input event arrives. If no input arrives, the hold action never fires. The 50ms tick resolves this with minimal overhead.
D21: Virtual Port Handling (Review 3, Full 4-Model)
Decision: Virtual MIDI ports (IAC Driver on macOS, loopMIDI on Windows, ALSA virtual ports on Linux) are recognized as a distinct category with specific handling:
-
Conductor's own virtual output ports are auto-excluded from input listening to prevent feedback loops. Detection: the daemon tracks port names it creates via
MidiOutput::create_virtual()and adds them to an internal exclusion list. -
Third-party virtual ports (IAC Driver, loopMIDI) are treated as normal input ports by default. Users can exclude them via
ignore_ports(D4) or by not defining a[[devices]]entry when in"configured"mode. -
Virtual ports in
"all"mode: All virtual ports are opened unless excluded byignore_ports. This is the expected behavior for DAW integration workflows where virtual ports are intentional. -
Platform-specific defaults: The default config template includes platform-appropriate
ignore_portssuggestions (commented out) to guide users.
D22: Rich ProcessedEvent for Rule Context (Review 5, Council)
Decision: ProcessedEvent variants must carry all metadata the rule engine might need. The MessageProcessor must never query EventProcessor state — all context is snapshot-copied into the event at detection time.
Examples:
ProcessedEvent::Hold { note, press_velocity, duration_ms }— velocity and duration available for rulesProcessedEvent::DoubleTap { note, first_velocity, second_velocity, interval_ms }— both tap velocitiesProcessedEvent::Chord { notes, velocities }— per-note velocities
This ensures the MessageProcessor remains stateless and lock-free. If a rule needs to check "was the hold velocity above 100?", that data is already in the event.
D23: Raw Event Passthrough in EventProcessor (Review 5, Council)
Decision: The EventProcessor emits a ProcessedEvent::Raw(InputEvent) immediately for every input, before any gesture detection. This ensures:
- Raw MIDI triggers (e.g.,
CompiledTrigger::MidiNote,MidiCC) fire on the event itself, not after gesture detection delay - Ordering is preserved: raw event first, then gesture events (Hold, Chord) when detected later via the tick loop
- No bypass path needed — all events flow through EventProcessor, maintaining a single pipeline
The MessageProcessor matches ProcessedEvent::Raw against raw triggers and ProcessedEvent::Hold/DoubleTap/Chord against gesture triggers. A mapping with consume: true on a raw trigger prevents the gesture from firing for that note.
D24: Sync/Async Channel Bridge (Review 5, Council)
Decision: Use tokio::sync::mpsc (not crossbeam::channel) for the MessageProcessor → ActionDispatcher channel. tokio::sync::mpsc::Sender::try_send() is sync-safe and non-blocking, making it safe to call from the lock-free hot path. Receiver::recv().await is async-native on the dispatcher side, avoiding the risk of blocking a tokio worker thread (which would happen with crossbeam::Receiver::recv()).
Channel capacity: bounded at 1024 envelopes. CC messages are coalesced upstream by the rate limiter (D9) to reduce channel pressure.
Testing Strategy
Unit Tests
- Config parsing with
[[devices]]array - Backward compat: singular
[device]auto-migrates to[[devices]]withalias = "main" DeviceMatcherConfigmatching: ExactName, NameContains, NameRegex, UsbIdentifierDeviceMatcher::specificity()orderingDeviceEvent<InputEvent>correctly wraps events withDeviceIdattributionDeviceId::from_port_instance()disambiguates duplicate port names (twin device)Triggerdevice filter: match when filter matches, reject when mismatch, pass when None- Per-device
EventProcessorisolation: chord buffer from device A independent of device B listen_mode = "configured"only opens ports matching[[devices]]entrieslisten_mode = "configured"with empty[[devices]]falls back to"all"(adaptive default, D13)- MIDI Learn receives events from Ambiguous ports even in
"configured"mode (D13 exception) - DeviceId semantics: alias for bound, raw name for unbound, suffixed for duplicates (D19)
- Timer tick loop fires Hold events without requiring new input
- Conductor's own virtual output ports are excluded from input listening (D21)
CompiledTrigger::MidiNotematches correct note/channel/velocity rangeCompiledTrigger::MidiCCrespects value_min/value_max boundsCompiledTrigger::MidiNoteRangematches notes within range, rejects outside- Rule priority ordering: higher priority rules fire first
consumeflag stops further rule evaluation after matchRuleConditionevaluation: InMode, And/Or/Not boolean combinatorsArcSwaprule set swap doesn't block in-flight evaluationsCompiledRuleSet::rules_by_deviceprovides O(1) device lookupMidiTransform::apply()— channel remap produces correct status byteMidiTransform::apply()— CC remap produces correct data1MidiTransform::apply()— velocity scaling clamps to 0-127MidiTransform::apply()— value inversion (127 - value)ValueCurve::Lutapplies correct lookup table mappingValueCurve::LogarithmicandExponentialproduce monotonic output for 0-127ActionDispatcherroutesMidiForwardto correct output portActionDispatcherreturnsTargetNotBoundfor unresolved alias- Bounded channel
try_senddrops gracefully when full (no blocking) MidiMessage::TwoByte(ProgramChange) passes through transform with channel remap onlyMidiMessage::SysExpasses through transform unchangedProcessedEvent::Rawemitted for every input before gesture events (D23)ProcessedEvent::Holdcarriespress_velocityandduration_ms(D22)consume: trueon raw trigger prevents subsequent gesture trigger for same note
Integration Tests
- Two simulated devices producing events → different device_ids in ProcessedEvents
- Device-specific mapping: Note 36 from "pads" → Action X; Note 36 from "keys" → Action Y
- Hot-plug: add port → daemon opens it; remove port → daemon cleans up EventProcessor
- Config reload: add/remove
[[devices]]entries → InputManager adjusts listening ports - Mute device → events from that device are dropped
- Full pipeline: MIDI CC in → rule match → MIDI forward out with transform (end-to-end)
- Full pipeline: Note in → rule match → OS keystroke out
- Config reload: rules recompiled → new CompiledRuleSet swapped via ArcSwap → old rules still work until last reader exits
- Action dispatch isolation: slow shell command doesn't block MIDI forwarding
Property Tests
- Any valid MIDI message with any device_id parses without panic
- Device filter matching is deterministic (same inputs → same result)
- Matcher specificity ordering is a total order
- MidiTransform output is within 0-127 for all inputs 0-127 (all curve types)
- ValueCurve output is monotonically non-decreasing for 0-127 input (all built-in curves)
- CompiledRuleSet compilation is deterministic: same Config → same rules → same match results
References
docs/multi-device-architecture.md— Original design proposal (Hybrid Option C)docs/adrs/ADR-008-device-identity-port-resolution-message-pipeline.md— DeviceIdentity, PortResolver, lock-free MessageProcessor, ActionDispatcher, MidiTransform (fully superseded)docs/adrs/ADR-007-llm-integration-architecture.md— MCP tools and Plan/Apply pattern- arc-swap — Wait-free atomic pointer swap for lock-free data structures
- tokio::sync::mpsc — Bounded async channel;
Sender::try_sendis sync-safe for the lock-free hot path - rosc — OSC protocol implementation for
OscSendaction type
LLM Council Review
Review 1 (Standard Tier)
Models: grok-3, gemini-2.5-flash, gpt-4.1, claude-opus-4
Identified: DeviceEvent<T> wrapper pattern (adopted), Windows port exclusivity (D6), IPC backward compat (D4), duplicate alias resolution (D7), regex DoS (D2), rate limiting (D9), mute persistence (D8), config auto-rewrite (D3). All addressed in D1–D10.
Review 2 (Reasoning Tier)
Models: gpt-5.2, gemini-3-pro-preview, claude-opus-4.6, grok-4
Verdict: Approved with critical modifications.
Critical Findings (new in reasoning-tier review)
-
Twin Device Problem (Gemini, GPT, Claude — unanimous): Two identical controllers (e.g., two "Korg nanoKONTROL2") report the same port name.
DeviceIdfrom raw name causes HashMap collision andEventProcessorstate corruption. Resolution: Instance-basedDeviceIdwith suffix disambiguation ("nanoKONTROL2","nanoKONTROL2 #2"). See D11. -
Missing heartbeat/tick loop (Gemini):
EventProcessorhandles Hold and DoubleTap via timers, but only wakes on input. A held note never fires Hold until the next key press. Resolution: 50mstimer_tick_loopinEngineManager. See D12. -
Write lock per event (unanimous):
RwLock::write()onHashMap<String, EventProcessor>per event serializes all processing and allocatesStringper event. Resolution:DashMap<DeviceId, EventProcessor>withArc<str>clone (~1ns). See D14. -
listen_modeconfig (GPT, Claude): "Listen to all" may surprise users when unconfigured devices trigger global mappings. Resolution:listen_mode = "all" | "configured"setting; legacy[device]defaults to"configured". See D13.
Additional Findings
-
Global vs per-device modes (Claude): Mode switching should be explicitly global. Resolution: Documented as global by design. See D15.
-
DeviceEvent unwrapped too early (Claude, low): After EngineManager unwraps
DeviceEvent, the device_id and event are no longer co-located in the type system. Accepted risk — the separation is one function scope, andDeviceEvent<ProcessedEvent>through the entire matching pipeline would add complexity without proportional benefit. -
Rate limiter placement (Claude, GPT): Token bucket should live in
InputManagerper-DeviceId, before events enter the processing pipeline, to prevent channel saturation. -
Config reload behavior (Claude): New
[[devices]]entries should trigger immediate port re-resolution; removed entries should revert alias to raw port name; changed matchers should re-resolve all open ports. -
Auto-detect own virtual ports (Claude): Conductor's own virtual MIDI output ports should be auto-excluded to prevent feedback loops, rather than relying solely on
ignore_ports.
Review 3 (Cross-ADR Reasoning Tier — 3/4 Models)
Models: gpt-5.2, gemini-3-pro-preview, grok-4 (claude-opus-4.6 timed out)
Scope: Joint review of ADR-008 and ADR-009 as a pair.
Verdict: Accept ADR-009 as governing architecture; partially supersede ADR-008.
Critical Cross-ADR Findings
-
Config schema: ADR-009 wins (unanimous). ADR-008's
[[device]]/[[map]]tables break existing configs unnecessarily. Resolution:[[devices]]+[[modes.mappings]]standardized. -
PortResolver ownership: ADR-008 was right — resolution logic belongs in
conductor-core(unanimous). Daemon provides enumeration, core provides matching/scoring/binding. Resolution: See D16. -
listen_modedefault should be"configured"(Gemini, GPT):"all"is dangerous — plugging in an unexpected device triggers all global mappings. Resolution: Default changed to"configured"in D13. -
Instance suffixes are nondeterministic (Gemini, GPT, Grok): OS port enumeration order can swap across reboots. ADR-009's
#2suffix approach would silently reassign mappings. Resolution: Suffixes kept for observability; ADR-008'sAmbiguousstate adopted for binding policy in D11. -
ADR-008 disposition (unanimous): Split into adopted (Identity, Binding, Resolution), deferred (Engine, Dispatch), and rejected (Config). Resolution: See D17.
-
Action dispatch gap (Gemini, GPT): ADR-009 should define a trait boundary for future OSC/plugin dispatch. Resolution: See D18.
Review 4 (Cross-ADR Reasoning Tier — Full 4-Model)
Models: gpt-5.2, gemini-3-pro-preview, claude-opus-4.6, grok-4
Scope: Re-run of cross-ADR review to capture all 4 reasoning-tier models.
Verdict: Approved with critical amendments.
Critical Findings
-
MIDI Learn deadlock (Claude, GPT — critical): In
"configured"mode, Ambiguous ports are inert (D11). But MIDI Learn needs events from those exact ports to disambiguate them. Without an exception, users cannot resolve ambiguity. Resolution: MIDI Learn exception added to D13 — Ambiguous ports temporarily opened to Learn pipeline only. -
Zero-config silence (Claude, Gemini, Grok — critical): If a user upgrades to ADR-009 with no
[[devices]]and no legacy[device],listen_mode = "configured"results in zero open ports — complete silence. Resolution: Adaptive default added to D13 — empty config falls back to"all"mode. -
DeviceId semantic ambiguity (GPT, Gemini): The ADR did not explicitly define what DeviceId values look like for bound vs unbound vs ambiguous states. Resolution: See D19.
-
Push vs tick confusion (Claude): The 50ms heartbeat description could be read as polling for MIDI events. Clarification needed that events are push-based; tick is only for timer state. Resolution: See D20.
-
Virtual port handling (Grok, GPT): Virtual ports need explicit policy beyond
ignore_ports. Conductor's own output ports must be auto-excluded to prevent feedback loops. Resolution: See D21.
Deferred Items (acknowledged, not blocking)
- Channel overflow policy: What happens when the
mpsc::channelis full? Deferred to implementation — bounded channel with configurable capacity; drop-oldest or back-pressure TBD based on benchmarking. - Latency budget allocation: Explicit per-stage latency budgets (resolution: <0.1ms, routing: <0.05ms, matching: <0.1ms). Tracked as implementation concern, not architectural.
- Cross-platform config sharing: Users syncing config across macOS/Windows/Linux may have platform-specific matchers. Deferred — recommend platform-conditional config sections in a future ADR.
Review 5 (Lock-Free Engine Integration — Reasoning Tier)
Models: gpt-5.2, gemini-3-pro-preview, grok-4 (claude-opus-4.6 timed out)
Scope: Review of ADR-008 lock-free engine, ActionDispatcher, and MidiTransform integration into ADR-009.
Verdict: Structurally sound; two critical fixes required.
Critical Findings
-
[u8; 3]insufficient for MIDI (unanimous): MidiTransform returned fixed 3-byte array, breaking 2-byte messages (ProgramChange, Channel Pressure) and SysEx. Resolution: Replaced withMidiMessageenum (Short/TwoByte/SysEx). See Component 11. -
crossbeam::Receiver::recv() blocks tokio (unanimous): The sync
recv()call would block a tokio worker thread. Resolution: Switched totokio::sync::mpsc—Sender::try_send()is sync-safe for the hot path;Receiver::recv().awaitis async-native. See D24.
Important Findings
-
ProcessedEvent must be "fat" (unanimous): The rule engine must never query EventProcessor state. All context (velocity, duration) must be snapshot-copied into the event. Resolution: See D22.
-
Raw event passthrough (GPT, Grok): EventProcessor should emit
ProcessedEvent::Rawfor every input to preserve ordering and enable raw MIDI triggers without bypass. Resolution: See D23. -
NoteOff drop on overflow (Gemini, GPT): Dropping NoteOff during channel overflow causes stuck notes. Resolution: CC coalescing upstream via D9 rate limiting; discrete events (NoteOn/Off) have effective priority due to low frequency. EventProcessor reset on reconnect (D10) provides safety net.
All Council concerns from all five reviews have been addressed in D1–D24.