diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Gather/GatherRuleExecutorEmitModeTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Gather/GatherRuleExecutorEmitModeTests.cs
new file mode 100644
index 00000000..5955c8a9
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Gather/GatherRuleExecutorEmitModeTests.cs
@@ -0,0 +1,249 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Threading;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
+using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
+using AutopilotMonitor.Shared.Models;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
+{
+ ///
+ /// EmitMode "on_change" on : poll on the trigger cadence but
+ /// emit only when the collected result changes. The dedup state machine is asserted
+ /// synchronously via the internal ShouldEmitOnChange / ComputeCanonicalHash
+ /// (no timer races); the ExecuteRule wiring (hash-before-injection, empty-result no-op,
+ /// emitOnlyIfExists composition) through the public startup path.
+ ///
+ [Collection("SerialThreading")] // startup rules execute on the shared ThreadPool
+ public sealed class GatherRuleExecutorEmitModeTests : IDisposable
+ {
+ private readonly TempDirectory _tmp = new TempDirectory();
+ private readonly List _events = new List();
+ private readonly object _eventsGate = new object();
+ private readonly GatherRuleExecutor _executor;
+
+ private const string AbsentPath =
+ "HKLM\\SOFTWARE\\AutopilotMonitorTests\\DefinitelyAbsent_7e3a1f4b-0000-0000-0000-000000000000";
+
+ public GatherRuleExecutorEmitModeTests()
+ {
+ var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
+ _executor = new GatherRuleExecutor(
+ "sess", "tenant",
+ evt => { lock (_eventsGate) _events.Add(evt); },
+ logger)
+ {
+ UnrestrictedMode = true
+ };
+ }
+
+ public void Dispose()
+ {
+ _executor.Dispose();
+ _tmp.Dispose();
+ }
+
+ private int EventCount { get { lock (_eventsGate) return _events.Count; } }
+
+ private EnrollmentEvent SingleEvent()
+ {
+ lock (_eventsGate) return Assert.Single(_events);
+ }
+
+ private bool WaitForEventCount(int count, int timeoutMs = 10000)
+ {
+ var sw = Stopwatch.StartNew();
+ while (sw.ElapsedMilliseconds < timeoutMs)
+ {
+ if (EventCount >= count) return true;
+ Thread.Sleep(25);
+ }
+ return EventCount >= count;
+ }
+
+ private static GatherRule OnChangeRule(string id = "GATHER-OC-001") => new GatherRule
+ {
+ RuleId = id,
+ Title = id,
+ CollectorType = "registry",
+ Target = AbsentPath,
+ Trigger = "interval",
+ IntervalSeconds = 3600,
+ OutputEventType = "gather_test",
+ Enabled = true,
+ EmitMode = "on_change",
+ };
+
+ private static Dictionary Result(params (string Key, object Value)[] pairs)
+ {
+ var d = new Dictionary();
+ foreach (var (key, value) in pairs) d[key] = value;
+ return d;
+ }
+
+ // ── on_change state machine (synchronous) ──────────────────────────
+
+ [Fact]
+ public void FirstResult_AlwaysEmits_ThenUnchangedIsSuppressed_ThenChangeReemits()
+ {
+ var rule = OnChangeRule();
+
+ Assert.True(_executor.ShouldEmitOnChange(rule, Result(("exists", false)))); // first
+ Assert.False(_executor.ShouldEmitOnChange(rule, Result(("exists", false)))); // unchanged
+ Assert.False(_executor.ShouldEmitOnChange(rule, Result(("exists", false)))); // unchanged
+ Assert.True(_executor.ShouldEmitOnChange(rule, Result(("exists", true), ("Version", "1")))); // changed
+ }
+
+ [Fact]
+ public void EmitAfterSuppressionStreak_CarriesSuppressedPollCount_AndResets()
+ {
+ var rule = OnChangeRule();
+
+ Assert.True(_executor.ShouldEmitOnChange(rule, Result(("exists", false))));
+ Assert.False(_executor.ShouldEmitOnChange(rule, Result(("exists", false))));
+ Assert.False(_executor.ShouldEmitOnChange(rule, Result(("exists", false))));
+ Assert.False(_executor.ShouldEmitOnChange(rule, Result(("exists", false))));
+
+ var changed = Result(("exists", true));
+ Assert.True(_executor.ShouldEmitOnChange(rule, changed));
+ Assert.Equal(3, changed["suppressedPolls"]);
+ Assert.True(changed.ContainsKey("suppressedSinceUtc"));
+
+ // Counter must reset: the next change emits without a suppression payload.
+ var changedAgain = Result(("exists", false));
+ Assert.True(_executor.ShouldEmitOnChange(rule, changedAgain));
+ Assert.False(changedAgain.ContainsKey("suppressedPolls"));
+ Assert.False(changedAgain.ContainsKey("suppressedSinceUtc"));
+ }
+
+ [Fact]
+ public void SuppressionState_IsIsolatedPerRule()
+ {
+ var ruleA = OnChangeRule("GATHER-OC-A");
+ var ruleB = OnChangeRule("GATHER-OC-B");
+ var payload = Result(("exists", false));
+
+ Assert.True(_executor.ShouldEmitOnChange(ruleA, new Dictionary(payload)));
+ // Same content, different rule: B has no hash yet — must emit.
+ Assert.True(_executor.ShouldEmitOnChange(ruleB, new Dictionary(payload)));
+ Assert.False(_executor.ShouldEmitOnChange(ruleA, new Dictionary(payload)));
+ Assert.False(_executor.ShouldEmitOnChange(ruleB, new Dictionary(payload)));
+ }
+
+ [Fact]
+ public void DedupState_SurvivesUpdateRulesRefresh()
+ {
+ var rule = OnChangeRule();
+ Assert.True(_executor.ShouldEmitOnChange(rule, Result(("exists", false))));
+
+ // Config refresh mid-session must not reset the last-emitted hash (that would
+ // re-emit an unchanged result on every refresh).
+ _executor.UpdateRules(new List { rule });
+
+ Assert.False(_executor.ShouldEmitOnChange(rule, Result(("exists", false))));
+ }
+
+ // ── Canonical hashing ──────────────────────────────────────────────
+
+ [Fact]
+ public void CanonicalHash_IsKeyOrderInsensitive()
+ {
+ var a = new Dictionary { ["alpha"] = 1, ["beta"] = "x" };
+ var b = new Dictionary { ["beta"] = "x", ["alpha"] = 1 };
+
+ Assert.Equal(
+ GatherRuleExecutor.ComputeCanonicalHash(a),
+ GatherRuleExecutor.ComputeCanonicalHash(b));
+ }
+
+ [Fact]
+ public void CanonicalHash_DetectsValueChanges_IncludingNested()
+ {
+ var baseline = new Dictionary
+ {
+ ["exists"] = true,
+ ["entries"] = new List