diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/DiagnosticSignalHandlerTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/DiagnosticSignalHandlerTests.cs
new file mode 100644
index 00000000..e91536c4
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/DiagnosticSignalHandlerTests.cs
@@ -0,0 +1,137 @@
+using System;
+using System.Collections.Generic;
+using AutopilotMonitor.DecisionCore.Engine;
+using AutopilotMonitor.DecisionCore.Signals;
+using AutopilotMonitor.DecisionCore.State;
+using Xunit;
+
+namespace AutopilotMonitor.DecisionCore.Tests
+{
+ ///
+ /// Plan §4.x M4.4.3 — and
+ /// are diagnostic signals that
+ /// previously fell through to HandleUnhandledSignal and produced
+ /// unhandled_signal_kind:* dead-ends in the journal. They are now first-class
+ /// neutral observations: bookkeeping advances, the transition is Taken, the stage
+ /// is unchanged, no effects, no hypothesis impact.
+ ///
+ public sealed class DiagnosticSignalHandlerTests
+ {
+ [Theory]
+ [InlineData(DecisionSignalKind.DeviceInfoCollected)]
+ [InlineData(DecisionSignalKind.AutopilotProfileRead)]
+ public void Diagnostic_signal_records_taken_transition_without_deadend(DecisionSignalKind kind)
+ {
+ var engine = new DecisionEngine();
+ var state = DecisionState.CreateInitial("s", "t");
+
+ var step = engine.Reduce(state, MakeSignal(kind, ordinal: 3));
+
+ Assert.True(step.Transition.Taken);
+ Assert.Null(step.Transition.DeadEndReason);
+ Assert.Equal(kind.ToString(), step.Transition.Trigger);
+ }
+
+ [Theory]
+ [InlineData(DecisionSignalKind.DeviceInfoCollected)]
+ [InlineData(DecisionSignalKind.AutopilotProfileRead)]
+ public void Diagnostic_signal_leaves_stage_and_hypotheses_unchanged(DecisionSignalKind kind)
+ {
+ var engine = new DecisionEngine();
+ var midFlight = DecisionState.CreateInitial("s", "t")
+ .ToBuilder()
+ .WithStage(SessionStage.EspAccountSetup)
+ .WithStepIndex(10)
+ .WithLastAppliedSignalOrdinal(9)
+ .Build();
+
+ var step = engine.Reduce(midFlight, MakeSignal(kind, ordinal: 10));
+
+ Assert.Equal(SessionStage.EspAccountSetup, step.NewState.Stage);
+ Assert.Same(midFlight.EnrollmentType, step.NewState.EnrollmentType);
+ Assert.Same(midFlight.WhiteGloveSealing, step.NewState.WhiteGloveSealing);
+ Assert.Same(midFlight.DeviceOnlyDeployment, step.NewState.DeviceOnlyDeployment);
+ }
+
+ [Theory]
+ [InlineData(DecisionSignalKind.DeviceInfoCollected)]
+ [InlineData(DecisionSignalKind.AutopilotProfileRead)]
+ public void Diagnostic_signal_advances_bookkeeping(DecisionSignalKind kind)
+ {
+ var engine = new DecisionEngine();
+ var state = DecisionState.CreateInitial("s", "t")
+ .ToBuilder()
+ .WithStepIndex(7)
+ .WithLastAppliedSignalOrdinal(6)
+ .Build();
+
+ var step = engine.Reduce(state, MakeSignal(kind, ordinal: 7));
+
+ Assert.Equal(8, step.NewState.StepIndex);
+ Assert.Equal(7, step.NewState.LastAppliedSignalOrdinal);
+ }
+
+ [Theory]
+ [InlineData(DecisionSignalKind.DeviceInfoCollected)]
+ [InlineData(DecisionSignalKind.AutopilotProfileRead)]
+ public void Diagnostic_signal_emits_no_effects(DecisionSignalKind kind)
+ {
+ var engine = new DecisionEngine();
+ var state = DecisionState.CreateInitial("s", "t");
+
+ var step = engine.Reduce(state, MakeSignal(kind));
+
+ Assert.Empty(step.Effects);
+ }
+
+ [Theory]
+ [InlineData(DecisionSignalKind.DeviceInfoCollected)]
+ [InlineData(DecisionSignalKind.AutopilotProfileRead)]
+ public void Diagnostic_signal_does_not_cancel_active_deadlines(DecisionSignalKind kind)
+ {
+ var engine = new DecisionEngine();
+ var stateWithDeadline = DecisionState.CreateInitial("s", "t")
+ .ToBuilder()
+ .AddDeadline(new ActiveDeadline(
+ name: DeadlineNames.HelloSafety,
+ dueAtUtc: new DateTime(2026, 4, 20, 10, 5, 0, DateTimeKind.Utc),
+ firesSignalKind: DecisionSignalKind.DeadlineFired))
+ .Build();
+
+ var step = engine.Reduce(stateWithDeadline, MakeSignal(kind));
+
+ Assert.Single(step.NewState.Deadlines);
+ Assert.Equal(DeadlineNames.HelloSafety, step.NewState.Deadlines[0].Name);
+ }
+
+ [Fact]
+ public void Diagnostic_signals_at_higher_schema_version_fall_through_to_unhandled()
+ {
+ // §2.2 L.6 — dispatch is keyed on (Kind, SchemaVersion). If a collector ever emits
+ // a v2 variant before the reducer handles it, the kernel must dead-end (not
+ // silently record), so replay flags the version gap.
+ var engine = new DecisionEngine();
+ var state = DecisionState.CreateInitial("s", "t");
+
+ var step = engine.Reduce(state, MakeSignal(DecisionSignalKind.DeviceInfoCollected, schemaVersion: 2));
+
+ Assert.False(step.Transition.Taken);
+ Assert.Equal("unhandled_signal_kind:DeviceInfoCollected:v2", step.Transition.DeadEndReason);
+ }
+
+ private static DecisionSignal MakeSignal(
+ DecisionSignalKind kind,
+ long ordinal = 0,
+ int schemaVersion = 1,
+ IReadOnlyDictionary? payload = null) =>
+ new DecisionSignal(
+ sessionSignalOrdinal: ordinal,
+ sessionTraceOrdinal: ordinal,
+ kind: kind,
+ kindSchemaVersion: schemaVersion,
+ occurredAtUtc: new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc),
+ sourceOrigin: "test",
+ evidence: new Evidence(EvidenceKind.Raw, $"test:{kind}", "test"),
+ payload: payload);
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs
index f74e9b4e..ed3857e9 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs
@@ -235,6 +235,39 @@ namespace AutopilotMonitor.DecisionCore.Engine
return new DecisionStep(newState, transition, effects);
}
+ // ============================================================== diagnostic signals
+ // Plan §4.x M4.4.3 — close the reducer-handler gap for signals that carry useful
+ // telemetry but do NOT influence the state machine. Previously these fell through to
+ // HandleUnhandledSignal, which wrote a DeadEnd transition every time — noise in the
+ // journal. Now they record as neutral taken transitions; full payload/evidence
+ // remains in the SignalLog for Inspector analysis.
+
+ ///
+ /// Handle . Diagnostic-only —
+ /// carries hardware inventory in payload, does not drive stage or hypothesis.
+ ///
+ private DecisionStep HandleDeviceInfoCollectedV1(DecisionState state, DecisionSignal signal) =>
+ RecordDiagnosticObservation(state, signal, nameof(DecisionSignalKind.DeviceInfoCollected));
+
+ ///
+ /// Handle . Diagnostic-only —
+ /// carries Autopilot profile registry contents, does not drive stage or hypothesis.
+ ///
+ private DecisionStep HandleAutopilotProfileReadV1(DecisionState state, DecisionSignal signal) =>
+ RecordDiagnosticObservation(state, signal, nameof(DecisionSignalKind.AutopilotProfileRead));
+
+ private DecisionStep RecordDiagnosticObservation(DecisionState state, DecisionSignal signal, string trigger)
+ {
+ var newState = BumpStepBookkeeping(state, signal);
+ var transition = BuildTakenTransition(
+ before: state,
+ signal: signal,
+ toStage: state.Stage,
+ nextStepIndex: newState.StepIndex,
+ trigger: trigger);
+ return new DecisionStep(newState, transition, Array.Empty());
+ }
+
///
/// Determine the user-visible enrollment phase implied by an ESP phase-change signal.
/// Plan §2.3 phase-fact mapping. Populated in M3.1 as Classic handlers come online.
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs
index 6ddd9ba9..72c62d73 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs
@@ -88,7 +88,11 @@ namespace AutopilotMonitor.DecisionCore.Engine
(DecisionSignalKind.EspTerminalFailure, 1) => HandleEspTerminalFailureV1(state, signal),
(DecisionSignalKind.SystemRebootObserved, 1) => HandleSystemRebootObservedV1(state, signal),
- // ----- Still unwired (AppInstall, DeviceInfo, AutopilotProfileRead — v11.1+) -----
+ // ----- Diagnostic observations (DecisionEngine.Shared.cs) — Plan §4.x M4.4.3 -----
+ (DecisionSignalKind.DeviceInfoCollected, 1) => HandleDeviceInfoCollectedV1(state, signal),
+ (DecisionSignalKind.AutopilotProfileRead, 1) => HandleAutopilotProfileReadV1(state, signal),
+
+ // ----- Still unwired (AppInstall — v11.1+) -----
_ => HandleUnhandledSignal(state, signal),
};
}