Compare commits

...

5 Commits

Author SHA1 Message Date
Oliver Kieselbach dbf6e50e2d feat(agent,web,shared): phase_exit gather trigger — one-shot collection at a phase's end
Follow-up to gather-rule phase scoping. One-shot collection at a phase START
already existed (phase_change dedups per (rule, phase)) and at enrollment END
(on_event + enrollment_complete); the END of a specific phase had no trigger.

New trigger value "phase_exit": fires once when TriggerPhase is LEFT (empty =
every phase transition). Together with phase_change it forms the two one-shot
bookends of a phase.

Agent (GatherRuleExecutor.OnPhaseChanged):
- Exit rules are 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 fires on its own exit even though the phase
  being entered is out of scope.
- A transition into Failed fires the exit rules of the failing phase — the
  snapshot at a failure boundary is the point of the trigger.
- Exit of Unknown never fires; a repeated identical phase signal is not a
  transition.
- Dedup key "exit:{ruleId}|{phase}" shares _phaseRulesExecuted with the
  phase_change keys but stays in a disjoint key space, so an enter-rule and an
  exit-rule on the same phase cannot consume each other's slot.

Portal: trigger labels are now "Phase Start" / "Phase End"; the canonical phase
dropdown and the create/save payloads cover both via PHASE_TRIGGERS; the card
renders "entering X" / "leaving X".

No ConfigVersion bump: no new field is transported. Old agents do not know the
value and simply never fire the rule (silent no-op).

Tests: GatherRuleExecutorPhaseExitTests (11) — enter-vs-exit, no refire on
re-entry, empty-triggerPhase, exit-of-Unknown, same-phase-signal, into-Failed,
Complete, scope-against-left-phase, out-of-scope slot preservation, key-space
disjointness, on_change interplay. Build 0 Errors; V2.Core.Tests 1884/1884;
Functions.Tests 3099/3099; vitest 557/557; tsc silent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 15:37:48 +02:00
Oliver Kieselbach 062962ae41 docs(claude): dedupe and condense agent directives
CLAUDE.md ships with every request. Removed the example from the
operator-only-infrastructure rule - rules state the rule, not an
instance - and applied the same yardstick to the rest of the file.

Merged what said the same thing twice: "would a staff engineer approve"
and "what would a perfectionist reviewer reject"; Demand Elegance and
Code Quality and Core Principles; Task Management's lessons item and the
Self-Improvement Loop; Task Management's plan steps and Plan Mode
Default. Dropped motivational framing that steers no decision, and a
stray heading level.

Every actual rule survives: the 3+ step plan threshold, stop-and-re-plan,
subagent usage, verify-before-done with a diff against main, autonomous
bug fixing, lessons capture, 500 LOC chunked reads, no absence claims
from one grep, the structural-fix mandate, and the Codex review note.

425 words, down from 614 at the start of the session - and that now
includes the customer-facing claims section, which did not exist then.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 14:15:14 +02:00
Oliver Kieselbach be08660976 docs(claude): condense customer-facing claims and OKF sections
CLAUDE.md ships with every request, so length is a running cost. Cut the
new claims section from 12 lines to 4 and tightened the OKF section,
dropping the spec URL and restating the rules imperatively. Net effect:
the file is now smaller than before the claims section was added
(654 words vs 856).

No rules were removed - verify-before-writing, the change triggers, the
durable-phrasing rule, and the operator-only-infra caveat all remain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 14:09:48 +02:00
Oliver Kieselbach b032005f5b docs(claude): add customer-facing claims to the release checklist
Terms, Privacy, the About page and the trust docs make statements that
customers and security reviewers rely on, but nothing opened them during
a refactor, so they drifted. The July 2026 sweep found a ".NET 8" backend
that was .NET 10, a "Next.js 18" that does not exist, ETL log collection
that never happened, and "each tenant runs its own isolated instance" for
a shared multi-tenant service.

Records which surfaces exist, which kinds of change must update them in
the same commit, and that claims are verified against the code rather
than carried forward.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 14:01:23 +02:00
Oliver Kieselbach 0ab568f479 fix(web): correct stale About claims; open Community plan to any tenant
Terms: replace the blanket business-use-only clause. It excluded exactly
the wrong group - a registered association is not a consumer under German
law anyway (only natural persons can be), while the clause did shut out
private individuals running their own lab or personal tenant, who are a
real and intended audience for a free community edition. Community is now
stated as open to anyone with an eligible Entra ID/Intune tenant;
business-only stays where it means something, on the Enterprise contract.

