feat(classification,calibration): rule 5a completed-assumed, max-lifetime grace skip, drift-radar floors

First real calibration read (2026-08-27, four days instrumented) drove three
data-backed changes:

- Rule 5a: past grace (or with the agent provably gone), Device Setup done +
  real-user desktop + zero app_install_failed reconciles to Succeeded
  "completed (assumed)" (r5_assumed). r5-Incomplete devices re-enrolled at
  2.6% - the succeeded background, not the ~29% failure level - and the
  sampled desktop half had clean app records. No desktop or any failed app
  stays Incomplete; rule 4 remains the immediate desktop+Hello reconcile.
- Rule 5 skips the AwaitingUser grace once either max-lifetime watchdog shape
  (enrollment_failed/agent_timeout, agent_shutting_down/max_lifetime) is in
  the stream: all five observed maxlife:r5_awaiting parks expired unhealed
  while live-agent parks upgraded 7/26. The maxlife ingest appends its
  trigger event to the read stream so the fact is never missed; late
  telemetry still heals a terminal verdict. WhiteGlove rule 1b keeps its
  park (its one episode healed).
- Drift radar: MinWindowHits raised to 10 (a 3-hit episode survived as
  noise), per-path share regression now needs >=5 baseline hits (new or
  renamed vocabulary cannot "regress"), and rule-shaped legacy:* paths count
  into the silence/classifier-verdict group sums on both sides of the
  2026-08-23 instrumentation cutover - dissolving the lift-124 silence
  artifact. Episodes below the fire floors clear themselves.

Backend only; the new path appears in the calibration matrix dynamically.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-08-27 18:10:19 +02:00
parent 42c45cf567
commit 040e411958
10 changed files with 321 additions and 32 deletions
+39 -9
View File
@@ -10,7 +10,7 @@ tags:
- classification
- whiteglove
- self-deploying
timestamp: 2026-08-23T18:00:00+02:00
timestamp: 2026-08-27T20:00:00+02:00
---
# Silent-Session Classification
@@ -30,7 +30,10 @@ so a session gets the same verdict regardless of which path noticed the silence
subcategory counts (incl. the 30s fallback-confirmed messages), `HasExplicitFailure`,
`HasEnrollmentComplete` vs. `HasTerminalComplete` (the Part-1 `whiteglove_complete`),
`HasAgentEmergencyBreak`, `DesktopArrived`, `HelloResolved`, `HelloPolicyDisabled`,
`SkipUserEsp`, RealmJoin detection/resolution. Session-row flags are passed alongside:
`SkipUserEsp`, RealmJoin detection/resolution, `HasAppInstallFailure` (any
`app_install_failed`), and `HasAgentMaxLifetimeTimeout` (either max-lifetime shape:
`enrollment_failed`/`agent_timeout` or `agent_shutting_down`/`max_lifetime` — the agent is
provably gone). Session-row flags are passed alongside:
`IsPreProvisioned` + `ResumedAt` (WhiteGlove Part 2) and `IsSelfDeployingProfile`
(registry-confirmed `CloudAssignedOobeConfig 0x20|0x40`, set at registration).
@@ -44,7 +47,9 @@ subcategory counts (incl. the 30s fallback-confirmed messages), `HasExplicitFail
| 2 | Account Setup all-succeeded, or `enrollment_complete`, or `whiteglove_complete` on a **non-resumed** session | Succeeded |
| 3 | `HasAgentEmergencyBreak` | Incomplete immediately (agent self-destructed, nothing can arrive) |
| 4 | `DesktopArrived` + (Hello terminal, or Hello disabled + SkipUserEsp) | Succeeded (user provably finished) |
| 5 | Device Setup done | AwaitingUser within grace, Incomplete past grace |
| 5 | Device Setup done + within grace + **no** max-lifetime watchdog observed | AwaitingUser |
| 5a | Device Setup done + past grace (or watchdog observed) + `DesktopArrived` + no `app_install_failed` | **Succeeded** — completed (assumed), `r5_assumed` |
| 5b | Device Setup done, otherwise past grace (or watchdog observed) | Incomplete |
| 6 | otherwise | Incomplete |
Grace = `ResolveGraceHours(SessionGraceHours, AbsoluteMaxSessionHours)` (~51h default),
@@ -65,7 +70,12 @@ budget on a Wi-Fi link still associating), and rule 5 labeled finished kiosks
Incomplete turned a working rollout into a red wall. Other self-deploying tenants completed
normally in the same window, so this is a classification gap, not a profile-wide agent bug.
Agent-side companion fix: `SessionRegistrationHelper` now waits up to 15s for a network link
and retries 6× (~62s backoff) instead of 5× (~30s).
and retries 6× (~62s backoff) instead of 5× (~30s). Measured 2026-08-27 (builds
2.0.1428/1429, both carrying the fix): the reconcile share did not move, and the backend saw
no registration attempt from any relaunch — no success, no 4xx, zero `register:superseded`
in the tenant — so the relaunch never reaches the network at all (device powered off after
ESP, or the relaunch never runs). The sweep reconcile is the permanent completion path for
this profile, not a stopgap.
## Callers (all pass both gate inputs)
@@ -78,13 +88,33 @@ and retries 6× (~62s backoff) instead of 5× (~30s).
| Agent stall probe (`session_stalled`) | `EventIngestProcessor.TryMapStallToWhiteGloveAwaitingUserAsync` | Only the WG gate (1b); a stall probe on a self-deploying device with Device Setup done means the agent is alive and something else blocks — stays Stalled. |
| Admin retro-reconcile | `LegacyReclassificationService` | Modes `legacy_timeouts`, `pending_orphans`, `self_deploying_silent` (Incomplete/AwaitingUser/Stalled rows with the profile flag, heal-only to Succeeded, admin-marked rows skipped). `POST /api/maintenance/reclassify-legacy?mode=…&dryRun=false&tenantId=…&maxSessions=…`, dry-run by default. |
## Known gap (decided, not built)
## Why rule 5a exists (calibration read 2026-08-27)
The verdict-calibration matrix showed r5-Incomplete devices re-enrolling at 2.6 % within
7 days — the succeeded background (`agent:complete_soft` sits at 2.8 %), not the ~29 %
failure level — consistently across tenants, and half the sampled sessions were healthy
desktops with a clean app record whose agent died before the Hello terminal (some with the
agent's own "AccountSetup treated as complete" synthesis as the literally last event). Past
the full grace, "the user phase is still running" is no longer a possible explanation, so
desktop + zero `app_install_failed` + no explicit failure now reconciles to Succeeded
("completed (assumed)") instead of a red Incomplete. Sessions without a desktop (user never
began sign-in: battery-drained, powered off, max-lifetime) and sessions with any failed app
stay Incomplete — "completed" must not overclaim. Rule 4 remains the IMMEDIATE reconcile
(desktop + Hello proof); 5a only fires where waiting is over. Desktop arrival alone is still
rejected as a live completion signal.
## Closed gap: max-lifetime AwaitingUser (2026-08-27)
After max-lifetime the agent writes `enrollment-complete.marker` and self-destructs, so an
AwaitingUser verdict from that path can never heal — it is a 51h countdown to Incomplete.
The emergency-break path (rule 3) already terminalizes immediately; max-lifetime still goes
through the grace window by design ("user simply absent"). Revisit if customers read
AwaitingUser as "still possible".
AwaitingUser verdict from that path could never heal — the calibration correction stream
confirmed it: all five observed `maxlife:r5_awaiting` episodes expired to Incomplete
unhealed, while live-agent `sweep:r5_awaiting` parks upgraded 7 of 26 to Succeeded. Rule 5
therefore skips the grace once `HasAgentMaxLifetimeTimeout` is in the stream and decides the
terminal fork (5a/5b) immediately; the max-lifetime ingest appends its trigger event to the
read stream so the fact is never missed, and late straggler telemetry still heals a terminal
verdict via `TryLateTelemetryReconcileAsync`. WhiteGlove rule 1b deliberately keeps its
park — sealed/boxed devices do heal (the observed `maxlife:r1b_awaiting` episode resolved to
Succeeded).
# Examples
+19 -7
View File
@@ -9,7 +9,7 @@ tags:
- classifier
- calibration
- regression-radar
timestamp: 2026-08-23T18:00:00+02:00
timestamp: 2026-08-27T20:00:00+02:00
---
# Verdict Calibration (classifier thermometer)
@@ -114,15 +114,18 @@ and is wiped with the tenant on offboarding.
## Drift radar — `VerdictCalibrationRadar`
Same statistics as the [rule regression radar](rule-regression-radar.md) (7-day window vs the
prior 28 days, ≥ 5 window hits, ≥ 20 window sessions, lift ≥ 2, `MetricsMath.
RateIncreaseSeparated`), anchored on yesterday, over every partition with rows (each tenant
and `global`):
prior 28 days, ≥ 20 window sessions, lift ≥ 2, `MetricsMath.RateIncreaseSeparated`), with
stricter floors since the first-month tuning (2026-08-27): **≥ 10 window hits** (verdict
shares on single-digit counts are noise — a 3-hit episode survived as such), and a per-path
share regression additionally needs **≥ 5 baseline hits** — a path with no established
baseline is new (or renamed) vocabulary, not a regression. Anchored on yesterday, over every
partition with rows (each tenant and `global`):
| Kind | Signal | Re-arm |
| --- | --- | --- |
| `share_regression` | one **backend-decided** path's share of all sessions doubled (`sweep`/`maxlife`/`late`/`retro`/`rule`/`manual`/`ingest`; `agent:*` and `register:*` mirror customer workflow mix — first prod pass fired on `agent:whiteglove_pending` rollout weeks — and `legacy:*` is derived, all excluded) | share < 1.5× baseline, the path stops occurring, or the path is no longer eligible |
| `silence_share_regression` | the `sweep:*` + `maxlife:*` share doubled — the backend had to decide more often because the agent went silent (a liveness signal, not a classifier one) | same |
| `evidence_gap` | absolute: `r6` (pure fallthrough) decides ≥ 20 % of the window's classifier verdicts, ≥ 20 verdicts | < 15 % or < 20 verdicts |
| `share_regression` | one **backend-decided** path's share of all sessions doubled (`sweep`/`maxlife`/`late`/`retro`/`rule`/`manual`/`ingest`; `agent:*` and `register:*` mirror customer workflow mix — first prod pass fired on `agent:whiteglove_pending` rollout weeks — and `legacy:*` is derived, all excluded per-path) | share < 1.5× baseline, the path stops occurring, the path is no longer eligible, or the current numbers fall below the fire floors |
| `silence_share_regression` | the `sweep:*` + `maxlife:*` share doubled — the backend had to decide more often because the agent went silent (a liveness signal, not a classifier one). Rule-shaped `legacy:*` paths count into BOTH sums (window and baseline): they were the same backend decisions before the 2026-08-23 instrumentation, and without that continuity the group had a near-zero baseline by construction and fired a lift-124 rollout artifact | share < 1.5× baseline or hits stop |
| `evidence_gap` | absolute: `r6` (pure fallthrough) decides ≥ 20 % of the window's classifier verdicts (stamped rule paths plus rule-shaped `legacy:*`, same continuity), ≥ 20 verdicts | < 15 % or < 20 verdicts |
Episodes are tracker rows `verdictcalibration|{kind}|{path}|{status}` (register = `AddEntity`,
409 = already burning, fail-closed; refresh carries `FirstNotifiedAt`; 30-day tracker
@@ -140,6 +143,15 @@ Kill switch `VerdictCalibrationRadarDisabled`.
re-enrollment within 7 days after Succeeded 8.9 % (lab background), after Failed 24.3 %,
after Incomplete 10.6 % — Incomplete behaves like Succeeded, so rules 5/6 are probably too
cautious; the matrix now shows this per rule instead of per status.
* **First real calibration read (2026-08-27, four days instrumented):** the per-rule and
per-tenant split resolved that hand analysis — `legacy:r5_incomplete` re-enrolled at 2.6 %
(the `agent:complete_soft` background), spread across tenants, while `legacy:r6`'s 29 %
was concentrated in one tenant that disciplinedly redoes failed enrollments (48.5 % there,
6.7 % elsewhere — so r6 stays untouched, it correctly flags that tenant). Consequences
shipped the same day: rule 5a "completed (assumed)" (see
[Silent-Session Classification](silent-session-classification.md)), the max-lifetime grace
skip, and the radar floors + legacy group continuity above — the first production month
had produced nine standing alerts, every one an instrumentation-rollout artifact.
* **Correction stream:** a session swept to `sweep:r5_incomplete`, later completed by the
agent → row `agent:complete` with `PriorVerdictPath = sweep:r5_incomplete`; the aggregate
increments `OverriddenByLateCompletion` on the `sweep:r5_incomplete / Incomplete` bucket.
+2
View File
@@ -2,6 +2,8 @@
## 2026-08-27
* **Update**: `backend/silent-session-classification.md`, `backend/verdict-calibration.md` - the first real calibration read tuned what it measures. Rule 5 grew a past-grace fork 5a: Device Setup done + real-user desktop + zero `app_install_failed` reconciles to Succeeded "completed (assumed)" (`r5_assumed`) - r5-Incomplete devices re-enrolled at 2.6 %, the succeeded background, and the sampled desktop half had clean app records; no desktop or any failed app stays Incomplete. The max-lifetime watchdog (either event shape) now skips the AwaitingUser grace entirely - all five observed maxlife parks expired unhealed, and late telemetry heals a terminal verdict anyway - while WhiteGlove rule 1b keeps its park (its one episode healed). The drift radar got floors (≥ 10 window hits; per-path share regression needs ≥ 5 baseline hits, so new/renamed vocabulary cannot "regress") and legacy continuity: rule-shaped `legacy:*` paths count into the silence and classifier-verdict group sums on both sides of the 2026-08-23 cutover, which dissolves the lift-124 silence artifact and lets sub-floor episodes clear themselves. Backend only; the new path shows up in the matrix on its own.
* **Update**: `backend/tenant-activation-welcome-mail.md` - the reverse lookup. A delivery log names only the recipient, so an address had to lead back to its tenant: the tenant console's search now matches the notification and contact address as well as domain and tenant id, with the quoted exact mode unchanged. The addresses sit in `PreviewWhitelist`, not on the tenant config, so a new GA/Reader-only `GET /api/preview/notification-emails` returns them as one map (single cross-partition scan of `RowKey eq 'notification-email'`) instead of a point read per tenant; both sides join on the lowercased tenant id because the address rows carry the JWT `tid` while a config PartitionKey's casing is not guaranteed, and matching is case-insensitive because stored addresses keep the casing the user typed. The map is best-effort in the portal - a failed read costs only the search-by-address, never the tenant list - and the addresses stay out of the list rows (search key, not a column). Match rules moved into `tenantSearch.ts` with unit pins. Backend before web.
@@ -394,14 +394,91 @@ public class EnrollmentTimeoutClassifierTests
}
[Fact]
public void Classify_agent_timeout_failed_session_classifies_honestly()
public void Classify_agent_timeout_terminalizes_immediately_instead_of_parking()
{
// WG Part-2 shape (session 1506ce9f): DeviceSetup 4/4, no user phase, watchdog fired at
// 6h — honest verdict is AwaitingUser (within grace), never Failed.
// The watchdog firing proves the agent is gone for good — an AwaitingUser park from this
// state is a countdown, not a wait: all 5 observed maxlife:r5_awaiting episodes expired
// unhealed (calibration read 2026-08-27). Still never Failed, and late straggler
// telemetry can heal the Incomplete (TryLateTelemetryReconcileAsync).
var timeout = Evt("enrollment_failed",
data: new Dictionary<string, object> { ["failureType"] = "agent_timeout" });
var (status, _) = Classify(new[] { Esp(DeviceSetup44), timeout }, hoursSinceStart: 6);
Assert.Equal(SessionStatus.AwaitingUser, status);
var (status, reason) = Classify(new[] { Esp(DeviceSetup44), timeout }, hoursSinceStart: 6);
Assert.Equal(SessionStatus.Incomplete, status);
Assert.Contains("max-lifetime watchdog", reason);
// The V2 shutdown shape carries the same fact.
var shutdown = Evt("agent_shutting_down",
data: new Dictionary<string, object> { ["reason"] = "max_lifetime" });
var (viaShutdown, _) = Classify(new[] { Esp(DeviceSetup44), shutdown }, hoursSinceStart: 6);
Assert.Equal(SessionStatus.Incomplete, viaShutdown);
// WhiteGlove Part 2 (rule 1b) is deliberately untouched: a sealed/powered-off device
// between technician and end user stays AwaitingUser — that park DOES heal
// (the observed maxlife:r1b_awaiting episode resolved to Succeeded).
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(new[] { Esp(DeviceSetup44), timeout });
var (wgStatus, _, wgRule) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
rollup, Start, Start.AddHours(6), 72, isPreProvisioned: true, resumedAt: Start.AddHours(1));
Assert.Equal(SessionStatus.AwaitingUser, wgStatus);
Assert.Equal(ClassifierRules.R1bWhiteGloveAwaiting, wgRule);
}
// -------- Rule 5a: completed (assumed) — calibration read 2026-08-27 --------
[Fact]
public void ExtractRollup_detects_app_failures_and_maxlife_timeout()
{
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("app_install_failed") }).HasAppInstallFailure);
var timeout = Evt("enrollment_failed", data: new Dictionary<string, object> { ["failureType"] = "agent_timeout" });
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { timeout }).HasAgentMaxLifetimeTimeout);
var shutdown = Evt("agent_shutting_down", data: new Dictionary<string, object> { ["reason"] = "max_lifetime" });
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { shutdown }).HasAgentMaxLifetimeTimeout);
// Other shutdown reasons say nothing about max lifetime.
var ctrlC = Evt("agent_shutting_down", data: new Dictionary<string, object> { ["reason"] = "ctrl_c" });
Assert.False(EnrollmentTimeoutClassifier.ExtractRollup(new[] { ctrlC }).HasAgentMaxLifetimeTimeout);
// A real failure event does not raise the app flag.
Assert.False(EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("enrollment_failed") }).HasAppInstallFailure);
}
[Fact]
public void Classify_desktop_without_hello_past_grace_is_assumed_complete()
{
// The healthy half of the r5 population (drill-down 2026-08-27): desktop reached, apps
// clean, agent died before the Hello terminal. Past the full grace "user phase still
// running" is no longer a possible explanation — completed (assumed), not a red Incomplete.
var (status, reason, rule) = ClassifyWithRule(new[]
{
Esp(DeviceSetup44), Esp(AccountSetup15), Evt("desktop_arrived"),
}, hoursSinceStart: 80, grace: 72);
Assert.Equal(SessionStatus.Succeeded, status);
Assert.Equal(ClassifierRules.R5DesktopAssumed, rule);
Assert.Contains("treating the enrollment as completed", reason);
Assert.Contains("Agent last reported", reason); // silence-transparency clause
}
[Fact]
public void Classify_desktop_with_app_failure_past_grace_stays_Incomplete()
{
// "Completed" must not overclaim a session whose ESP payload provably failed
// (drill-down session b1edc6f8: desktop reached but .NET 3.5 install failed).
var (status, _) = Classify(new[]
{
Esp(DeviceSetup44), Evt("desktop_arrived"), Evt("app_install_failed"),
}, hoursSinceStart: 80, grace: 72);
Assert.Equal(SessionStatus.Incomplete, status);
}
[Fact]
public void Classify_maxlife_with_desktop_and_clean_apps_is_assumed_complete_within_grace()
{
// Agent provably gone + desktop + clean apps: no reason to park — the assumption is as
// good now as it would be after the 51h grace.
var timeout = Evt("enrollment_failed", data: new Dictionary<string, object> { ["failureType"] = "agent_timeout" });
var (status, _, rule) = ClassifyWithRule(new[]
{
Esp(DeviceSetup44), Evt("desktop_arrived"), timeout,
}, hoursSinceStart: 6);
Assert.Equal(SessionStatus.Succeeded, status);
Assert.Equal(ClassifierRules.R5DesktopAssumed, rule);
}
[Fact]
@@ -89,10 +89,79 @@ public class VerdictCalibrationRadarTests
}
[Fact]
public void Derived_legacy_paths_never_alert()
public void Derived_legacy_paths_never_alert_per_path_but_feed_the_group_sums()
{
var rows = Horizon(w => new[] { ("legacy:r6", "Incomplete", w ? 10 : 1), ("agent:complete", "Succeeded", w ? 10 : 19) });
Assert.DoesNotContain(VerdictCalibrationRadar.Evaluate(rows, Target), f => f.Kind == VerdictCalibrationAlertKinds.ShareRegression);
var findings = VerdictCalibrationRadar.Evaluate(rows, Target);
Assert.DoesNotContain(findings, f => f.Kind == VerdictCalibrationAlertKinds.ShareRegression);
// Legacy continuity: the rule-shaped legacy subset counts into the silence group, so a
// real silence jump stays visible even when the history predates the instrumentation.
Assert.Contains(findings, f => f.Kind == VerdictCalibrationAlertKinds.SilenceShareRegression);
}
[Fact]
public void Share_regression_requires_an_established_baseline()
{
// The 2026-08-23 instrumentation-cutover shape: the same decisions exist only under the
// legacy name in the baseline and only under the new name in the window. Zero baseline
// hits is new vocabulary, not a regression — and the legacy continuity keeps the silence
// group flat across the rename (production fired a lift-124 artifact without both).
var rows = Horizon(w => new[]
{
("sweep:r5_incomplete", "Incomplete", w ? 4 : 0),
("legacy:r5_incomplete", "Incomplete", w ? 0 : 4),
("agent:complete", "Succeeded", 16),
});
var findings = VerdictCalibrationRadar.Evaluate(rows, Target);
Assert.DoesNotContain(findings, f => f.Kind == VerdictCalibrationAlertKinds.ShareRegression);
Assert.DoesNotContain(findings, f => f.Kind == VerdictCalibrationAlertKinds.SilenceShareRegression);
}
[Fact]
public void Share_regression_needs_ten_window_hits()
{
// 7 window hits at 5% vs an established 1.07% baseline — lift ~4.7, but verdict shares
// on single-digit counts are noise (a 3-hit episode survived as such, read 2026-08-27).
var rows = new List<VerdictCalibrationDailyAggregate>();
for (var i = 34; i >= 0; i--)
{
var inWindow = i < 7;
rows.Add(new VerdictCalibrationDailyAggregate
{
TenantId = "t", Date = Target.AddDays(-i).ToString("yyyy-MM-dd"), SessionCount = 20, TerminalSessionCount = 20,
Buckets =
{
new VerdictCalibrationBucket { VerdictPath = "sweep:r6", Status = "Incomplete", Count = inWindow ? 1 : (i == 20 ? 6 : 0) },
new VerdictCalibrationBucket { VerdictPath = "agent:complete", Status = "Succeeded", Count = inWindow ? 19 : 20 },
},
});
}
Assert.DoesNotContain(VerdictCalibrationRadar.Evaluate(rows, Target),
f => f.Kind == VerdictCalibrationAlertKinds.ShareRegression);
}
[Fact]
public void ReArm_clears_episodes_below_the_fire_floors()
{
// A zero-baseline episode from before the floors existed (rename artifact) clears itself
// even while its share is elevated…
var artifact = new VerdictCalibrationAlert { Kind = VerdictCalibrationAlertKinds.ShareRegression, VerdictPath = "sweep:r5_incomplete", Status = "Incomplete" };
var renamed = Horizon(w => new[]
{
("sweep:r5_incomplete", "Incomplete", w ? 4 : 0),
("legacy:r5_incomplete", "Incomplete", w ? 0 : 4),
("agent:complete", "Succeeded", 16),
});
Assert.True(VerdictCalibrationRadar.ShouldReArm(artifact, renamed, Target));
// …and so does one whose window has shrunk under the 10-hit floor.
var shrunk = new VerdictCalibrationAlert { Kind = VerdictCalibrationAlertKinds.ShareRegression, VerdictPath = "maxlife:r5_awaiting", Status = "AwaitingUser" };
var tiny = Horizon(w => new[]
{
("maxlife:r5_awaiting", "AwaitingUser", 1),
("agent:complete", "Succeeded", 19),
});
Assert.True(VerdictCalibrationRadar.ShouldReArm(shrunk, tiny, Target));
}
[Fact]
@@ -60,6 +60,7 @@ public class VerdictPathTests
Assert.Equal("sweep:r5_incomplete", path);
Assert.Equal("sweep", VerdictPaths.Origin(path));
Assert.True(VerdictPaths.IsClassifierPath(path));
Assert.True(VerdictPaths.IsClassifierPath("sweep:r5_assumed"));
Assert.True(VerdictPaths.IsClassifierPath("maxlife:r1"));
Assert.True(VerdictPaths.IsClassifierPath("late:r4"));
Assert.True(VerdictPaths.IsClassifierPath("retro:r6"));
@@ -107,6 +108,7 @@ public class VerdictPathTests
Assert.Equal(ClassifierRules.R3EmergencyBreak, RuleOf(new[] { Esp(DeviceSetup44), Evt("agent_emergency_break") }));
Assert.Equal(ClassifierRules.R4DesktopHello, RuleOf(new[] { Esp(DeviceSetup44), Evt("desktop_arrived"), Evt("hello_provisioning_completed") }));
Assert.Equal(ClassifierRules.R5DeviceSetupAwaiting, RuleOf(new[] { Esp(DeviceSetup44), Esp(AccountSetup15) }, hours: 6, grace: 72));
Assert.Equal(ClassifierRules.R5DesktopAssumed, RuleOf(new[] { Esp(DeviceSetup44), Esp(AccountSetup15), Evt("desktop_arrived") }, hours: 80, grace: 72));
Assert.Equal(ClassifierRules.R5DeviceSetupIncomplete, RuleOf(new[] { Esp(DeviceSetup44), Esp(AccountSetup15) }, hours: 80, grace: 72));
Assert.Equal(ClassifierRules.R6Fallthrough, RuleOf(Array.Empty<EnrollmentEvent>()));
}
@@ -38,8 +38,10 @@ public class VerdictCalibrationFinding
/// <item><b>evidence_gap</b> — absolute gate: the pure fallthrough rule (r6) decides ≥20 % of the
/// window's classifier verdicts. The evidence the other rules need is not arriving.</item>
/// </list>
/// Derived (<c>legacy:*</c>) paths are excluded from all three — pre-instrumentation attribution
/// is too weak to alert on. Agent-declared paths (<c>agent:*</c>, <c>register:*</c>) are excluded
/// Derived (<c>legacy:*</c>) paths never alert per-path — pre-instrumentation attribution is too
/// weak for that — but their rule-shaped subset DOES feed the silence and classifier-verdict
/// group sums so the groups stay continuous across the 2026-08-23 instrumentation cutover
/// (see <see cref="Summarize"/>). Agent-declared paths (<c>agent:*</c>, <c>register:*</c>) are excluded
/// from the per-path share regression: their share tracks the customer's workflow mix (a
/// WhiteGlove rollout week doubles <c>agent:whiteglove_pending</c>; a Continue-Anyway tenant
/// lives on <c>agent:complete_soft</c>) — real signals, but not about the BACKEND classifier
@@ -51,7 +53,21 @@ public static class VerdictCalibrationRadar
{
public const int WindowDays = RuleRegressionRadar.WindowDays;
public const int BaselineDays = RuleRegressionRadar.BaselineDays;
public const int MinWindowHits = RuleRegressionRadar.MinWindowHitSessions;
/// <summary>
/// Minimum window hits before a share finding fires or keeps its episode. Deliberately
/// higher than the rule radar's 5: verdict shares swing on tiny counts, and the first
/// production month kept a 3-hit maxlife:r5_awaiting episode alive as pure noise
/// (calibration read 2026-08-27).
/// </summary>
public const int MinWindowHits = 10;
/// <summary>
/// Minimum baseline hits before a PER-PATH share regression is meaningful. A path with no
/// established baseline cannot regress — it is new vocabulary (or a renamed path), and
/// alerting on it just counts the rollout: the 2026-08-23 instrumentation cut over from
/// legacy:* to sweep:*/register:* names and fired seven zero-baseline artifacts. The group
/// kinds are exempt — their baselines carry the legacy continuity (see <see cref="Summarize"/>).
/// </summary>
public const int MinBaselineHits = 5;
public const int MinWindowSessions = RuleRegressionRadar.MinWindowSessions;
public const double MinLift = RuleRegressionRadar.MinLift;
public const double ReArmLiftFactor = RuleRegressionRadar.ReArmLiftFactor;
@@ -135,7 +151,7 @@ public static class VerdictCalibrationRadar
.ToList();
}
/// <summary>True when an ACTIVE alert may re-arm given the current horizon sums (or when its path is no longer eligible — episodes from an earlier, broader gate clear themselves).</summary>
/// <summary>True when an ACTIVE alert may re-arm given the current horizon sums (or when its path/numbers no longer pass the fire gates — episodes from an earlier, broader gate clear themselves).</summary>
public static bool ShouldReArm(VerdictCalibrationAlert alert, IReadOnlyList<VerdictCalibrationDailyAggregate> tenantRows, DateTime targetDateUtc)
{
if (alert.Kind == VerdictCalibrationAlertKinds.ShareRegression && !IsShareRegressionEligible(alert.VerdictPath))
@@ -153,6 +169,9 @@ public static class VerdictCalibrationRadar
default:
horizon.Paths.TryGetValue((alert.VerdictPath, alert.Status), out var sums);
// Below the fire floors the episode is noise-retention, not an elevated share —
// clear it (it re-fires only by passing the full gates again).
if (sums.WindowHits < MinWindowHits || sums.BaselineHits < MinBaselineHits) return true;
return ReArmByRate(sums.WindowHits, horizon.WindowSessions, sums.BaselineHits, horizon.BaselineSessions);
}
}
@@ -189,6 +208,8 @@ public static class VerdictCalibrationRadar
if (windowHits < MinWindowHits) return null;
if (windowSessions < MinWindowSessions) return null;
if (baselineSessions <= 0) return null;
// Per-path only: no established baseline → no regression claim (new/renamed vocabulary).
if (kind == VerdictCalibrationAlertKinds.ShareRegression && baselineHits < MinBaselineHits) return null;
var windowRate = (double)windowHits / windowSessions;
var baselineRate = (double)baselineHits / baselineSessions;
@@ -212,6 +233,14 @@ public static class VerdictCalibrationRadar
};
}
/// <summary>Derived classifier-rule path (<c>legacy:r5_incomplete</c>) — same detail shape as <see cref="VerdictPaths.IsClassifierPath"/>, legacy origin.</summary>
private static bool IsLegacyClassifierPath(string path, string origin)
{
if (origin != LegacyOrigin) return false;
var detail = path.Length > origin.Length + 1 ? path.Substring(origin.Length + 1) : string.Empty;
return detail.Length >= 2 && detail[0] == 'r' && char.IsDigit(detail[1]);
}
internal readonly record struct PathSums(int WindowHits, int BaselineHits);
internal sealed class Horizon
@@ -234,6 +263,16 @@ public static class VerdictCalibrationRadar
/// question is "how often did the backend have to decide", not which rule did). Classifier
/// verdicts = stamped rule paths (<see cref="VerdictPaths.IsClassifierPath"/>); r6 = their
/// fallthrough subset.
/// Legacy continuity: derived rule-shaped paths (<c>legacy:r5_incomplete</c>, <c>legacy:r6</c>, …)
/// were the SAME backend decisions before the 2026-08-23 instrumentation — the derivation reads
/// the writers' own reason literals, which is strong enough for a group sum (though still too
/// weak for per-path alerting). Counting them symmetrically (window AND baseline) keeps the
/// silence and classifier-verdict groups continuous across the cutover; without this the groups
/// had a near-zero baseline by construction and fired a lift-124 artifact. Legacy rows only
/// exist for pre-instrumentation sessions, so the term self-deprecates as they age out.
/// (Non-rule legacy paths — <c>legacy:superseded</c>, <c>legacy:wg_awaiting</c>,
/// <c>legacy:unknown</c> — stay excluded: superseded is a registration write, unknown is
/// unattributable, and wg_awaiting is a negligible tail.)
/// </summary>
internal static Horizon Summarize(IReadOnlyList<VerdictCalibrationDailyAggregate> rows, DateTime targetDateUtc)
{
@@ -262,11 +301,12 @@ public static class VerdictCalibrationRadar
: new PathSums(sums.WindowHits, sums.BaselineHits + b.Count);
var origin = VerdictPaths.Origin(b.VerdictPath);
if (origin == VerdictPaths.OriginSweep || origin == VerdictPaths.OriginMaxLifetime)
var legacyRule = IsLegacyClassifierPath(b.VerdictPath, origin);
if (origin == VerdictPaths.OriginSweep || origin == VerdictPaths.OriginMaxLifetime || legacyRule)
{
if (inWindow) h.SilenceWindowHits += b.Count; else h.SilenceBaselineHits += b.Count;
}
if (VerdictPaths.IsClassifierPath(b.VerdictPath))
if (VerdictPaths.IsClassifierPath(b.VerdictPath) || legacyRule)
{
if (inWindow) h.ClassifierWindowVerdicts += b.Count; else h.ClassifierBaselineVerdicts += b.Count;
if (b.VerdictPath.EndsWith(":" + ClassifierRules.R6Fallthrough, StringComparison.Ordinal))
@@ -99,7 +99,9 @@ namespace AutopilotMonitor.Functions.Services
bool HelloPolicyDisabled,
bool SkipUserEsp,
bool RealmJoinDetected,
bool RealmJoinResolved);
bool RealmJoinResolved,
bool HasAppInstallFailure,
bool HasAgentMaxLifetimeTimeout);
/// <summary>
/// Walk a session's events and distill the ESP rollup. Tolerant of missing/empty input
@@ -115,6 +117,7 @@ namespace AutopilotMonitor.Functions.Services
int acctBestN = 0, acctBestM = 0;
bool acctFallbackAll = false;
bool hasFailure = false, hasComplete = false, hasEnrollmentComplete = false, hasEmergencyBreak = false;
bool hasAppInstallFailure = false, hasMaxLifetimeTimeout = false;
bool desktopArrived = false, helloResolved = false;
bool realmJoinDetected = false, realmJoinResolved = false;
bool sawHelloPolicyEnabled = false, sawHelloPolicyDisabled = false;
@@ -134,7 +137,19 @@ namespace AutopilotMonitor.Functions.Services
// (misclassification audit 2026-07-16, tenant a53e67ec cluster).
if (!Eq(TryGetDataString(evt, "failureType") ?? string.Empty, "agent_timeout"))
hasFailure = true;
else
hasMaxLifetimeTimeout = true;
}
// The V2 watchdog shape of the same fact: the agent shut itself down at its
// max-lifetime cap. Either shape proves the agent is gone for good — rule 5 uses
// that to skip the AwaitingUser grace (a wait nothing can ever end).
else if (Eq(type, "agent_shutting_down"))
{
if (Eq(TryGetDataString(evt, "reason") ?? string.Empty, "max_lifetime"))
hasMaxLifetimeTimeout = true;
}
else if (Eq(type, "app_install_failed"))
hasAppInstallFailure = true;
else if (Eq(type, "enrollment_complete") || Eq(type, "whiteglove_complete"))
{
hasComplete = true;
@@ -230,7 +245,9 @@ namespace AutopilotMonitor.Functions.Services
HelloPolicyDisabled: sawHelloPolicyDisabled && !sawHelloPolicyEnabled,
SkipUserEsp: sawSkipUserTrue && !sawSkipUserFalse,
RealmJoinDetected: realmJoinDetected,
RealmJoinResolved: realmJoinResolved);
RealmJoinResolved: realmJoinResolved,
HasAppInstallFailure: hasAppInstallFailure,
HasAgentMaxLifetimeTimeout: hasMaxLifetimeTimeout);
}
/// <summary>
@@ -418,7 +435,13 @@ namespace AutopilotMonitor.Functions.Services
if (rollup.DeviceSetupAllSucceeded)
{
var elapsedHours = (nowUtc - startedAtUtc).TotalHours;
if (elapsedHours < graceHours)
// The grace only buys time for a LIVE agent to come back with real evidence. Once
// the agent's own max-lifetime watchdog has fired it has cleaned up and exited —
// parking such a session AwaitingUser is a countdown, not a wait (all 5 observed
// maxlife:r5_awaiting episodes expired unhealed; calibration read 2026-08-27), so
// it skips straight to the terminal fork. Nothing is lost by deciding now: late
// straggler telemetry still heals a terminal verdict (TryLateTelemetryReconcileAsync).
if (elapsedHours < graceHours && !rollup.HasAgentMaxLifetimeTimeout)
{
var acct = rollup.AccountSetupTotal > 0
? $" (Account Setup {rollup.AccountSetupSucceededCount}/{rollup.AccountSetupTotal})"
@@ -428,11 +451,32 @@ namespace AutopilotMonitor.Functions.Services
ClassifierRules.R5DeviceSetupAwaiting);
}
// 5a. Desktop reached + clean app record → completed (assumed). Unlike rule 4 this
// has no Hello terminal, so it never fires as an IMMEDIATE completion signal —
// only here, after the full grace (or with the agent provably gone), where
// "the user phase is still running" is no longer a possible explanation.
// Calibration justified the assumption (2026-08-27 read): r5-Incomplete devices
// re-enrolled at 2.6 % — the succeeded background, not the 29 % failure level —
// and the sampled half with a desktop had all tracked apps terminal with none
// failed. An app_install_failed anywhere disqualifies: "completed" must not
// overclaim a session whose ESP payload is provably not clean.
if (rollup.DesktopArrived && !rollup.HasAppInstallFailure)
return (SessionStatus.Succeeded, AppendReconcileTiming(
"Reconciled: Device Setup fully provisioned and a real-user desktop was observed — " +
"no completion, failure or app-install error before the agent went silent; treating the enrollment as completed",
startedAtUtc, nowUtc, lastEventAtUtc), ClassifierRules.R5DesktopAssumed);
// "0/0" would suggest a parsed rollup that never existed — when no Account Setup
// rollup was ever observed, say so instead (misclassification audit 2026-07-16).
var acctDetail = rollup.AccountSetupTotal > 0
? $"last Account Setup {rollup.AccountSetupSucceededCount}/{rollup.AccountSetupTotal}"
: "Account Setup progress never observed";
// Inside the grace the only way here is the max-lifetime short-circuit — the
// "within {grace}h" wording would be false, so name the watchdog instead.
if (elapsedHours < graceHours)
return (SessionStatus.Incomplete,
$"Agent max-lifetime watchdog fired — agent gone without completion after Device Setup completed ({acctDetail})",
ClassifierRules.R5DeviceSetupIncomplete);
return (SessionStatus.Incomplete,
$"No completion signal within {graceHours}h grace after Device Setup completed ({acctDetail})",
ClassifierRules.R5DeviceSetupIncomplete);
@@ -285,6 +285,12 @@ namespace AutopilotMonitor.Functions.Services
// Part-1 start (same anchor the maintenance sweep uses).
var effectiveStart = session?.ResumedAt ?? session?.StartedAt ?? triggerEvent.Timestamp;
// The trigger event itself may not be persisted yet when this read runs — append it so
// the rollup always carries the max-lifetime fact (rule 5 skips the AwaitingUser grace
// on it: the agent is provably gone). The rollup folds booleans, so a stored duplicate
// is harmless.
sessionEvents.Add(triggerEvent);
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(sessionEvents);
var (targetStatus, reason, rule) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
rollup, effectiveStart, now, graceHours, session?.LastEventAt ?? c.LatestEventTimestamp,
@@ -97,6 +97,13 @@ namespace AutopilotMonitor.Shared.Models
public const string R3EmergencyBreak = "r3";
public const string R4DesktopHello = "r4";
public const string R5DeviceSetupAwaiting = "r5_awaiting";
/// <summary>
/// Rule-5 grace expired (or the agent is provably gone) with a real-user desktop observed and a
/// clean app record — completed (assumed). Split from <see cref="R5DeviceSetupIncomplete"/> after
/// the 2026-08-27 calibration read: r5-Incomplete devices re-enrolled at 2.6 % (the succeeded
/// background), and half the sampled sessions were healthy desktops whose agent died after the work.
/// </summary>
public const string R5DesktopAssumed = "r5_assumed";
public const string R5DeviceSetupIncomplete = "r5_incomplete";
public const string R6Fallthrough = "r6";
}