From 4066171f14f6fa1efd3fe62b0deb673dab7a6064 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Thu, 30 Apr 2026 15:55:51 +0200 Subject: [PATCH] feat(backend): structured audit trail on V2 DecisionEngine timeline events DecisionAuditTrailBuilder now produces a uniform typed-payload (signalsSeen, signalEvidence, classifier verdict, scenario context) for reducer-emitted decision events. Six previously shipped with empty Data on the wire (whiteglove_complete, whiteglove_resumed, whiteglove_part2_complete, three enrollment_failed flavors); the existing enrollment_complete trail is consolidated onto the same builder. V1 path untouched. AdminPreemption left for follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../DecisionAuditTrailTests.cs | 401 ++++++++++++++++++ .../FinalizingStageTests.cs | 13 +- .../Engine/DecisionAuditTrailBuilder.cs | 371 ++++++++++++++++ .../Engine/DecisionEngine.Classic.cs | 80 +--- .../Engine/DecisionEngine.Edge.cs | 7 +- .../Engine/DecisionEngine.Shared.cs | 7 +- .../Engine/DecisionEngine.WhiteGlove.cs | 9 +- .../Engine/DecisionEngine.WhiteGlovePart2.cs | 33 +- 8 files changed, 841 insertions(+), 80 deletions(-) create mode 100644 src/Agent/AutopilotMonitor.DecisionCore.Tests/DecisionAuditTrailTests.cs create mode 100644 src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionAuditTrailBuilder.cs diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/DecisionAuditTrailTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/DecisionAuditTrailTests.cs new file mode 100644 index 00000000..d4e710b8 --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/DecisionAuditTrailTests.cs @@ -0,0 +1,401 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AutopilotMonitor.DecisionCore.Classifiers; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.DecisionCore.Tests +{ + /// + /// Coverage for and the six DecisionEngine emission + /// sites that previously published terminal / state-changing timeline events with empty + /// or near-empty EnrollmentEvent.Data: whiteglove_complete, + /// whiteglove_resumed, whiteglove_part2_complete, enrollment_failed + /// (Part-2 safety / EspTerminalFailure / EffectInfrastructureFailure). Each test exercises + /// the reducer's typed-payload contract end-to-end so a regression to "empty Data on the + /// wire" is caught at the engine boundary instead of in a UI smoke test. + /// + public sealed class DecisionAuditTrailTests + { + private static readonly DateTime T0 = new DateTime(2026, 4, 30, 8, 0, 0, DateTimeKind.Utc); + + private static DecisionSignal MakeSignal( + long ordinal, + DecisionSignalKind kind, + DateTime occurredAtUtc, + IReadOnlyDictionary? payload = null, + string sourceOrigin = "test") + { + return new DecisionSignal( + sessionSignalOrdinal: ordinal, + sessionTraceOrdinal: ordinal, + kind: kind, + kindSchemaVersion: 1, + occurredAtUtc: occurredAtUtc, + sourceOrigin: sourceOrigin, + evidence: new Evidence(EvidenceKind.Synthetic, $"{kind}-{ordinal}", "test"), + payload: payload); + } + + private static DecisionEffect SingleTimelineEffect(DecisionStep step, string eventType) => + step.Effects.Single(e => + e.Kind == DecisionEffectKind.EmitEventTimelineEntry + && e.Parameters != null + && e.Parameters.TryGetValue("eventType", out var et) + && et == eventType); + + // ============================================================ builder unit tests + + [Fact] + public void Build_emits_mandatory_anchor_fields_for_minimal_state() + { + var state = DecisionState.CreateInitial("s", "t").ToBuilder() + .WithStepIndex(7) + .WithLastAppliedSignalOrdinal(42) + .Build(); + + var data = DecisionAuditTrailBuilder.Build( + postState: state, + decidedStage: SessionStage.SessionStarted, + trigger: "Test:Trigger"); + + Assert.Equal("DecisionEngine", data["decisionSource"]); + Assert.Equal("Test:Trigger", data["trigger"]); + Assert.Equal(nameof(SessionStage.SessionStarted), data["sessionStage"]); + Assert.Equal(7, data["stepIndex"]); + Assert.Equal(42L, data["signalOrdinal"]); + Assert.IsType>(data["signalsSeen"]); + Assert.IsType>(data["signalEvidence"]); + Assert.IsType>(data["signalTimestamps"]); + Assert.IsType>(data["scenario"]); + Assert.False(data.ContainsKey("classifier")); + Assert.False(data.ContainsKey("classifierInputs")); + Assert.False(data.ContainsKey("reason")); + } + + [Fact] + public void Build_omits_signalOrdinal_when_state_has_no_applied_signal() + { + // CreateInitial leaves LastAppliedSignalOrdinal at -1 — the builder should NOT + // forge a "signalOrdinal=-1" entry that would mislead the Inspector. + var state = DecisionState.CreateInitial("s", "t"); + + var data = DecisionAuditTrailBuilder.Build( + postState: state, + decidedStage: SessionStage.SessionStarted, + trigger: "Test:NoSignal"); + + Assert.False(data.ContainsKey("signalOrdinal")); + } + + [Fact] + public void Build_includes_failureReason_only_when_provided() + { + var state = DecisionState.CreateInitial("s", "t"); + + var withReason = DecisionAuditTrailBuilder.Build( + postState: state, + decidedStage: SessionStage.Failed, + trigger: "Test:Failure", + failureReason: "ime_pattern_failure"); + Assert.Equal("ime_pattern_failure", withReason["reason"]); + + var withoutReason = DecisionAuditTrailBuilder.Build( + postState: state, + decidedStage: SessionStage.Failed, + trigger: "Test:Failure"); + Assert.False(withoutReason.ContainsKey("reason")); + } + + [Fact] + public void Build_classifier_section_only_appears_when_verdict_supplied() + { + var state = DecisionState.CreateInitial("s", "t"); + var verdict = new ClassifierVerdictInfo("WhiteGloveSealing", "Confirmed", 80, "shellcore_alone", "abc123"); + + var data = DecisionAuditTrailBuilder.Build( + postState: state, + decidedStage: SessionStage.WhiteGloveSealed, + trigger: "Test:Classifier", + classifier: verdict); + + var classifierBlock = Assert.IsType>(data["classifier"]); + Assert.Equal("WhiteGloveSealing", classifierBlock["id"]); + Assert.Equal("Confirmed", classifierBlock["level"]); + Assert.Equal(80, classifierBlock["score"]); + Assert.Equal("shellcore_alone", classifierBlock["reason"]); + Assert.Equal("abc123", classifierBlock["inputHash"]); + } + + [Fact] + public void Build_classifierInputs_flattens_snapshot_record_to_camelCased_dict() + { + var snapshot = new WhiteGloveSealingSnapshot( + shellCoreWhiteGloveSuccessSeen: true, + whiteGloveSealingPatternSeen: true, + aadJoinedWithUser: false, + desktopArrived: false, + helloResolved: false, + hasAccountSetupActivity: false, + isDeviceOnlyDeploymentHypothesis: false, + systemRebootUtc: null, + currentEnrollmentPhase: null); + + var data = DecisionAuditTrailBuilder.Build( + postState: DecisionState.CreateInitial("s", "t"), + decidedStage: SessionStage.WhiteGloveSealed, + trigger: "Test:Inputs", + classifierInputs: snapshot); + + var inputs = Assert.IsType>(data["classifierInputs"]); + Assert.Equal(true, inputs["shellCoreWhiteGloveSuccessSeen"]); + Assert.Equal(true, inputs["whiteGloveSealingPatternSeen"]); + Assert.Equal(false, inputs["aadJoinedWithUser"]); + // Null nullable properties are skipped, not emitted as null sentinels. + Assert.False(inputs.ContainsKey("systemRebootUtc")); + Assert.False(inputs.ContainsKey("currentEnrollmentPhase")); + } + + [Fact] + public void VerdictFromSignalPayload_returns_null_for_missing_keys() + { + Assert.Null(DecisionAuditTrailBuilder.VerdictFromSignalPayload(null)); + Assert.Null(DecisionAuditTrailBuilder.VerdictFromSignalPayload(new Dictionary())); + Assert.Null(DecisionAuditTrailBuilder.VerdictFromSignalPayload( + new Dictionary { ["classifier"] = "X" })); // no level + } + + [Fact] + public void VerdictFromSignalPayload_parses_full_payload() + { + var v = DecisionAuditTrailBuilder.VerdictFromSignalPayload( + new Dictionary + { + ["classifier"] = "WhiteGloveSealing", + ["level"] = "Confirmed", + ["score"] = "80", + ["reason"] = "shellcore_alone", + ["inputHash"] = "abc123", + }); + + Assert.NotNull(v); + Assert.Equal("WhiteGloveSealing", v!.Id); + Assert.Equal("Confirmed", v.Level); + Assert.Equal(80, v.Score); + Assert.Equal("shellcore_alone", v.Reason); + Assert.Equal("abc123", v.InputHash); + } + + // ============================================================ whiteglove_complete + + [Fact] + public void WhiteGloveComplete_carries_audit_trail_with_classifier_verdict_and_inputs() + { + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial("sess-wg", "tenant-wg"); + // Drive through SessionStarted so the StepIndex / LastAppliedSignalOrdinal are real. + state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState; + // Observe ShellCore success — the inline-WG fast path: a single high-confidence signal + // is enough for the classifier to seal at 80/100. + var shellSignal = MakeSignal(1, DecisionSignalKind.WhiteGloveShellCoreSuccess, T0.AddMinutes(5)); + state = engine.Reduce(state, shellSignal).NewState; + + // The reducer emitted a RunClassifier effect; the harness here synthesizes the + // resulting verdict signal directly so the WG verdict-applier path runs. + var verdictSignal = MakeSignal(2, DecisionSignalKind.ClassifierVerdictIssued, T0.AddMinutes(5), + new Dictionary + { + ["classifier"] = WhiteGloveSealingClassifier.ClassifierId, + ["level"] = "Confirmed", + ["score"] = "80", + ["reason"] = "shellcore_alone", + ["inputHash"] = "deadbeef", + }); + + var step = engine.Reduce(state, verdictSignal); + Assert.Equal(SessionStage.WhiteGloveSealed, step.NewState.Stage); + + var effect = SingleTimelineEffect(step, "whiteglove_complete"); + var payload = Assert.IsType>(effect.TypedPayload); + + Assert.Equal("DecisionEngine", payload["decisionSource"]); + Assert.Equal($"ClassifierVerdictIssued:{WhiteGloveSealingClassifier.ClassifierId}:Confirmed", payload["trigger"]); + Assert.Equal(nameof(SessionStage.WhiteGloveSealed), payload["sessionStage"]); + + var signalsSeen = Assert.IsType>(payload["signalsSeen"]); + Assert.Contains("shellcore_whiteglove_success", signalsSeen); + + var classifier = Assert.IsType>(payload["classifier"]); + Assert.Equal(WhiteGloveSealingClassifier.ClassifierId, classifier["id"]); + Assert.Equal("Confirmed", classifier["level"]); + Assert.Equal(80, classifier["score"]); + Assert.Equal("deadbeef", classifier["inputHash"]); + + var inputs = Assert.IsType>(payload["classifierInputs"]); + Assert.Equal(true, inputs["shellCoreWhiteGloveSuccessSeen"]); + Assert.Equal(false, inputs["aadJoinedWithUser"]); + + var scenario = Assert.IsType>(payload["scenario"]); + Assert.Equal(EnrollmentMode.WhiteGlove.ToString(), scenario["mode"]); + } + + // ============================================================ whiteglove_resumed + + [Fact] + public void WhiteGloveResumed_attaches_audit_trail_alongside_legacy_scalar_parameters() + { + var engine = new DecisionEngine(); + // Sealed Part-1 state recovered after reboot. + var sealedState = DecisionState.CreateInitial("sess-wgr", "tenant-wgr") + .ToBuilder() + .WithStage(SessionStage.WhiteGloveSealed) + .Build(); + var resumeUtc = new DateTime(2026, 4, 30, 9, 0, 0, DateTimeKind.Utc); + var step = engine.Reduce(sealedState, MakeSignal( + 10, DecisionSignalKind.SessionRecovered, resumeUtc, sourceOrigin: "EnrollmentOrchestrator")); + + var effect = SingleTimelineEffect(step, "whiteglove_resumed"); + + // Legacy scalar parameters preserved (existing assertions in ReducerTimelineEffectTests). + Assert.Equal(resumeUtc.ToString("o", System.Globalization.CultureInfo.InvariantCulture), + effect.Parameters!["resumedAtUtc"]); + Assert.Equal("EnrollmentOrchestrator", effect.Parameters["sourceOrigin"]); + + // Newly-attached audit trail. + var payload = Assert.IsType>(effect.TypedPayload); + Assert.Equal("SessionRecovered:WhiteGloveSealed->AwaitingUserSignIn", payload["trigger"]); + Assert.Equal(nameof(SessionStage.WhiteGloveAwaitingUserSignIn), payload["sessionStage"]); + // The bridge-specific scalars are present in the typed payload too. + Assert.Equal(resumeUtc.ToString("o", System.Globalization.CultureInfo.InvariantCulture), payload["resumedAtUtc"]); + Assert.Equal("EnrollmentOrchestrator", payload["sourceOrigin"]); + } + + // ============================================================ whiteglove_part2_complete + + [Fact] + public void WhiteGlovePart2Complete_carries_audit_trail_with_part2_verdict_and_facts() + { + var engine = new DecisionEngine(); + // Establish Part-2 awaiting state with all four Part-2 facts in place so the + // verdict applier transitions to WhiteGloveCompletedPart2. + var state = DecisionState.CreateInitial("sess-wg2", "tenant-wg2") + .ToBuilder() + .WithStage(SessionStage.WhiteGloveAwaitingUserSignIn) + .Build(); + state = engine.Reduce(state, MakeSignal(20, DecisionSignalKind.UserAadSignInComplete, T0.AddHours(2))).NewState; + state = engine.Reduce(state, MakeSignal(21, DecisionSignalKind.HelloResolvedPart2, T0.AddHours(2).AddSeconds(10))).NewState; + state = engine.Reduce(state, MakeSignal(22, DecisionSignalKind.DesktopArrivedPart2, T0.AddHours(2).AddSeconds(20))).NewState; + state = engine.Reduce(state, MakeSignal(23, DecisionSignalKind.AccountSetupCompletedPart2, T0.AddHours(2).AddSeconds(30))).NewState; + + var verdictSignal = MakeSignal(24, DecisionSignalKind.ClassifierVerdictIssued, T0.AddHours(2).AddMinutes(1), + new Dictionary + { + ["classifier"] = WhiteGlovePart2CompletionClassifier.ClassifierId, + ["level"] = "Confirmed", + ["score"] = "100", + ["reason"] = "all_part2_facts_present", + ["inputHash"] = "feedface", + }); + + var step = engine.Reduce(state, verdictSignal); + Assert.Equal(SessionStage.WhiteGloveCompletedPart2, step.NewState.Stage); + + var effect = SingleTimelineEffect(step, "whiteglove_part2_complete"); + var payload = Assert.IsType>(effect.TypedPayload); + + Assert.Equal($"ClassifierVerdictIssued:{WhiteGlovePart2CompletionClassifier.ClassifierId}:Confirmed", payload["trigger"]); + Assert.Equal(nameof(SessionStage.WhiteGloveCompletedPart2), payload["sessionStage"]); + + var signalsSeen = Assert.IsType>(payload["signalsSeen"]); + Assert.Contains("user_aad_sign_in_complete", signalsSeen); + Assert.Contains("hello_resolved_part2", signalsSeen); + Assert.Contains("desktop_arrived_part2", signalsSeen); + Assert.Contains("account_setup_completed_part2", signalsSeen); + + var classifier = Assert.IsType>(payload["classifier"]); + Assert.Equal(WhiteGlovePart2CompletionClassifier.ClassifierId, classifier["id"]); + Assert.Equal("feedface", classifier["inputHash"]); + } + + // ============================================================ enrollment_failed (Part-2 safety) + + [Fact] + public void Part2SafetyDeadline_failure_event_carries_audit_trail_with_part2_user_absent_reason() + { + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial("sess-p2s", "tenant-p2s") + .ToBuilder() + .WithStage(SessionStage.WhiteGloveAwaitingUserSignIn) + .AddDeadline(new ActiveDeadline( + name: DeadlineNames.WhiteGlovePart2Safety, + dueAtUtc: T0.AddHours(24), + firesSignalKind: DecisionSignalKind.DeadlineFired, + firesPayload: new Dictionary + { + [SignalPayloadKeys.Deadline] = DeadlineNames.WhiteGlovePart2Safety, + })) + .Build(); + + var step = engine.Reduce(state, MakeSignal( + 30, DecisionSignalKind.DeadlineFired, T0.AddHours(24), + new Dictionary { [SignalPayloadKeys.Deadline] = DeadlineNames.WhiteGlovePart2Safety })); + + Assert.Equal(SessionStage.Failed, step.NewState.Stage); + + var effect = SingleTimelineEffect(step, "enrollment_failed"); + var payload = Assert.IsType>(effect.TypedPayload); + + Assert.Equal($"DeadlineFired:{DeadlineNames.WhiteGlovePart2Safety}", payload["trigger"]); + Assert.Equal("part2_user_absent", payload["reason"]); + Assert.Equal(nameof(SessionStage.Failed), payload["sessionStage"]); + } + + // ============================================================ enrollment_failed (ESP terminal) + + [Fact] + public void EspTerminalFailure_failure_event_carries_audit_trail_with_signal_reason() + { + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial("sess-esp", "tenant-esp"); + state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState; + + var step = engine.Reduce(state, MakeSignal( + 40, DecisionSignalKind.EspTerminalFailure, T0.AddMinutes(15), + new Dictionary { ["reason"] = "policy_apply_timeout" })); + + Assert.Equal(SessionStage.Failed, step.NewState.Stage); + + var effect = SingleTimelineEffect(step, "enrollment_failed"); + var payload = Assert.IsType>(effect.TypedPayload); + + Assert.Equal(nameof(DecisionSignalKind.EspTerminalFailure), payload["trigger"]); + Assert.Equal("policy_apply_timeout", payload["reason"]); + } + + // ============================================================ enrollment_failed (effect infra) + + [Fact] + public void EffectInfrastructureFailure_failure_event_carries_audit_trail_with_signal_reason() + { + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial("sess-eff", "tenant-eff"); + state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState; + + var step = engine.Reduce(state, MakeSignal( + 50, DecisionSignalKind.EffectInfrastructureFailure, T0.AddMinutes(2), + new Dictionary { ["reason"] = "deadline_scheduler_offline" })); + + Assert.Equal(SessionStage.Failed, step.NewState.Stage); + + var effect = SingleTimelineEffect(step, "enrollment_failed"); + var payload = Assert.IsType>(effect.TypedPayload); + + Assert.Equal(nameof(DecisionSignalKind.EffectInfrastructureFailure), payload["trigger"]); + Assert.Equal("deadline_scheduler_offline", payload["reason"]); + } + } +} diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/FinalizingStageTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/FinalizingStageTests.cs index b0b20bc5..3bb80a01 100644 --- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/FinalizingStageTests.cs +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/FinalizingStageTests.cs @@ -153,8 +153,11 @@ namespace AutopilotMonitor.DecisionCore.Tests && e.Parameters.TryGetValue("eventType", out var et) && et == "enrollment_complete"); var payload = Assert.IsType>(terminalEffect.TypedPayload); - Assert.Equal("DecisionEngine", payload["completionSource"]); - Assert.Equal($"DeadlineFired:{DeadlineNames.FinalizingGrace}", payload["completionTrigger"]); + // Field names come from the unified DecisionAuditTrailBuilder (decisionSource / + // trigger replace the legacy completionSource / completionTrigger; the rest of the + // shape is a strict superset of what enrollment_complete used to publish). + Assert.Equal("DecisionEngine", payload["decisionSource"]); + Assert.Equal($"DeadlineFired:{DeadlineNames.FinalizingGrace}", payload["trigger"]); Assert.Equal(nameof(SessionStage.Completed), payload["sessionStage"]); var signalsSeen = Assert.IsType>(payload["signalsSeen"]); @@ -168,6 +171,12 @@ namespace AutopilotMonitor.DecisionCore.Tests // HelloOutcome payload was supplied as "Success" by the InitialAwaitingDesktop fixture. Assert.Equal("Success", payload["helloOutcome"]); + + // Evidence map carries ordinal + utc per fact for the Inspector's signal-jump. + var evidence = Assert.IsType>(payload["signalEvidence"]); + var helloEvidence = Assert.IsType>(evidence["helloResolved"]); + Assert.True(helloEvidence.ContainsKey("ordinal")); + Assert.True(helloEvidence.ContainsKey("utc")); } [Fact] diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionAuditTrailBuilder.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionAuditTrailBuilder.cs new file mode 100644 index 00000000..877cd688 --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionAuditTrailBuilder.cs @@ -0,0 +1,371 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using AutopilotMonitor.DecisionCore.State; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.DecisionCore.Engine +{ + /// + /// Uniform structured audit-trail payload for terminal / state-changing + /// effects emitted by the + /// . Replaces the per-emission-site ad-hoc payload shapes + /// that previously left events like whiteglove_complete with empty + /// EnrollmentEvent.Data on the wire. + /// + /// The output is meant to be passed verbatim as ; + /// emitters (V2 EventTimelineEmitter) hand the dictionary + /// directly to . Field ordering uses + /// so on-disk Snapshot test outputs are stable. + /// + /// + /// + /// Contract for callers: + /// + /// is the state AFTER the reduce step that emits the + /// event. StepIndex and LastAppliedSignalOrdinal on it correspond to + /// the emitting transition's StepIndex and the signal that drove the reduce. + /// is what the caller is committing to — usually + /// equals postState.Stage; passed explicitly so the field reflects the + /// decision's outcome even if the caller still routes through an intermediate + /// stage. + /// mirrors so a + /// consumer can correlate the audit-trail event with the journal transition without + /// joining tables (e.g. "DeadlineFired:FinalizingGrace", + /// "ClassifierVerdictIssued:WhiteGloveSealing:Confirmed"). + /// + /// + internal static class DecisionAuditTrailBuilder + { + /// + /// Stable string the audit trail uses for the decisionSource field. Mirrors the + /// V2 emitter default (EventTimelineEmitter.SourceId) — keeps backend filters + /// like Source == "DecisionEngine" uniform across event-type and audit-trail. + /// + public const string DecisionSource = "DecisionEngine"; + + /// + /// Build the audit-trail payload. All optional parameters default to off — the + /// resulting dictionary always contains the mandatory anchors (decisionSource, + /// trigger, sessionStage, stepIndex, signalsSeen, + /// signalEvidence, signalTimestamps, scenario) and adds the + /// classifier / failure / context fields only when the corresponding optional argument + /// or state fact is present. + /// + /// State produced by the reducer step (post-transition). + /// Decision outcome stage to report (often == postState.Stage). + /// The same string the reducer attached to the transition. + /// Optional human-readable failure reason — surfaced as reason when set. + /// Optional classifier verdict (id + level + score + reason + inputHash). + /// Optional classifier-input snapshot (e.g. ) — flattened to a dict via . + public static Dictionary Build( + DecisionState postState, + SessionStage decidedStage, + string trigger, + string? failureReason = null, + ClassifierVerdictInfo? classifier = null, + object? classifierInputs = null) + { + if (postState == null) throw new ArgumentNullException(nameof(postState)); + if (string.IsNullOrEmpty(trigger)) throw new ArgumentException("Trigger is mandatory.", nameof(trigger)); + + var data = new Dictionary(StringComparer.Ordinal) + { + ["decisionSource"] = DecisionSource, + ["trigger"] = trigger, + ["sessionStage"] = decidedStage.ToString(), + ["stepIndex"] = postState.StepIndex, + }; + + if (postState.LastAppliedSignalOrdinal >= 0) + { + data["signalOrdinal"] = postState.LastAppliedSignalOrdinal; + } + + // Signal census — both a flat list (cheap rendering / KQL `has` filter) and a + // structured evidence map (ordinal + UTC for the Inspector's "jump to signal"). + var (signalsSeen, signalEvidence, signalTimestamps) = BuildSignalCensus(postState); + data["signalsSeen"] = signalsSeen; + data["signalEvidence"] = signalEvidence; + // signalTimestamps mirrors the legacy V2-parity field name (FinalizingStageTests + // pre-existing assertion) — kept verbatim so audit-trail is a strict superset of + // what enrollment_complete already published. + data["signalTimestamps"] = signalTimestamps; + + // Scenario block — single source of truth for "what kind of enrollment was this". + data["scenario"] = BuildScenarioBlock(postState); + + if (classifier != null) + { + data["classifier"] = classifier.ToDictionary(); + } + + if (classifierInputs != null) + { + data["classifierInputs"] = FlattenClassifierInputs(classifierInputs); + } + + if (!string.IsNullOrEmpty(failureReason)) + { + data["reason"] = failureReason!; + } + + // Hello / IME context — present in the data only when actually known so the + // payload doesn't mis-suggest "Hello observed = false" when it's just absent. + if (postState.HelloOutcome != null) + { + data["helloOutcome"] = postState.HelloOutcome.Value; + } + if (postState.HelloPolicyEnabled != null) + { + data["helloPolicyEnabled"] = postState.HelloPolicyEnabled.Value; + } + if (postState.ImeMatchedPatternId != null) + { + data["imePatternMatchedPatternId"] = postState.ImeMatchedPatternId.Value; + } + + return data; + } + + /// + /// Shorthand to wrap a classifier-verdict signal payload (the parameter dict carried by + /// ) into a typed + /// . Returns null if the payload is missing + /// the mandatory classifier + level keys — caller treats null as "no + /// classifier section in the audit trail" rather than synthesising fake values. + /// + public static ClassifierVerdictInfo? VerdictFromSignalPayload(IReadOnlyDictionary? payload) + { + if (payload == null) return null; + if (!payload.TryGetValue("classifier", out var id) || string.IsNullOrEmpty(id)) return null; + if (!payload.TryGetValue("level", out var levelRaw) || string.IsNullOrEmpty(levelRaw)) return null; + + int score = 0; + if (payload.TryGetValue("score", out var scoreRaw)) + { + int.TryParse(scoreRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out score); + } + var reason = payload.TryGetValue("reason", out var r) ? r : id; + var inputHash = payload.TryGetValue("inputHash", out var h) ? h : string.Empty; + + return new ClassifierVerdictInfo(id, levelRaw, score, reason, inputHash); + } + + // --- helpers ------------------------------------------------------------------------ + + private static (List SignalsSeen, Dictionary SignalEvidence, Dictionary SignalTimestamps) BuildSignalCensus(DecisionState s) + { + var seen = new List(8); + var evidence = new Dictionary(StringComparer.Ordinal); + var timestamps = new Dictionary(StringComparer.Ordinal); + + // Flow signals (Classic / SelfDeploying paths). + if (s.HelloResolvedUtc != null) + { + seen.Add("hello_resolved"); + timestamps["helloResolved"] = FormatUtc(s.HelloResolvedUtc.Value); + evidence["helloResolved"] = TimestampedEvidence(s.HelloResolvedUtc); + } + if (s.DesktopArrivedUtc != null) + { + seen.Add("desktop_arrived"); + timestamps["desktopArrived"] = FormatUtc(s.DesktopArrivedUtc.Value); + evidence["desktopArrived"] = TimestampedEvidence(s.DesktopArrivedUtc); + } + if (s.EspFinalExitUtc != null) + { + seen.Add("esp_final_exit"); + timestamps["espFinalExit"] = FormatUtc(s.EspFinalExitUtc.Value); + evidence["espFinalExit"] = TimestampedEvidence(s.EspFinalExitUtc); + } + if (s.ImeMatchedPatternId != null) + { + seen.Add("ime_pattern_matched"); + evidence["imePatternMatched"] = OrdinalEvidence(s.ImeMatchedPatternId.SourceSignalOrdinal); + } + if (s.UserAadSignInCompleteUtc != null) + { + seen.Add("user_aad_sign_in_complete"); + timestamps["userAadSignInComplete"] = FormatUtc(s.UserAadSignInCompleteUtc.Value); + evidence["userAadSignInComplete"] = TimestampedEvidence(s.UserAadSignInCompleteUtc); + } + if (s.SystemRebootUtc != null) + { + seen.Add("system_reboot"); + timestamps["systemReboot"] = FormatUtc(s.SystemRebootUtc.Value); + evidence["systemReboot"] = TimestampedEvidence(s.SystemRebootUtc); + } + if (s.AccountSetupEnteredUtc != null) + { + seen.Add("account_setup_entered"); + timestamps["accountSetupEntered"] = FormatUtc(s.AccountSetupEnteredUtc.Value); + evidence["accountSetupEntered"] = TimestampedEvidence(s.AccountSetupEnteredUtc); + } + + // WhiteGlove Part-1 sealing signals (engine-internal observations, no UTC fact). + if (s.ScenarioObservations.ShellCoreWhiteGloveSuccessSeen?.Value == true) + { + seen.Add("shellcore_whiteglove_success"); + evidence["shellcoreWhiteGloveSuccess"] = OrdinalEvidence(s.ScenarioObservations.ShellCoreWhiteGloveSuccessSeen.SourceSignalOrdinal); + } + if (s.ScenarioObservations.WhiteGloveSealingPatternSeen?.Value == true) + { + seen.Add("whiteglove_sealing_pattern"); + evidence["whiteGloveSealingPattern"] = OrdinalEvidence(s.ScenarioObservations.WhiteGloveSealingPatternSeen.SourceSignalOrdinal); + } + if (s.ScenarioObservations.AadUserJoinWithUserObserved != null) + { + seen.Add(s.ScenarioObservations.AadUserJoinWithUserObserved.Value + ? "aad_user_joined_with_user" + : "aad_user_joined_device_only"); + evidence["aadUserJoinObserved"] = new Dictionary(StringComparer.Ordinal) + { + ["ordinal"] = s.ScenarioObservations.AadUserJoinWithUserObserved.SourceSignalOrdinal, + ["withUser"] = s.ScenarioObservations.AadUserJoinWithUserObserved.Value, + }; + } + + // WhiteGlove Part-2 facts. + if (s.HelloResolvedPart2Utc != null) + { + seen.Add("hello_resolved_part2"); + timestamps["helloResolvedPart2"] = FormatUtc(s.HelloResolvedPart2Utc.Value); + evidence["helloResolvedPart2"] = TimestampedEvidence(s.HelloResolvedPart2Utc); + } + if (s.DesktopArrivedPart2Utc != null) + { + seen.Add("desktop_arrived_part2"); + timestamps["desktopArrivedPart2"] = FormatUtc(s.DesktopArrivedPart2Utc.Value); + evidence["desktopArrivedPart2"] = TimestampedEvidence(s.DesktopArrivedPart2Utc); + } + if (s.AccountSetupCompletedPart2Utc != null) + { + seen.Add("account_setup_completed_part2"); + timestamps["accountSetupCompletedPart2"] = FormatUtc(s.AccountSetupCompletedPart2Utc.Value); + evidence["accountSetupCompletedPart2"] = TimestampedEvidence(s.AccountSetupCompletedPart2Utc); + } + + return (seen, evidence, timestamps); + } + + private static Dictionary BuildScenarioBlock(DecisionState s) + { + var block = new Dictionary(StringComparer.Ordinal); + if (s.ScenarioProfile != null) + { + block["mode"] = s.ScenarioProfile.Mode.ToString(); + block["joinMode"] = s.ScenarioProfile.JoinMode.ToString(); + block["espConfig"] = s.ScenarioProfile.EspConfig.ToString(); + block["preProvisioningSide"] = s.ScenarioProfile.PreProvisioningSide.ToString(); + block["confidence"] = s.ScenarioProfile.Confidence.ToString(); + } + if (s.CurrentEnrollmentPhase != null) + { + block["currentPhase"] = s.CurrentEnrollmentPhase.Value.ToString(); + } + return block; + } + + /// + /// Reflect the public properties of a classifier-input snapshot record into a flat + /// Dictionary<string, object>. Keeps the audit trail self-describing + /// without coupling to a specific snapshot type — works for both + /// and the future Part-2 snapshot. + /// Property names are camelCased so the JSON-on-the-wire matches the surrounding + /// audit-trail style. + /// + private static Dictionary FlattenClassifierInputs(object snapshot) + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (var prop in snapshot.GetType().GetProperties()) + { + if (!prop.CanRead) continue; + var value = prop.GetValue(snapshot); + if (value == null) continue; + var key = ToCamelCase(prop.Name); + if (value is DateTime dt) + { + result[key] = FormatUtc(dt); + } + else if (value is Enum) + { + result[key] = value.ToString()!; + } + else + { + result[key] = value; + } + } + return result; + } + + private static Dictionary TimestampedEvidence(SignalFact? fact) + { + // Caller guards null; this overload exists for the symmetric SignalFact + // case so we capture both the ordinal and the canonicalized UTC stamp in one go. + var dict = new Dictionary(StringComparer.Ordinal) + { + ["ordinal"] = fact!.SourceSignalOrdinal, + }; + if (fact.Value is DateTime dt) + { + dict["utc"] = FormatUtc(dt); + } + else if (fact.Value != null) + { + dict["value"] = fact.Value!; + } + return dict; + } + + private static Dictionary OrdinalEvidence(long ordinal) => + new Dictionary(StringComparer.Ordinal) + { + ["ordinal"] = ordinal, + }; + + private static string FormatUtc(DateTime utc) => + DateTime.SpecifyKind(utc, DateTimeKind.Utc) + .ToString("o", CultureInfo.InvariantCulture); + + private static string ToCamelCase(string s) + { + if (string.IsNullOrEmpty(s) || char.IsLower(s[0])) return s; + return char.ToLowerInvariant(s[0]) + s.Substring(1); + } + } + + /// + /// Immutable record of a classifier verdict consumed by + /// . Lifted out of the legacy parameter-bag + /// shape so callers cannot accidentally drop a key on the audit trail. + /// + internal sealed class ClassifierVerdictInfo + { + public ClassifierVerdictInfo(string id, string level, int score, string reason, string inputHash) + { + Id = id ?? throw new ArgumentNullException(nameof(id)); + Level = level ?? throw new ArgumentNullException(nameof(level)); + Score = score; + Reason = reason ?? string.Empty; + InputHash = inputHash ?? string.Empty; + } + + public string Id { get; } + public string Level { get; } + public int Score { get; } + public string Reason { get; } + public string InputHash { get; } + + public Dictionary ToDictionary() => + new Dictionary(StringComparer.Ordinal) + { + ["id"] = Id, + ["level"] = Level, + ["score"] = Score, + ["reason"] = Reason, + ["inputHash"] = InputHash, + }; + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs index 9c7da059..7713cb55 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs @@ -470,10 +470,11 @@ namespace AutopilotMonitor.DecisionCore.Engine /// deadline path and the SelfDeploying DeviceSetupProvisioningComplete path. Restores /// V1 backend-side audit-trail parity (882fef64 debrief follow-up): Parameters carry /// just eventType (legacy contract), and a structured - /// dictionary feeds EnrollmentEvent.Data with signalsSeen, - /// signalTimestamps, completionSource, completionTrigger, plus the - /// resolved Hello / scenario context. Lets backend consumers reconstruct "which signals - /// drove completion" without re-reading the agent's final-status.json. + /// dictionary built by feeds + /// EnrollmentEvent.Data with signalsSeen, signalEvidence, + /// signalTimestamps, decisionSource, trigger, plus the resolved + /// Hello / scenario context. Lets backend consumers reconstruct "which signals drove + /// completion" without re-reading the agent's final-status.json. /// /// Post-transition state (Stage=Completed, Outcome=EnrollmentComplete). /// @@ -490,73 +491,10 @@ namespace AutopilotMonitor.DecisionCore.Engine { ["eventType"] = "enrollment_complete", }, - typedPayload: BuildEnrollmentCompleteAuditTrail(state, completionTrigger)); + typedPayload: DecisionAuditTrailBuilder.Build( + postState: state, + decidedStage: state.Stage, + trigger: completionTrigger)); } - - private static Dictionary BuildEnrollmentCompleteAuditTrail( - DecisionState state, - string completionTrigger) - { - var signalsSeen = new List(8); - var signalTimestamps = new Dictionary(StringComparer.Ordinal); - - if (state.HelloResolvedUtc != null) - { - signalsSeen.Add("hello_resolved"); - signalTimestamps["helloResolved"] = FormatUtc(state.HelloResolvedUtc.Value); - } - if (state.DesktopArrivedUtc != null) - { - signalsSeen.Add("desktop_arrived"); - signalTimestamps["desktopArrived"] = FormatUtc(state.DesktopArrivedUtc.Value); - } - if (state.EspFinalExitUtc != null) - { - signalsSeen.Add("esp_final_exit"); - signalTimestamps["espFinalExit"] = FormatUtc(state.EspFinalExitUtc.Value); - } - if (state.ImeMatchedPatternId != null) - { - signalsSeen.Add("ime_pattern_matched"); - } - if (state.UserAadSignInCompleteUtc != null) - { - signalsSeen.Add("user_aad_sign_in_complete"); - signalTimestamps["userAadSignInComplete"] = FormatUtc(state.UserAadSignInCompleteUtc.Value); - } - - var data = new Dictionary(StringComparer.Ordinal) - { - ["completionSource"] = "DecisionEngine", - ["completionTrigger"] = completionTrigger, - ["signalsSeen"] = signalsSeen, - ["signalTimestamps"] = signalTimestamps, - ["sessionStage"] = state.Stage.ToString(), - }; - - if (state.HelloOutcome != null) - { - data["helloOutcome"] = state.HelloOutcome.Value; - } - if (state.HelloPolicyEnabled != null) - { - data["helloPolicyEnabled"] = state.HelloPolicyEnabled.Value; - } - if (state.ImeMatchedPatternId != null) - { - data["imePatternMatchedPatternId"] = state.ImeMatchedPatternId.Value; - } - if (state.ScenarioProfile != null) - { - data["enrollmentMode"] = state.ScenarioProfile.Mode.ToString(); - data["enrollmentJoinMode"] = state.ScenarioProfile.JoinMode.ToString(); - } - - return data; - } - - private static string FormatUtc(DateTime utc) => - DateTime.SpecifyKind(utc, DateTimeKind.Utc) - .ToString("o", System.Globalization.CultureInfo.InvariantCulture); } } diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs index 73183aeb..83295288 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs @@ -78,7 +78,12 @@ namespace AutopilotMonitor.DecisionCore.Engine { ["eventType"] = "enrollment_failed", ["reason"] = reason, - }), + }, + typedPayload: DecisionAuditTrailBuilder.Build( + postState: newState, + decidedStage: SessionStage.Failed, + trigger: nameof(DecisionSignalKind.EspTerminalFailure), + failureReason: reason)), }; return new DecisionStep(newState, transition, effects); diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs index 5ac2f316..c9dba3e7 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs @@ -153,7 +153,12 @@ namespace AutopilotMonitor.DecisionCore.Engine { ["eventType"] = "enrollment_failed", ["reason"] = reason, - }); + }, + typedPayload: DecisionAuditTrailBuilder.Build( + postState: newState, + decidedStage: SessionStage.Failed, + trigger: nameof(DecisionSignalKind.EffectInfrastructureFailure), + failureReason: reason)); var transition = BuildTakenTransition( before: state, diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.WhiteGlove.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.WhiteGlove.cs index 3ef7d79d..1258252c 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.WhiteGlove.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.WhiteGlove.cs @@ -132,11 +132,18 @@ namespace AutopilotMonitor.DecisionCore.Engine nextStepIndex: nextStep, trigger: $"ClassifierVerdictIssued:{classifier}:Confirmed"); + var verdictInfo = new ClassifierVerdictInfo(classifier, level.ToString(), score, reason, inputHash); var sealedEffects = new[] { new DecisionEffect( DecisionEffectKind.EmitEventTimelineEntry, - parameters: new Dictionary { ["eventType"] = "whiteglove_complete" }), + parameters: new Dictionary { ["eventType"] = "whiteglove_complete" }, + typedPayload: DecisionAuditTrailBuilder.Build( + postState: sealedState, + decidedStage: SessionStage.WhiteGloveSealed, + trigger: $"ClassifierVerdictIssued:{classifier}:Confirmed", + classifier: verdictInfo, + classifierInputs: BuildWhiteGloveSealingSnapshot(sealedState))), }; return new DecisionStep(sealedState, sealedTransition, sealedEffects); diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.WhiteGlovePart2.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.WhiteGlovePart2.cs index 454f0790..eb186ba4 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.WhiteGlovePart2.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.WhiteGlovePart2.cs @@ -48,6 +48,17 @@ namespace AutopilotMonitor.DecisionCore.Engine nextStepIndex: nextStep, trigger: "SessionRecovered:WhiteGloveSealed->AwaitingUserSignIn"); + var resumedTrigger = "SessionRecovered:WhiteGloveSealed->AwaitingUserSignIn"; + var resumedAudit = DecisionAuditTrailBuilder.Build( + postState: newState, + decidedStage: SessionStage.WhiteGloveAwaitingUserSignIn, + trigger: resumedTrigger); + // Augment with bridge-specific scalars so the resumedAt timestamp + the recovering + // collector's source label are preserved on the wire (kept on Parameters too for + // tests / consumers that read scalar keys). + resumedAudit["resumedAtUtc"] = signal.OccurredAtUtc.ToString("o", System.Globalization.CultureInfo.InvariantCulture); + resumedAudit["sourceOrigin"] = signal.SourceOrigin ?? string.Empty; + var effects = new[] { new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: safety), @@ -56,9 +67,10 @@ namespace AutopilotMonitor.DecisionCore.Engine parameters: new Dictionary { ["eventType"] = "whiteglove_resumed", - ["resumedAtUtc"] = signal.OccurredAtUtc.ToString("o"), + ["resumedAtUtc"] = signal.OccurredAtUtc.ToString("o", System.Globalization.CultureInfo.InvariantCulture), ["sourceOrigin"] = signal.SourceOrigin ?? string.Empty, - }), + }, + typedPayload: resumedAudit), }; return new DecisionStep(newState, transition, effects); @@ -140,7 +152,12 @@ namespace AutopilotMonitor.DecisionCore.Engine { ["eventType"] = "enrollment_failed", ["reason"] = "part2_user_absent", - }), + }, + typedPayload: DecisionAuditTrailBuilder.Build( + postState: newState, + decidedStage: SessionStage.Failed, + trigger: $"DeadlineFired:{DeadlineNames.WhiteGlovePart2Safety}", + failureReason: "part2_user_absent")), }; return new DecisionStep(newState, transition, effects); @@ -243,11 +260,19 @@ namespace AutopilotMonitor.DecisionCore.Engine nextStepIndex: nextStep, trigger: $"ClassifierVerdictIssued:{WhiteGlovePart2CompletionClassifier.ClassifierId}:Confirmed"); + var part2Verdict = new ClassifierVerdictInfo( + WhiteGlovePart2CompletionClassifier.ClassifierId, level.ToString(), score, reason, inputHash); var effects = new[] { new DecisionEffect( DecisionEffectKind.EmitEventTimelineEntry, - parameters: new Dictionary { ["eventType"] = "whiteglove_part2_complete" }), + parameters: new Dictionary { ["eventType"] = "whiteglove_part2_complete" }, + typedPayload: DecisionAuditTrailBuilder.Build( + postState: sealedState, + decidedStage: SessionStage.WhiteGloveCompletedPart2, + trigger: $"ClassifierVerdictIssued:{WhiteGlovePart2CompletionClassifier.ClassifierId}:Confirmed", + classifier: part2Verdict, + classifierInputs: BuildWhiteGlovePart2CompletionSnapshot(sealedState))), }; return new DecisionStep(sealedState, sealedTransition, effects);