About page - claims verified against the code and corrected:
- "each customer tenant runs its own isolated instance" was wrong and
  contradicted the same page two sections earlier. It is one shared
  multi-tenant service with partition-key isolation and delegated MSP
  access.
- Diagnostics: no ETL tracing and no Windows event log export happens;
  the bundle is agent logs, IME logs, agent state and device info. Upload
  is tenant-configured (off/always/on failure) at the end of enrollment,
  not triggered on demand from the portal - the portal downloads it.
- .NET 8 Isolated -> .NET 10 Isolated on Flex Consumption.
- "Next.js 18" -> Next.js 15 (React 18); 18 was React's version.
- Roles: Admin/Operator -> Admin/Operator/Viewer plus delegated MSP.
- Notifications: Teams only -> Teams, Slack, generic JSON webhooks, with
  start/success/failure and SLA triggers. Telegram stays unlisted; it is
  operator-only alerting, not a tenant feature.
- Agent deployment: Win32 app -> Intune platform script; self-destruct is
  on by default, not optional-sounding.
- Dropped the "tamper-evident" audit claim - records are ordinary table
  entities with no hash chain, signing, or immutability policy.

Confirmed while verifying: no tenant-configurable email channel exists
anywhere in the codebase, so the published privacy statement that Resend
serves only the onboarding mail is accurate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 13:58:07 +02:00
14 changed files with 408 additions and 101 deletions
+25 -67
View File
@@ -1,79 +1,37 @@
## Workflow Orchestration
# Agent Directives: Mechanical Overrides
# Agent Directives
You are operating within a constrained context window and strict system prompts. To produce production-grade code, you MUST adhere to these overrides:
- **Plan first.** Enter plan mode for any non-trivial task (3+ steps or an architectural decision) and write the spec before building. If something goes sideways, stop and re-plan instead of pushing on.
- **Use subagents liberally** for research, exploration, and parallel analysis — one focus each — to keep the main context clean.
- **Verify before claiming done.** Run the tests, check the logs, show the evidence. Diff against `main` where behaviour could regress.
- **Fix bugs autonomously.** Given a report, a failing test, or an error, resolve it — no hand-holding.
- **Capture corrections.** After any correction from the user, record the pattern and a preventing rule in `tasks/lessons.md`; review it at session start.
- Read files over 500 LOC in chunks. Never treat one grep as proof of absence.
Codex reviews your output once you are done.
## Code Quality
### 1. Plan Mode Default
- Enter plan mode for ANY non-trivial task (3+ steps or architectural decisions)
- If something goes sideways, STOP and re-plan immediately — don't keep pushing
- Use plan mode for specification steps, not just building
- Write detailed specs upfront to reduce ambiguity
### 2. Subagent Strategy
- Use subagents liberally to keep main context window clean
- Offload research, exploration, and parallel analysis to subagents
- For complex problems, throw more compute at it via subagents
- One tack per subagent for focused execution
### 3. Self-Improvement Loop
- After ANY correction from the user: update `tasks/lessons.md` with the pattern
- Write rules for yourself that prevent the same mistake
- Ruthlessly iterate on these lessons until mistake rate drops
- Review lessons at session start for relevant project
### 4. Verification Before Done
- Never mark a task complete without proving it works
- Diff behavior between main and your changes when relevant
- Ask yourself: "Would a staff engineer approve this?"
- Run tests, check logs, demonstrate correctness
### 5. Demand Elegance (Balanced)
- For non-trivial changes: pause and ask "is there a more elegant way?"
- If a fix feels hacky: "Knowing everything I know now, implement the elegant solution"
- Skip this for simple, obvious fixes — don't over-engineer
- Challenge your own work before presenting it
### 6. Autonomous Bug Fixing
- When given a bug report: just fix it. Don't ask for hand-holding
- Point at logs, errors, failing tests — then resolve them
- Zero context switching required from the user
- Go fix failing CI tests without being told how
### Tools Execution
- Read any file over 500 LOC in chunks using offset and limit parameters.
- Do not assume a single grep caught everything.
### Code Quality
- If architecture is flawed, state is duplicated, or patterns are inconsistent - propose and implement structural fixes. Ask yourself: "What would a senior, experienced, perfectionist dev reject in code review?" Fix all of it.
Codex will review your output once you are done!
- Simplicity first: minimal, targeted changes. No temporary fixes — find the root cause.
- Where architecture is flawed, state is duplicated, or patterns are inconsistent, propose and implement the structural fix. Ask what a perfectionist reviewer would reject, and fix that too.
- On non-trivial changes, ask whether there is a more elegant way before presenting. Skip it for simple fixes — don't over-engineer.
## Task Management
1. **Plan First**: Write plan to `tasks/todo.md` with checkable items
2. **Verify Plan**: Check in before starting implementation
3. **Track Progress**: Mark items complete as you go
4. **Explain Changes**: High-level summary at each step
5. **Document Results**: Add review section to `tasks/todo.md`
6. **Capture Lessons**: Update `tasks/lessons.md` after corrections
Keep the plan in `tasks/todo.md` as checkable items, confirm it before implementing, tick items off as you go, and add a review section when done.
## Core Principles
## Customer-Facing Claims
- **Simplicity First**: Make every change as simple as possible. Impact minimal code.
- **No Laziness**: Find root causes. No temporary fixes. Senior developer standards.
- **Minimal Impact**: Changes should only touch what's necessary. Avoid introducing bugs.
`src/Web/autopilot-monitor-web/app/{about,terms,privacy}/page.tsx` and `autopilotmonitor-docs/trust/*` state facts customers and security reviewers rely on. They drift silently.
## Technical Docs — OKF Knowledge Bundle (`docs/`)
- Verify every claim against the code before writing it — never carry one forward because it was already on the page.
- Update them in the same change as: runtime/framework versions, roles, notification providers, agent deployment or lifecycle, diagnostics payload, isolation/delegation model, retention caps, sub-processors, or any default governing what is collected.
- Describe only what customers can actually use; operator-only infrastructure is not a feature.
- Trust pages: durable phrasing over exact figures, and bump their "Last reviewed" date.
`docs/` is an [Open Knowledge Format (OKF) v0.1](https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md) knowledge bundle for contributor/AI-facing technical documentation (customer docs live in the separate autopilotmonitor-docs repo).
## Technical Docs — OKF Bundle (`docs/`)
- **Consume first**: start at `docs/index.md` for progressive disclosure before opening individual documents.
- **When new durable technical knowledge emerges** (architecture decisions, flows, non-obvious mechanisms), capture it as an OKF concept document in `docs/` — markdown with YAML frontmatter: `type` (mandatory), plus `title`, `description`, `resource`, `tags`, `timestamp` (recommended).
- **Maintain the bundle**: add every new document to `docs/index.md`, note changes in `docs/log.md` (grouped by ISO date). Use standard RELATIVE markdown links between docs — never the OKF `/`-prefixed bundle-absolute form (GitHub resolves those from the repo root and navigation breaks). `index.md` and `log.md` are reserved filenames.
- Documents are English, structural markdown over prose (`# Schema`, `# Examples`, `# Citations` sections where applicable).
Contributor/AI-facing docs (customer docs live in the `autopilotmonitor-docs` repo). Start at `docs/index.md` before opening individual files.
- New durable knowledge (architecture decisions, flows, non-obvious mechanisms) → an OKF concept doc with YAML frontmatter: `type` (mandatory), plus `title`, `description`, `resource`, `tags`, `timestamp`.
- Register it in `docs/index.md`, note the change in `docs/log.md` (ISO date). `index.md` and `log.md` are reserved names.
- English, structural markdown (`# Schema`, `# Examples`, `# Citations`). Links between docs are RELATIVE — never `/`-prefixed, that breaks GitHub navigation.
+1 -1
View File
@@ -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
+1
View File
@@ -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.
+31 -2
View File
@@ -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**:
+3 -3
View File
@@ -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",
@@ -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
{
/// <summary>
/// The <c>phase_exit</c> trigger: a one-shot collection when a phase is LEFT — the closing
/// bookend to <c>phase_change</c> (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.
/// </summary>
[Collection("SerialThreading")] // rules execute on the shared ThreadPool
public sealed class GatherRuleExecutorPhaseExitTests : IDisposable
{
private readonly TempDirectory _tmp = new TempDirectory();
private readonly List<EnrollmentEvent> _events = new List<EnrollmentEvent>();
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<string> 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<GatherRule> { 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<GatherRule> { 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<GatherRule> { 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<GatherRule> { ExitRule("GATHER-PX-004", "") });
_executor.OnPhaseChanged(EnrollmentPhase.Start);
Thread.Sleep(300);
Assert.Equal(0, EventCount);
}
[Fact]
public void RepeatedSamePhaseSignal_IsNotAnExit()
{
_executor.UpdateRules(new List<GatherRule> { 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<GatherRule> { 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<GatherRule> { 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<GatherRule>
{
ExitRule("GATHER-PX-010", "DeviceSetup", activePhases: new List<string> { "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<string> { "AccountSetup" });
_executor.UpdateRules(new List<GatherRule> { 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<GatherRule> { 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<GatherRule> { rule });
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
_executor.OnPhaseChanged(EnrollmentPhase.AppsUser);
Assert.True(WaitForEventCount(1));
}
}
}
@@ -176,12 +176,59 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
/// <summary>
/// Called when a phase change event occurs - executes rules triggered by phase changes
/// (<c>phase_change</c> on entering the new phase, <c>phase_exit</c> on leaving the old one).
/// </summary>
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<GatherRule> exitRules = null;
lock (_scopeLock)
{
var previousPhase = _currentPhase;
if (previousPhase != EnrollmentPhase.Unknown && previousPhase != newPhase)
{
var previousPhaseName = previousPhase.ToString();
exitRules = new List<GatherRule>();
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<GatherRule> deferredStartup;
lock (_scopeLock)
@@ -107,7 +107,11 @@ namespace AutopilotMonitor.Shared.Models
// ===== WHEN TO COLLECT =====
/// <summary>
/// Trigger type: "startup", "phase_change", "interval", "on_event"
/// Trigger type: "startup", "phase_change", "phase_exit", "interval", "on_event".
/// <para>
/// "phase_change" fires once when the phase in <see cref="TriggerPhase"/> is ENTERED,
/// "phase_exit" once when it is LEFT — the two one-shot bookends of a phase.
/// </para>
/// </summary>
public string Trigger { get; set; } = default!;
@@ -117,8 +121,9 @@ namespace AutopilotMonitor.Shared.Models
public int? IntervalSeconds { get; set; }
/// <summary>
/// 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 <see cref="EnrollmentPhase"/> enum names, e.g.
/// "DeviceSetup", "AccountSetup", "Complete". Empty = every phase transition.
/// </summary>
public string TriggerPhase { get; set; } = default!;
@@ -26,9 +26,9 @@ const FEATURES = [
{
title: "Diagnostics Collection",
description:
"Trigger on-demand diagnostic uploads directly from the portal. Collect ETL logs, event logs, IME logs, and system information from any enrolled device without local access.",
"Collect agent logs, IME logs, agent state, and device information as a ZIP bundle at the end of an enrollment — then download it from the session view without touching the device.",
color: "green",
bullets: ["On-demand ZIP bundle upload", "ETL, event & IME log collection", "Configurable log path rules"],
bullets: ["Configurable upload: off, always, or on failure", "Agent, IME & device information bundle", "Configurable additional log paths"],
},
{
title: "Detailed Event Timeline",
@@ -40,7 +40,7 @@ const FEATURES = [
{
title: "Audit Logging & Compliance",
description:
"Complete audit trail of all administrative actions and configuration changes. Meet compliance requirements with detailed, tamper-evident records and configurable data retention.",
"Complete audit trail of all administrative actions and configuration changes. Meet compliance requirements with detailed, tenant-scoped records and configurable data retention.",
color: "red",
bullets: ["Admin action history", "Configurable retention policies", "Tenant-scoped audit log"],
},
@@ -138,7 +138,7 @@ export default function AboutPage() {
{
title: "MSPs & Enterprise Teams",
description:
"Autopilot Monitor supports multi-tenant deployments — each customer tenant runs its own isolated instance. MSPs with login access to a customer environment can use the portal for that tenant independently.",
"Autopilot Monitor is a multi-tenant service with strict per-tenant data isolation — telemetry, configuration, and diagnostics are partitioned and access-scoped to each tenant. MSPs can be granted delegated read access across several customer tenants from a single login.",
},
].map((item) => (
<div key={item.title} className="rounded-lg bg-blue-50 border border-blue-100 p-4">
@@ -165,7 +165,7 @@ export default function AboutPage() {
{ step: "2", text: "The bootstrapper installs the Autopilot Monitor Agent on each enrolling device." },
{ step: "3", text: "The agent captures live enrollment events and uploads them to the backend pipeline." },
{ step: "4", text: "The portal displays real-time session data, analyze rule results, and fleet health metrics." },
{ step: "5", text: "On completion, the agent optionally self-destructs and uploads a diagnostics bundle." },
{ step: "5", text: "On completion, the agent uploads a diagnostics bundle if configured, then removes itself." },
].map((item) => (
<li key={item.step} className="flex items-start gap-3 text-sm text-gray-700">
<span className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-blue-600 text-white text-xs font-bold">
@@ -189,7 +189,7 @@ export default function AboutPage() {
{
title: "Backend",
items: [
"Azure Functions (.NET 8 Isolated) — serverless, scalable API",
"Azure Functions (.NET 10 Isolated, Flex Consumption) — serverless, scalable API",
"Azure Table Storage — high-throughput event ingestion",
"Azure Blob Storage — diagnostics and log bundle storage",
"Azure SignalR Service — real-time push to the portal",
@@ -198,10 +198,10 @@ export default function AboutPage() {
{
title: "Portal (Web Frontend)",
items: [
"Next.js 18 + TypeScript — fast, server-rendered React app",
"Next.js 15 (React 18) + TypeScript — fast, server-rendered React app",
"Microsoft Entra ID (MSAL) — secure authentication",
"Role-based access control (Admin / Operator)",
"Multi-tenant architecture",
"Role-based access control (Admin / Operator / Viewer)",
"Multi-tenant architecture with delegated MSP access",
],
},
{
@@ -209,16 +209,16 @@ export default function AboutPage() {
items: [
".NET binary — lightweight, low-overhead monitoring",
"Runs via scheduled task (no Windows service — easy, residue-free removal)",
"Deployed via Intune bootstrapper script (Win32 app)",
"Client certificate authentication",
"Optional self-destruct on enrollment completion",
"Deployed via an Intune platform script (PowerShell bootstrapper)",
"Mutual TLS using the existing Intune MDM device certificate",
"Self-destruct on enrollment completion (on by default — removes task and files)",
],
},
{
title: "Integrations",
items: [
"Microsoft Intune — agent deployment target",
"Microsoft Teams — enrollment failure notifications",
"Microsoft Teams, Slack & generic JSON webhooks — start, success, failure and SLA alerts",
"Intune Management Extension (IME) log — event source for log pattern detection",
"WMI & Registry — extended data gather rules",
],
@@ -293,7 +293,16 @@ export function GatherRuleCard({
)}
{rule.trigger === "phase_change" && rule.triggerPhase && (
<span className="text-gray-500">
on phase: <code className="px-1 bg-gray-200 rounded text-xs">{rule.triggerPhase}</code>
entering <code className="px-1 bg-gray-200 rounded text-xs">{formatGatherPhase(rule.triggerPhase)}</code>
</span>
)}
{rule.trigger === "phase_exit" && (
<span className="text-gray-500">
{rule.triggerPhase ? (
<>leaving <code className="px-1 bg-gray-200 rounded text-xs">{formatGatherPhase(rule.triggerPhase)}</code></>
) : (
"on every phase exit"
)}
</span>
)}
{rule.trigger === "on_event" && rule.triggerEventType && (
@@ -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
</div>
)}
{form.trigger === "phase_change" && (
{PHASE_TRIGGERS.includes(form.trigger) && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Trigger Phase</label>
<select
@@ -433,7 +434,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
onChange={(e) => setForm({ ...form, triggerPhase: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
>
<option value="">Any phase change</option>
<option value="">Any phase</option>
{form.triggerPhase && !GATHER_PHASES.some((p) => p.value === form.triggerPhase) && (
<option value={form.triggerPhase}>{form.triggerPhase} (legacy value)</option>
)}
@@ -441,7 +442,11 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
<option key={p.value} value={p.value}>{p.label}</option>
))}
</select>
<p className="text-xs text-gray-400 mt-1">Run this rule when the enrollment reaches this phase. &quot;Any phase change&quot; fires once per phase transition.</p>
<p className="text-xs text-gray-400 mt-1">
{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."}
</p>
</div>
)}
@@ -16,7 +16,7 @@ import { FormJsonToggle, JsonModeToggleButtons } from "@/components/rules/FormJs
import { useAuthenticatedFetch, useNotificationMessages, useGlobalAdminScope } from "@/hooks";
import { GlobalAdminBanner, globalAdminSubtitle } from "@/components/GlobalAdminBanner";
import { TenantScopeSelector } from "@/components/TenantScopeSelector";
import { GatherRule, NewRuleForm, EMPTY_FORM, CATEGORY_COLORS, withDerivedScopeMode } from "./types";
import { GatherRule, NewRuleForm, EMPTY_FORM, CATEGORY_COLORS, PHASE_TRIGGERS, withDerivedScopeMode } from "./types";
import { GatherRuleFormFields } from "./components/GatherRuleFormFields";
import { GatherRuleCard } from "./components/GatherRuleCard";
@@ -209,7 +209,7 @@ export default function GatherRulesPage() {
parameters: buildParameters(form),
trigger: form.trigger,
intervalSeconds: form.trigger === "interval" ? form.intervalSeconds : null,
triggerPhase: form.trigger === "phase_change" ? form.triggerPhase : null,
triggerPhase: PHASE_TRIGGERS.includes(form.trigger) ? form.triggerPhase : null,
triggerEventType: form.trigger === "on_event" ? form.triggerEventType : null,
activePhases: form.scopeMode === "during" && form.activePhases.length > 0 ? form.activePhases : null,
activeFromPhase: form.scopeMode === "from" && form.activeFromPhase ? form.activeFromPhase : null,
@@ -296,7 +296,7 @@ export default function GatherRulesPage() {
parameters: buildParameters(form),
trigger: form.trigger,
intervalSeconds: form.trigger === "interval" ? form.intervalSeconds : null,
triggerPhase: form.trigger === "phase_change" ? form.triggerPhase : null,
triggerPhase: PHASE_TRIGGERS.includes(form.trigger) ? form.triggerPhase : null,
triggerEventType: form.trigger === "on_event" ? form.triggerEventType : null,
activePhases: form.scopeMode === "during" && form.activePhases.length > 0 ? form.activePhases : null,
activeFromPhase: form.scopeMode === "from" && form.activeFromPhase ? form.activeFromPhase : null,
@@ -61,7 +61,10 @@ export interface NewRuleForm {
export const CATEGORIES = ["network", "identity", "apps", "device", "esp", "enrollment"] as const;
export const COLLECTOR_TYPES = ["registry", "eventlog", "wmi", "file", "command_allowlisted", "logparser", "json", "xml"] as const;
export const TRIGGERS = ["startup", "phase_change", "interval", "on_event"] as const;
export const TRIGGERS = ["startup", "phase_change", "phase_exit", "interval", "on_event"] as const;
/** Triggers that collect at a phase boundary and therefore use the triggerPhase field. */
export const PHASE_TRIGGERS: ReadonlyArray<string> = ["phase_change", "phase_exit"];
export const SEVERITIES = ["info", "warning", "error", "critical"] as const;
// Canonical phase-scope tokens: the backend EnrollmentPhase enum NAMES from Start(0) through
@@ -168,7 +171,8 @@ export const EMPTY_FORM: NewRuleForm = {
export function formatTrigger(trigger: string) {
switch (trigger) {
case "phase_change": return "Phase Change";
case "phase_change": return "Phase Start";
case "phase_exit": return "Phase End";
case "on_event": return "On Event";
default: return trigger.charAt(0).toUpperCase() + trigger.slice(1);
}
@@ -48,8 +48,11 @@ export default function TermsPage() {
<div className="bg-blue-50 border-l-4 border-blue-400 p-4 rounded">
<p className="text-blue-900 text-sm">
<strong>Business use only.</strong> Autopilot Monitor is offered exclusively to businesses and
organizations acting in a commercial or professional capacity. It is not offered to consumers.
<strong>Who can use it.</strong> Autopilot Monitor requires a Microsoft Entra ID tenant with Intune and
registered Autopilot devices. The <strong>Community</strong> plan is open to anyone with such a tenant —
organizations, non-profits, and individuals running their own lab or personal tenant alike. The{" "}
<strong>Enterprise</strong> plan is a commercial agreement and is offered to businesses and organizations
acting in a commercial or professional capacity.
</p>
</div>
</div>