Skip to content

feat(#760): Wire channel scope into rule matching (#751)#777

Merged
amiable-dev merged 6 commits intomainfrom
fix/760-pipeline-wiring-channels
Mar 24, 2026
Merged

feat(#760): Wire channel scope into rule matching (#751)#777
amiable-dev merged 6 commits intomainfrom
fix/760-pipeline-wiring-channels

Conversation

@amiable-dev
Copy link
Owner

Summary

Wire the existing channels field on DeviceIdentityConfig into the runtime rule matching pipeline. Events with channels outside a binding's scope now skip that binding's device-specific rules.

Changes

  • Add ProcessedEvent::channel() method — extracts MIDI channel from all variants
  • Add channel_scopes: HashMap<String, Vec<u8>> to CompiledRuleSet
  • Rule compiler populates scopes from Config.devices[].channels
  • match_event() gates device-specific rule lookup with is_channel_in_scope()
  • Any-device rules still apply regardless of channel scope

TDD

  • test_channel_scope_filters_events: device with channels: [9] rejects ch.0, accepts ch.9
  • test_empty_channel_scope_matches_all: empty channels = all channels match

Test plan

  • 8 routing tests pass (6 existing + 2 new)
  • cargo clippy --workspace clean
  • cargo test --workspace

Note

This PR is independent of #776 (UsbIdentifier wiring) — no merge conflicts. Both can be reviewed/merged in parallel.

🤖 Generated with Claude Code

Channel scope on DeviceIdentityConfig now filters events at runtime.
Events with channels outside the binding's scope skip device-specific
rules while any-device rules still apply.

- Add ProcessedEvent::channel() extractor for all variants
- Add channel_scopes HashMap on CompiledRuleSet (populated by compiler)
- is_channel_in_scope() check in match_event() gates device-specific rules
- Rule compiler builds scopes from Config.devices[].channels
- TDD: 2 new tests (channel filtering, empty scope = all channels)

8 routing tests pass (6 existing + 2 new).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 24, 2026 08:49
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR wires the existing per-device channels scope (DeviceIdentityConfig.channels) into the compiled rule matching pipeline so device-scoped mappings only apply when an event’s MIDI channel is within that device’s configured scope.

Changes:

  • Add ProcessedEvent::channel() to consistently extract the MIDI channel across processed event variants.
  • Compile per-device channel scopes into CompiledRuleSet and gate device-specific rule matching based on scope.
  • Add routing tests covering scoped vs unscoped device channel behavior.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
conductor-core/src/event_processor.rs Adds ProcessedEvent::channel() helper used by channel scope filtering.
conductor-core/src/rule_compiler.rs Builds per-device channel_scopes from Config.devices[].channels and attaches them to the compiled ruleset.
conductor-core/src/rule_set.rs Applies channel-scope gating to device-specific branches in match_event().
conductor-core/tests/multi_device_rule_routing_test.rs Adds tests validating scoped device matching behavior (accept/reject by channel).

Comment on lines +85 to +88
/// Set channel scopes from device identity configs (#751)
pub(crate) fn set_channel_scopes(&mut self, scopes: HashMap<String, Vec<u8>>) {
self.channel_scopes = scopes;
}
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CompiledRuleSet is documented as “immutable” / “never mutated after construction”, but the new set_channel_scopes(&mut self, ...) mutator violates that invariant and makes it easier to accidentally mutate a rule set after it has been swapped into ArcSwap. Prefer passing channel_scopes into CompiledRuleSet::new(...) (or building it inside new) and remove the setter to keep the type construction-only and truly immutable.

Copilot uses AI. Check for mistakes.
input: None,
output: None,
protocol: None,
channels: vec![9], // Only channel 10 (0-indexed)
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The inline comment is internally inconsistent: channels: vec![9] is “channel 9 (0-indexed)” which corresponds to MIDI channel 10 in 1-indexed UI terms, but the comment currently says “Only channel 10 (0-indexed)”. Please correct the wording to avoid confusion about the 0- vs 1-indexed convention.

Suggested change
channels: vec![9], // Only channel 10 (0-indexed)
channels: vec![9], // Only channel 9 (0-indexed, i.e., MIDI channel 10)

Copilot uses AI. Check for mistakes.
Comment on lines 116 to 129
// Channel scope check (#751): if device has a channel scope and this
// event's channel is outside it, skip all device-specific matching.
// Any-device rules and global rules still apply (they're not device-scoped).
let device_in_scope =
device_id.is_none_or(|device| self.is_channel_in_scope(device, event));

// Check mode rules if mode_index is valid
if let Some(mode) = self.mode_rules.get(mode_index) {
// 1. Device-specific rules for this mode
if let Some(device) = device_id
// 1. Device-specific rules for this mode (skip if channel out of scope)
if device_in_scope
&& let Some(device) = device_id
&& let Some(rules) = mode.device_rules.get(device)
&& let Some(action) = find_matching_action(rules, event)
{
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Channel scoping is only applied in match_event(). match_event_with_provenance() still evaluates device-specific rules even when the event channel is out of scope, which can cause actions to fire when the daemon uses provenance matching (e.g., in event monitoring/capture paths). Apply the same device_in_scope gating to the device-specific branches in match_event_with_provenance() (and add/adjust a test to cover this case).

Copilot uses AI. Check for mistakes.
…e, comment

- Pass channel_scopes into CompiledRuleSet::new() instead of setter
- Apply channel scope check to match_event_with_provenance() too
- Fix comment: channel 9 = 0-indexed (MIDI channel 10)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comment on lines +35 to +41
// Build channel scopes from device identity configs (#751)
let channel_scopes: HashMap<String, Vec<u8>> = config
.devices
.iter()
.filter(|d| !d.channels.is_empty())
.map(|d| (d.alias.clone(), d.channels.clone()))
.collect();
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

compile() filters out devices with empty channels, so channel_scopes will never contain an entry with an empty vec. This makes the "Empty vec = all channels" semantics depend on absence of a key rather than an empty value; consider either (a) collecting all devices (including empty channels) so the data matches the documented semantics, or (b) updating the documentation/comments to reflect that missing key means "all channels".

Copilot uses AI. Check for mistakes.
Comment on lines +86 to 97
/// Check if an event's channel is in scope for a device (#751)
fn is_channel_in_scope(&self, device_id: &str, event: &ProcessedEvent) -> bool {
match self.channel_scopes.get(device_id) {
Some(channels) if !channels.is_empty() => {
match event.channel() {
Some(ch) => channels.contains(&ch),
None => true, // Non-MIDI events bypass channel filtering
}
}
_ => true, // No scope or empty scope = all channels
}
}
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CompiledRuleSet::is_channel_in_scope() duplicates the channel-scope semantics already implemented on DeviceIdentityConfig::is_channel_in_scope(). To reduce the risk of these two implementations drifting over time, consider extracting a shared helper (e.g., a small function that takes &[u8] + Option<u8>), and have both places call it.

Copilot uses AI. Check for mistakes.
… doc

- Extract channel_in_scope() as shared helper in rule_set.rs
- DeviceIdentityConfig::is_channel_in_scope() delegates to shared helper
- Document that missing key in channel_scopes = no restriction
- Prevents logic drift between two implementations

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

global_rules: GlobalRuleSet,
/// Monotonic version for debugging/logging
version: u64,
/// Channel scopes per device alias (#751). Empty vec = all channels.
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The struct field doc says "Empty vec = all channels", but rule_compiler::compile() intentionally does not store entries for devices with empty channels (it omits the key entirely). Consider updating this comment to reflect the actual invariant (e.g., missing key means no restriction, and an empty vec would also mean no restriction if ever stored).

Suggested change
/// Channel scopes per device alias (#751). Empty vec = all channels.
/// Channel scopes per device alias (#751).
/// Missing key = no restriction (all channels); an empty vec would also mean no restriction if present.

Copilot uses AI. Check for mistakes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Comment on lines +108 to +113
// Channel scope check (#751): if device has a channel scope and this
// event's channel is outside it, skip all device-specific matching.
// Any-device rules and global rules still apply (they're not device-scoped).
let device_in_scope =
device_id.is_none_or(|device| self.is_channel_in_scope(device, event));

Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Channel scoping is intended to skip only device-specific rule lookups while still allowing mode/global any-device rules to match. The new tests cover device-specific rejection/acceptance, but there’s no assertion that an out-of-scope event still matches an any-device rule (i.e., a trigger with no device filter). Adding a test for that interaction would prevent regressions to the stated behavior.

Copilot uses AI. Check for mistakes.
Verifies that when a device's channel scope rejects an event, the
any-device rules in the same mode still fire. This covers the
interaction between channel scoping and rule priority ordering.

9 routing tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Comment on lines +176 to +179
// Channel scope check (#751) — same as match_event()
let device_in_scope =
device_id.is_none_or(|device| self.is_channel_in_scope(device, event));

Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The channel-scope gating logic is duplicated in both match_event() and match_event_with_provenance(). To reduce the risk of future drift (e.g., behavior changes made in one but not the other), consider factoring this into a small shared helper (or reusing match_event() and then enriching provenance).

Copilot uses AI. Check for mistakes.
…venance (round 5)

Both methods apply the same channel scope gating pattern. Comments
cross-reference each other to prevent drift during future changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

@amiable-dev amiable-dev merged commit 8b0ed25 into main Mar 24, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants