mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Coverage for <see cref="DecisionAuditTrailBuilder"/> and the six DecisionEngine emission
|
||||
/// sites that previously published terminal / state-changing timeline events with empty
|
||||
/// or near-empty <c>EnrollmentEvent.Data</c>: <c>whiteglove_complete</c>,
|
||||
/// <c>whiteglove_resumed</c>, <c>whiteglove_part2_complete</c>, <c>enrollment_failed</c>
|
||||
/// (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.
|
||||
/// </summary>
|
||||
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<string, string>? 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<List<string>>(data["signalsSeen"]);
|
||||
Assert.IsType<Dictionary<string, object>>(data["signalEvidence"]);
|
||||
Assert.IsType<Dictionary<string, object>>(data["signalTimestamps"]);
|
||||
Assert.IsType<Dictionary<string, object>>(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<Dictionary<string, object>>(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<Dictionary<string, object>>(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<string, string>()));
|
||||
Assert.Null(DecisionAuditTrailBuilder.VerdictFromSignalPayload(
|
||||
new Dictionary<string, string> { ["classifier"] = "X" })); // no level
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerdictFromSignalPayload_parses_full_payload()
|
||||
{
|
||||
var v = DecisionAuditTrailBuilder.VerdictFromSignalPayload(
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["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<string, string>
|
||||
{
|
||||
["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<Dictionary<string, object>>(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<List<string>>(payload["signalsSeen"]);
|
||||
Assert.Contains("shellcore_whiteglove_success", signalsSeen);
|
||||
|
||||
var classifier = Assert.IsType<Dictionary<string, object>>(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<Dictionary<string, object>>(payload["classifierInputs"]);
|
||||
Assert.Equal(true, inputs["shellCoreWhiteGloveSuccessSeen"]);
|
||||
Assert.Equal(false, inputs["aadJoinedWithUser"]);
|
||||
|
||||
var scenario = Assert.IsType<Dictionary<string, object>>(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<Dictionary<string, object>>(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<string, string>
|
||||
{
|
||||
["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<Dictionary<string, object>>(effect.TypedPayload);
|
||||
|
||||
Assert.Equal($"ClassifierVerdictIssued:{WhiteGlovePart2CompletionClassifier.ClassifierId}:Confirmed", payload["trigger"]);
|
||||
Assert.Equal(nameof(SessionStage.WhiteGloveCompletedPart2), payload["sessionStage"]);
|
||||
|
||||
var signalsSeen = Assert.IsType<List<string>>(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<Dictionary<string, object>>(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<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.Deadline] = DeadlineNames.WhiteGlovePart2Safety,
|
||||
}))
|
||||
.Build();
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(
|
||||
30, DecisionSignalKind.DeadlineFired, T0.AddHours(24),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = DeadlineNames.WhiteGlovePart2Safety }));
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
|
||||
var effect = SingleTimelineEffect(step, "enrollment_failed");
|
||||
var payload = Assert.IsType<Dictionary<string, object>>(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<string, string> { ["reason"] = "policy_apply_timeout" }));
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
|
||||
var effect = SingleTimelineEffect(step, "enrollment_failed");
|
||||
var payload = Assert.IsType<Dictionary<string, object>>(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<string, string> { ["reason"] = "deadline_scheduler_offline" }));
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
|
||||
var effect = SingleTimelineEffect(step, "enrollment_failed");
|
||||
var payload = Assert.IsType<Dictionary<string, object>>(effect.TypedPayload);
|
||||
|
||||
Assert.Equal(nameof(DecisionSignalKind.EffectInfrastructureFailure), payload["trigger"]);
|
||||
Assert.Equal("deadline_scheduler_offline", payload["reason"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,8 +153,11 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
&& e.Parameters.TryGetValue("eventType", out var et) && et == "enrollment_complete");
|
||||
|
||||
var payload = Assert.IsType<Dictionary<string, object>>(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<List<string>>(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<Dictionary<string, object>>(payload["signalEvidence"]);
|
||||
var helloEvidence = Assert.IsType<Dictionary<string, object>>(evidence["helloResolved"]);
|
||||
Assert.True(helloEvidence.ContainsKey("ordinal"));
|
||||
Assert.True(helloEvidence.ContainsKey("utc"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Uniform structured audit-trail payload for terminal / state-changing
|
||||
/// <see cref="DecisionEffectKind.EmitEventTimelineEntry"/> effects emitted by the
|
||||
/// <see cref="DecisionEngine"/>. Replaces the per-emission-site ad-hoc payload shapes
|
||||
/// that previously left events like <c>whiteglove_complete</c> with empty
|
||||
/// <c>EnrollmentEvent.Data</c> on the wire.
|
||||
/// <para>
|
||||
/// The output is meant to be passed verbatim as <see cref="DecisionEffect.TypedPayload"/>;
|
||||
/// <see cref="Telemetry"/> emitters (V2 <c>EventTimelineEmitter</c>) hand the dictionary
|
||||
/// directly to <see cref="EnrollmentEvent.Data"/>. Field ordering uses
|
||||
/// <see cref="StringComparer.Ordinal"/> so on-disk Snapshot test outputs are stable.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Contract for callers:</b>
|
||||
/// <list type="bullet">
|
||||
/// <item><paramref name="postState"/> is the state AFTER the reduce step that emits the
|
||||
/// event. <c>StepIndex</c> and <c>LastAppliedSignalOrdinal</c> on it correspond to
|
||||
/// the emitting transition's StepIndex and the signal that drove the reduce.</item>
|
||||
/// <item><paramref name="decidedStage"/> is what the caller is committing to — usually
|
||||
/// equals <c>postState.Stage</c>; passed explicitly so the field reflects the
|
||||
/// decision's outcome even if the caller still routes through an intermediate
|
||||
/// stage.</item>
|
||||
/// <item><paramref name="trigger"/> mirrors <see cref="DecisionTransition.Trigger"/> so a
|
||||
/// consumer can correlate the audit-trail event with the journal transition without
|
||||
/// joining tables (e.g. <c>"DeadlineFired:FinalizingGrace"</c>,
|
||||
/// <c>"ClassifierVerdictIssued:WhiteGloveSealing:Confirmed"</c>).</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
internal static class DecisionAuditTrailBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Stable string the audit trail uses for the <c>decisionSource</c> field. Mirrors the
|
||||
/// V2 emitter default (<c>EventTimelineEmitter.SourceId</c>) — keeps backend filters
|
||||
/// like <c>Source == "DecisionEngine"</c> uniform across event-type and audit-trail.
|
||||
/// </summary>
|
||||
public const string DecisionSource = "DecisionEngine";
|
||||
|
||||
/// <summary>
|
||||
/// Build the audit-trail payload. All optional parameters default to off — the
|
||||
/// resulting dictionary always contains the mandatory anchors (<c>decisionSource</c>,
|
||||
/// <c>trigger</c>, <c>sessionStage</c>, <c>stepIndex</c>, <c>signalsSeen</c>,
|
||||
/// <c>signalEvidence</c>, <c>signalTimestamps</c>, <c>scenario</c>) and adds the
|
||||
/// classifier / failure / context fields only when the corresponding optional argument
|
||||
/// or state fact is present.
|
||||
/// </summary>
|
||||
/// <param name="postState">State produced by the reducer step (post-transition).</param>
|
||||
/// <param name="decidedStage">Decision outcome stage to report (often == <c>postState.Stage</c>).</param>
|
||||
/// <param name="trigger">The same <see cref="DecisionTransition.Trigger"/> string the reducer attached to the transition.</param>
|
||||
/// <param name="failureReason">Optional human-readable failure reason — surfaced as <c>reason</c> when set.</param>
|
||||
/// <param name="classifier">Optional classifier verdict (id + level + score + reason + inputHash).</param>
|
||||
/// <param name="classifierInputs">Optional classifier-input snapshot (e.g. <see cref="Classifiers.WhiteGloveSealingSnapshot"/>) — flattened to a dict via <see cref="FlattenClassifierInputs"/>.</param>
|
||||
public static Dictionary<string, object> 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<string, object>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shorthand to wrap a classifier-verdict signal payload (the parameter dict carried by
|
||||
/// <see cref="Signals.DecisionSignalKind.ClassifierVerdictIssued"/>) into a typed
|
||||
/// <see cref="ClassifierVerdictInfo"/>. Returns <c>null</c> if the payload is missing
|
||||
/// the mandatory <c>classifier</c> + <c>level</c> keys — caller treats null as "no
|
||||
/// classifier section in the audit trail" rather than synthesising fake values.
|
||||
/// </summary>
|
||||
public static ClassifierVerdictInfo? VerdictFromSignalPayload(IReadOnlyDictionary<string, string>? 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<string> SignalsSeen, Dictionary<string, object> SignalEvidence, Dictionary<string, object> SignalTimestamps) BuildSignalCensus(DecisionState s)
|
||||
{
|
||||
var seen = new List<string>(8);
|
||||
var evidence = new Dictionary<string, object>(StringComparer.Ordinal);
|
||||
var timestamps = new Dictionary<string, object>(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<string, object>(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<string, object> BuildScenarioBlock(DecisionState s)
|
||||
{
|
||||
var block = new Dictionary<string, object>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reflect the public properties of a classifier-input snapshot record into a flat
|
||||
/// <c>Dictionary<string, object></c>. Keeps the audit trail self-describing
|
||||
/// without coupling to a specific snapshot type — works for both
|
||||
/// <see cref="Classifiers.WhiteGloveSealingSnapshot"/> and the future Part-2 snapshot.
|
||||
/// Property names are camelCased so the JSON-on-the-wire matches the surrounding
|
||||
/// audit-trail style.
|
||||
/// </summary>
|
||||
private static Dictionary<string, object> FlattenClassifierInputs(object snapshot)
|
||||
{
|
||||
var result = new Dictionary<string, object>(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<string, object> TimestampedEvidence<T>(SignalFact<T>? fact)
|
||||
{
|
||||
// Caller guards null; this overload exists for the symmetric SignalFact<DateTime>
|
||||
// case so we capture both the ordinal and the canonicalized UTC stamp in one go.
|
||||
var dict = new Dictionary<string, object>(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<string, object> OrdinalEvidence(long ordinal) =>
|
||||
new Dictionary<string, object>(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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Immutable record of a classifier verdict consumed by
|
||||
/// <see cref="DecisionAuditTrailBuilder.Build"/>. Lifted out of the legacy parameter-bag
|
||||
/// shape so callers cannot accidentally drop a key on the audit trail.
|
||||
/// </summary>
|
||||
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<string, object> ToDictionary() =>
|
||||
new Dictionary<string, object>(StringComparer.Ordinal)
|
||||
{
|
||||
["id"] = Id,
|
||||
["level"] = Level,
|
||||
["score"] = Score,
|
||||
["reason"] = Reason,
|
||||
["inputHash"] = InputHash,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 <c>eventType</c> (legacy contract), and a structured <see cref="DecisionEffect.TypedPayload"/>
|
||||
/// dictionary feeds <c>EnrollmentEvent.Data</c> with <c>signalsSeen</c>,
|
||||
/// <c>signalTimestamps</c>, <c>completionSource</c>, <c>completionTrigger</c>, plus the
|
||||
/// resolved Hello / scenario context. Lets backend consumers reconstruct "which signals
|
||||
/// drove completion" without re-reading the agent's <c>final-status.json</c>.
|
||||
/// dictionary built by <see cref="DecisionAuditTrailBuilder"/> feeds
|
||||
/// <c>EnrollmentEvent.Data</c> with <c>signalsSeen</c>, <c>signalEvidence</c>,
|
||||
/// <c>signalTimestamps</c>, <c>decisionSource</c>, <c>trigger</c>, plus the resolved
|
||||
/// Hello / scenario context. Lets backend consumers reconstruct "which signals drove
|
||||
/// completion" without re-reading the agent's <c>final-status.json</c>.
|
||||
/// </summary>
|
||||
/// <param name="state">Post-transition state (Stage=Completed, Outcome=EnrollmentComplete).</param>
|
||||
/// <param name="completionTrigger">
|
||||
@@ -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<string, object> BuildEnrollmentCompleteAuditTrail(
|
||||
DecisionState state,
|
||||
string completionTrigger)
|
||||
{
|
||||
var signalsSeen = new List<string>(8);
|
||||
var signalTimestamps = new Dictionary<string, object>(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<string, object>(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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, string> { ["eventType"] = "whiteglove_complete" }),
|
||||
parameters: new Dictionary<string, string> { ["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);
|
||||
|
||||
@@ -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<string, string>
|
||||
{
|
||||
["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<string, string> { ["eventType"] = "whiteglove_part2_complete" }),
|
||||
parameters: new Dictionary<string, string> { ["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);
|
||||
|
||||
Reference in New Issue
Block a user