diff --git a/docs/index.md b/docs/index.md
index 49178c41..09b28c8f 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -22,7 +22,7 @@ https://docs.autopilotmonitor.com (separate repository).
# Rules
-* [Gather Rule Phase Scoping & Emit-on-Change](rules/gather-rule-phase-scoping.md) - Restricting gather rules to enrollment phases (activePhases / activeFromPhase sticky latch) and emitMode "on_change" result-dedup — the anti-spam pair for interval rules.
+* [Gather Rule Phase Scoping, One-Shot Triggers & Emit-on-Change](rules/gather-rule-phase-scoping.md) - Restricting gather rules to enrollment phases (activePhases / activeFromPhase sticky latch), one-shot collection at a phase's start/end (phase_change / phase_exit), and emitMode "on_change" result-dedup.
# Backend
diff --git a/docs/log.md b/docs/log.md
index f07a33dc..97a0b941 100644
--- a/docs/log.md
+++ b/docs/log.md
@@ -2,6 +2,7 @@
## 2026-07-20
+* **Update**: Extended `rules/gather-rule-phase-scoping.md` with the `phase_exit` trigger — the closing bookend to `phase_change`, for one-shot collection when a phase is LEFT (e.g. "state at the end of Account Setup"). Documents the evaluation-before-phase-advance rule (triggerPhase match and scope gate see the phase being left), firing into `Failed`, the disjoint `exit:` dedup key space, and why no ConfigVersion bump is needed (no new field — old agents simply never fire the rule).
* **Update**: Reworked `agent/mdm-reboot-coalescing.md` after the first real enrollment (session b2e890c1): 2800 records are now **aggregated per burst** into ONE neutral Info event (`rebootUris[]`/`uriCount`/`firstRebootUri`; watermark persisted at flush so a pre-emit reboot kill loses nothing); the raw event no longer claims a reboot — ANALYZE-ESP-005 is gated on an actually-observed `system_reboot_detected`. **Removed** the Shell-Core `esp_reboot_coalescing` event entirely: the "RebootCoalescing" token appears in the routine `SubcategoryProcessing_Started` bootstrap marker on every enrollment (false positive by construction). Verified 2800 description shape recorded.
* **Creation**: Added `agent/mdm-reboot-coalescing.md` — the agent now attributes the "unexpected mid-ESP reboot + second sign-in" pattern (PMPC research) to the device-assigned policy URIs that forced it: new `MdmRebootPolicyTracker` (DM-Enterprise EventID 2800, per-URI `mdm_policy_reboot_required` events, cross-restart RecordId watermark), a `RebootCoalescing` branch in `ShellCoreTracker` (`esp_reboot_coalescing`, backfill-emitting by design), and the `ANALYZE-ESP-005` advisory (reassign device → user groups). ConfigVersion 35; event types deliberately distinct from `system_reboot_detected` so RebootCount stays uninflated.
diff --git a/docs/rules/gather-rule-phase-scoping.md b/docs/rules/gather-rule-phase-scoping.md
index b58ac26f..5d874b21 100644
--- a/docs/rules/gather-rule-phase-scoping.md
+++ b/docs/rules/gather-rule-phase-scoping.md
@@ -1,7 +1,7 @@
---
type: Concept
-title: Gather Rule Phase Scoping & Emit-on-Change
-description: How gather rules are restricted to enrollment phases (activePhases / activeFromPhase) and how emitMode "on_change" suppresses repeated identical results — the anti-spam pair for interval rules.
+title: Gather Rule Phase Scoping, One-Shot Triggers & Emit-on-Change
+description: How gather rules are restricted to enrollment phases (activePhases / activeFromPhase), collect once at a phase boundary (phase_change / phase_exit), and how emitMode "on_change" suppresses repeated identical results.
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleExecutor.cs
tags:
- agent
@@ -35,6 +35,35 @@ Backend validation (`GatherRulesFunction.ValidateScopeAndEmitMode`) rejects `Unk
numeric tokens, unknown emit modes, and both scope fields set at once (400). The agent
defensively prefers `activePhases` if both arrive anyway.
+# One-shot collection at phase boundaries
+
+Scoping answers "while *may* this rule run". The trigger answers "when does it fire". For
+one-shot collection the two phase triggers are the bookends of a phase:
+
+| Trigger | Fires |
+|---|---|
+| `phase_change` | Once when `triggerPhase` is **entered** (dedup key `{ruleId}\|{phase}`). |
+| `phase_exit` | Once when `triggerPhase` is **left** (dedup key `exit:{ruleId}\|{phase}`). |
+
+Empty `triggerPhase` means every transition. Both keys live in the same
+`_phaseRulesExecuted` set; the `exit:` prefix keeps the two key spaces disjoint, so an
+enter-rule and an exit-rule on the same phase never cannibalise each other's slot.
+
+`phase_exit` semantics that are easy to get wrong:
+
+* It is evaluated **before** `_currentPhase` advances, so both the `triggerPhase` match and
+ the phase-scope gate see the phase being **left**. A rule scoped to `DeviceSetup` therefore
+ still fires on its own exit, even though the phase being entered is outside that scope.
+* A transition into `Failed` **does** fire the exit rules of the failing phase — capturing
+ state at the failure boundary is the point.
+* Exit of `Unknown` never fires (nothing was entered yet), and a repeated identical phase
+ signal is not a transition.
+* An exit rule on `Complete` only fires if something transitions *away* from Complete. For
+ "at the end of enrollment", use `phase_change` on `Complete` or `on_event` with
+ `enrollment_complete`.
+* Old agents do not know the trigger value and simply never fire the rule (silent no-op) —
+ which is why `phase_exit` needs no ConfigVersion bump: no new field is transported.
+
# Scope semantics (agent, `GatherRuleExecutor`)
* Scope applies to **all trigger types**:
diff --git a/rules/schema/gather-rule.schema.json b/rules/schema/gather-rule.schema.json
index 7908b6f3..ef03fb54 100644
--- a/rules/schema/gather-rule.schema.json
+++ b/rules/schema/gather-rule.schema.json
@@ -80,8 +80,8 @@
},
"trigger": {
"type": "string",
- "description": "When to collect data.",
- "enum": ["startup", "phase_change", "interval", "on_event"]
+ "description": "When to collect data. \"phase_change\" fires once when triggerPhase is entered, \"phase_exit\" once when it is left.",
+ "enum": ["startup", "phase_change", "phase_exit", "interval", "on_event"]
},
"intervalSeconds": {
"type": "integer",
@@ -90,7 +90,7 @@
},
"triggerPhase": {
"type": "string",
- "description": "Phase to trigger on (only used when trigger = \"phase_change\")."
+ "description": "Phase to trigger on (used when trigger = \"phase_change\" or \"phase_exit\"). Empty = every phase transition."
},
"triggerEventType": {
"type": "string",
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Gather/GatherRuleExecutorPhaseExitTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Gather/GatherRuleExecutorPhaseExitTests.cs
new file mode 100644
index 00000000..94c467e6
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Gather/GatherRuleExecutorPhaseExitTests.cs
@@ -0,0 +1,246 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+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
+{
+ ///
+ /// The phase_exit trigger: a one-shot collection when a phase is LEFT — the closing
+ /// bookend to phase_change (which fires on ENTER). The distinguishing invariant is that
+ /// both the TriggerPhase match and the phase-scope gate must evaluate against the phase being
+ /// left, i.e. before the executor's current-phase state moves forward.
+ /// Rules use the absent-registry-path pattern so no real system state is touched.
+ ///
+ [Collection("SerialThreading")] // rules execute on the shared ThreadPool
+ public sealed class GatherRuleExecutorPhaseExitTests : 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_9c4d2a7f-0000-0000-0000-000000000000";
+
+ public GatherRuleExecutorPhaseExitTests()
+ {
+ 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 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 ExitRule(
+ string id, string triggerPhase, List activePhases = null, string fromPhase = null)
+ => new GatherRule
+ {
+ RuleId = id,
+ Title = id,
+ CollectorType = "registry",
+ Target = AbsentPath,
+ Trigger = "phase_exit",
+ TriggerPhase = triggerPhase,
+ OutputEventType = "gather_test",
+ Enabled = true,
+ ActivePhases = activePhases,
+ ActiveFromPhase = fromPhase,
+ };
+
+ [Fact]
+ public void FiresOnceWhenTheNamedPhaseIsLeft_NotWhenItIsEntered()
+ {
+ _executor.UpdateRules(new List { ExitRule("GATHER-PX-001", "AccountSetup") });
+
+ _executor.OnPhaseChanged(EnrollmentPhase.DeviceSetup);
+ Thread.Sleep(300);
+ Assert.Equal(0, EventCount);
+
+ // Entering AccountSetup must NOT fire an exit rule.
+ _executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
+ Thread.Sleep(300);
+ Assert.Equal(0, EventCount);
+
+ // Leaving it does.
+ _executor.OnPhaseChanged(EnrollmentPhase.AppsUser);
+ Assert.True(WaitForEventCount(1));
+ }
+
+ [Fact]
+ public void DoesNotRefireWhenThePhaseIsLeftTwice()
+ {
+ _executor.UpdateRules(new List { ExitRule("GATHER-PX-002", "AccountSetup") });
+
+ _executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
+ _executor.OnPhaseChanged(EnrollmentPhase.AppsUser);
+ Assert.True(WaitForEventCount(1));
+
+ // A phase can be re-entered (ESP re-reporting); the one-shot slot stays consumed.
+ _executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
+ _executor.OnPhaseChanged(EnrollmentPhase.AppsUser);
+ Thread.Sleep(400);
+ Assert.Equal(1, EventCount);
+ }
+
+ [Fact]
+ public void EmptyTriggerPhase_FiresOnEveryExit()
+ {
+ _executor.UpdateRules(new List { ExitRule("GATHER-PX-003", "") });
+
+ _executor.OnPhaseChanged(EnrollmentPhase.DeviceSetup); // exit of Unknown — must NOT fire
+ Thread.Sleep(300);
+ Assert.Equal(0, EventCount);
+
+ _executor.OnPhaseChanged(EnrollmentPhase.AccountSetup); // exit of DeviceSetup
+ Assert.True(WaitForEventCount(1));
+
+ _executor.OnPhaseChanged(EnrollmentPhase.AppsUser); // exit of AccountSetup
+ Assert.True(WaitForEventCount(2));
+ }
+
+ [Fact]
+ public void NeverFiresOnExitOfUnknown()
+ {
+ // Before the first phase signal there is no phase to leave.
+ _executor.UpdateRules(new List { ExitRule("GATHER-PX-004", "") });
+
+ _executor.OnPhaseChanged(EnrollmentPhase.Start);
+ Thread.Sleep(300);
+ Assert.Equal(0, EventCount);
+ }
+
+ [Fact]
+ public void RepeatedSamePhaseSignal_IsNotAnExit()
+ {
+ _executor.UpdateRules(new List { ExitRule("GATHER-PX-005", "") });
+
+ _executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
+ _executor.OnPhaseChanged(EnrollmentPhase.AccountSetup); // same phase — no transition
+ Thread.Sleep(300);
+ Assert.Equal(0, EventCount);
+ }
+
+ [Fact]
+ public void FiresOnTransitionIntoFailed_CapturingStateAtTheFailurePoint()
+ {
+ _executor.UpdateRules(new List { ExitRule("GATHER-PX-006", "AccountSetup") });
+
+ _executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
+ _executor.OnPhaseChanged(EnrollmentPhase.Failed);
+
+ Assert.True(WaitForEventCount(1), "leaving a phase into Failed must still snapshot it");
+ }
+
+ [Fact]
+ public void FiresOnExitOfCompleteOnly_WhenCompleteIsActuallyLeft()
+ {
+ // "End of enrollment" is better served by phase_change on Complete / on_event; an exit
+ // rule on Complete only fires if a later transition happens at all.
+ _executor.UpdateRules(new List { ExitRule("GATHER-PX-007", "Complete") });
+
+ _executor.OnPhaseChanged(EnrollmentPhase.FinalizingSetup);
+ _executor.OnPhaseChanged(EnrollmentPhase.Complete);
+ Thread.Sleep(300);
+ Assert.Equal(0, EventCount);
+ }
+
+ [Fact]
+ public void ScopeGate_EvaluatesAgainstThePhaseBeingLeft()
+ {
+ // Scope = only during DeviceSetup. Leaving DeviceSetup must fire (the rule is still in
+ // scope at that instant) even though the NEW phase is outside the scope.
+ _executor.UpdateRules(new List
+ {
+ ExitRule("GATHER-PX-010", "DeviceSetup", activePhases: new List { "DeviceSetup" })
+ });
+
+ _executor.OnPhaseChanged(EnrollmentPhase.DeviceSetup);
+ _executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
+
+ Assert.True(WaitForEventCount(1),
+ "the scope gate must see the phase being left, not the one being entered");
+ }
+
+ [Fact]
+ public void OutOfScopeExit_DoesNotFire_AndDoesNotConsumeTheOneShotSlot()
+ {
+ // Scope = only during AccountSetup; the rule exits DeviceSetup while out of scope.
+ var rule = ExitRule("GATHER-PX-011", "", activePhases: new List { "AccountSetup" });
+ _executor.UpdateRules(new List { rule });
+
+ _executor.OnPhaseChanged(EnrollmentPhase.DeviceSetup);
+ _executor.OnPhaseChanged(EnrollmentPhase.AccountSetup); // exit of DeviceSetup: out of scope
+ Thread.Sleep(300);
+ Assert.Equal(0, EventCount);
+
+ _executor.OnPhaseChanged(EnrollmentPhase.AppsUser); // exit of AccountSetup: in scope
+ Assert.True(WaitForEventCount(1));
+ }
+
+ [Fact]
+ public void ExitAndChangeRulesForTheSamePhase_BothFireIndependently()
+ {
+ // The dedup key spaces must not collide: same ruleId shape, same phase, different trigger.
+ var enter = new GatherRule
+ {
+ RuleId = "GATHER-PX-020",
+ Title = "enter",
+ CollectorType = "registry",
+ Target = AbsentPath,
+ Trigger = "phase_change",
+ TriggerPhase = "AccountSetup",
+ OutputEventType = "gather_test",
+ Enabled = true,
+ };
+ var exit = ExitRule("GATHER-PX-021", "AccountSetup");
+ _executor.UpdateRules(new List { enter, exit });
+
+ _executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
+ Assert.True(WaitForEventCount(1)); // enter fired
+
+ _executor.OnPhaseChanged(EnrollmentPhase.AppsUser);
+ Assert.True(WaitForEventCount(2)); // exit fired
+ }
+
+ [Fact]
+ public void ExitRuleWithEmitOnChange_StillEmitsItsFirstResult()
+ {
+ var rule = ExitRule("GATHER-PX-030", "AccountSetup");
+ rule.EmitMode = "on_change";
+ _executor.UpdateRules(new List { rule });
+
+ _executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
+ _executor.OnPhaseChanged(EnrollmentPhase.AppsUser);
+
+ Assert.True(WaitForEventCount(1));
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleExecutor.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleExecutor.cs
index 29f157de..d3fadff4 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleExecutor.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleExecutor.cs
@@ -176,12 +176,59 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
///
/// Called when a phase change event occurs - executes rules triggered by phase changes
+ /// (phase_change on entering the new phase, phase_exit on leaving the old one).
///
public void OnPhaseChanged(EnrollmentPhase newPhase)
{
var phaseName = newPhase.ToString();
- // Update the phase + from-phase latches FIRST so deferred startup activation and
+ // phase_exit runs FIRST and against the phase being LEFT: both the TriggerPhase match
+ // and the scope gate must see the old phase, so this block has to complete before
+ // _currentPhase moves below. A transition INTO Failed still fires the exit rules of the
+ // phase that failed — that snapshot is the whole point at a failure boundary.
+ List exitRules = null;
+ lock (_scopeLock)
+ {
+ var previousPhase = _currentPhase;
+ if (previousPhase != EnrollmentPhase.Unknown && previousPhase != newPhase)
+ {
+ var previousPhaseName = previousPhase.ToString();
+ exitRules = new List();
+
+ foreach (var rule in _activeRules)
+ {
+ if (rule.Trigger != "phase_exit") continue;
+ if (!string.IsNullOrEmpty(rule.TriggerPhase)
+ && !string.Equals(rule.TriggerPhase, previousPhaseName, StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ // Scope gate before the dedup add — an out-of-scope pass must not consume
+ // the once-per-(rule, phase) slot. IsRuleInScopeLocked still sees the old phase.
+ if (!IsRuleInScopeLocked(rule)) continue;
+
+ // "exit:" prefix keeps this key space disjoint from the phase_change keys.
+ if (!_phaseRulesExecuted.Add($"exit:{rule.RuleId}|{previousPhaseName}"))
+ {
+ _logger.Debug($"Phase-exit rule {rule.RuleId} already executed for exit of {previousPhaseName}, skipping");
+ continue;
+ }
+
+ _logger.Info($"Phase exit triggered rule {rule.RuleId} (left phase: {previousPhaseName}, now: {phaseName})");
+ exitRules.Add(rule);
+ }
+ }
+ }
+
+ if (exitRules != null)
+ {
+ foreach (var rule in exitRules)
+ {
+ var exitRule = rule;
+ ThreadPool.QueueUserWorkItem(_ => ExecuteRule(exitRule));
+ }
+ }
+
+ // Update the phase + from-phase latches so deferred startup activation and
// the phase_change rules below all evaluate against the NEW phase.
List deferredStartup;
lock (_scopeLock)
diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Rules/GatherRule.cs b/src/Shared/AutopilotMonitor.Shared/Models/Rules/GatherRule.cs
index 2b861c41..d3f7f3e0 100644
--- a/src/Shared/AutopilotMonitor.Shared/Models/Rules/GatherRule.cs
+++ b/src/Shared/AutopilotMonitor.Shared/Models/Rules/GatherRule.cs
@@ -107,7 +107,11 @@ namespace AutopilotMonitor.Shared.Models
// ===== WHEN TO COLLECT =====
///
- /// Trigger type: "startup", "phase_change", "interval", "on_event"
+ /// Trigger type: "startup", "phase_change", "phase_exit", "interval", "on_event".
+ ///
+ /// "phase_change" fires once when the phase in is ENTERED,
+ /// "phase_exit" once when it is LEFT — the two one-shot bookends of a phase.
+ ///
///
public string Trigger { get; set; } = default!;
@@ -117,8 +121,9 @@ namespace AutopilotMonitor.Shared.Models
public int? IntervalSeconds { get; set; }
///
- /// Phase to trigger on (only used when Trigger = "phase_change")
- /// e.g., "Identity", "MdmEnrollment", "AppInstallation"
+ /// Phase to trigger on (used when Trigger = "phase_change" or "phase_exit").
+ /// Canonical tokens are the enum names, e.g.
+ /// "DeviceSetup", "AccountSetup", "Complete". Empty = every phase transition.
///
public string TriggerPhase { get; set; } = default!;
diff --git a/src/Web/autopilot-monitor-web/app/gather-rules/components/GatherRuleCard.tsx b/src/Web/autopilot-monitor-web/app/gather-rules/components/GatherRuleCard.tsx
index f656c016..09a009da 100644
--- a/src/Web/autopilot-monitor-web/app/gather-rules/components/GatherRuleCard.tsx
+++ b/src/Web/autopilot-monitor-web/app/gather-rules/components/GatherRuleCard.tsx
@@ -293,7 +293,16 @@ export function GatherRuleCard({
)}
{rule.trigger === "phase_change" && rule.triggerPhase && (
- on phase: {rule.triggerPhase}
+ entering {formatGatherPhase(rule.triggerPhase)}
+
+ )}
+ {rule.trigger === "phase_exit" && (
+
+ {rule.triggerPhase ? (
+ <>leaving {formatGatherPhase(rule.triggerPhase)}>
+ ) : (
+ "on every phase exit"
+ )}
)}
{rule.trigger === "on_event" && rule.triggerEventType && (
diff --git a/src/Web/autopilot-monitor-web/app/gather-rules/components/GatherRuleFormFields.tsx b/src/Web/autopilot-monitor-web/app/gather-rules/components/GatherRuleFormFields.tsx
index 962d7f49..078685a8 100644
--- a/src/Web/autopilot-monitor-web/app/gather-rules/components/GatherRuleFormFields.tsx
+++ b/src/Web/autopilot-monitor-web/app/gather-rules/components/GatherRuleFormFields.tsx
@@ -11,6 +11,7 @@ import {
TARGET_PLACEHOLDERS,
TARGET_HINTS,
GATHER_PHASES,
+ PHASE_TRIGGERS,
EMIT_MODES,
formatTrigger,
} from "../types";
@@ -425,7 +426,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
)}
- {form.trigger === "phase_change" && (
+ {PHASE_TRIGGERS.includes(form.trigger) && (
-
Run this rule when the enrollment reaches this phase. "Any phase change" fires once per phase transition.
+
+ {form.trigger === "phase_exit"
+ ? "Collect once when the enrollment LEAVES this phase — a snapshot of its end state. \"Any phase\" fires on every phase transition. Also fires when the phase is left because enrollment failed."
+ : "Collect once when the enrollment REACHES this phase. \"Any phase\" fires on every phase transition."}
+