diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/PostSessionStartedSignalTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/PostSessionStartedSignalTests.cs
new file mode 100644
index 00000000..30443fd9
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/PostSessionStartedSignalTests.cs
@@ -0,0 +1,125 @@
+using System.IO;
+using System.Reflection;
+using AutopilotMonitor.Agent.V2;
+using AutopilotMonitor.Agent.V2.Core.Configuration;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Agent.V2.Core.Security;
+using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
+using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
+using AutopilotMonitor.DecisionCore.Signals;
+using AutopilotMonitor.Shared.Models;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
+{
+ ///
+ /// Verifies that
+ /// emits the SessionStarted decision signal with the registration metadata payload the
+ /// reducer anchor needs. Plan §M4-V2-parity PR-C.
+ ///
+ public sealed class PostSessionStartedSignalTests
+ {
+ private static AgentLogger NewLogger(string path)
+ => new AgentLogger(Path.Combine(path, "logs"), AgentLogLevel.Info);
+
+ private static SessionRegistrationResult BuildSucceeded(ValidatorType validatedBy, string? adminAction = null)
+ {
+ var response = new RegisterSessionResponse
+ {
+ SessionId = "session-anon-1",
+ Success = true,
+ Message = "ok",
+ ValidatedBy = validatedBy,
+ AdminAction = adminAction,
+ };
+
+ // SessionRegistrationResult uses a private ctor + public static factories. Call
+ // Succeeded via reflection-free factory — it's public.
+ return SessionRegistrationResult.Succeeded(response);
+ }
+
+ private static AgentConfiguration BuildConfig(bool bootstrap)
+ {
+ return new AgentConfiguration
+ {
+ ApiBaseUrl = "https://example.invalid",
+ SessionId = "session-anon-1",
+ TenantId = "tenant-anon-1",
+ SpoolDirectory = Path.GetTempPath(),
+ LogDirectory = Path.GetTempPath(),
+ LogLevel = AgentLogLevel.Info,
+ UseClientCertAuth = !bootstrap,
+ UseBootstrapTokenAuth = bootstrap,
+ BootstrapToken = bootstrap ? "dummy" : null,
+ };
+ }
+
+ [Fact]
+ public void Posts_session_started_kind_with_default_schema_version()
+ {
+ using var tmp = new TempDirectory();
+ var sink = new FakeSignalIngressSink();
+ var logger = NewLogger(tmp.Path);
+ var result = BuildSucceeded(ValidatorType.AutopilotV1);
+
+ AutopilotMonitor.Agent.V2.Program.PostSessionStartedSignal(sink, result, BuildConfig(bootstrap: false), logger);
+
+ Assert.Single(sink.Posted);
+ var posted = sink.Posted[0];
+ Assert.Equal(DecisionSignalKind.SessionStarted, posted.Kind);
+ Assert.Equal(1, posted.KindSchemaVersion);
+ Assert.Equal("Program.RunAgent", posted.SourceOrigin);
+ Assert.Equal(EvidenceKind.Synthetic, posted.Evidence.Kind);
+ Assert.Equal("register_session_success", posted.Evidence.Identifier);
+ }
+
+ [Fact]
+ public void Payload_includes_registration_metadata_keys()
+ {
+ using var tmp = new TempDirectory();
+ var sink = new FakeSignalIngressSink();
+ var logger = NewLogger(tmp.Path);
+ var result = BuildSucceeded(ValidatorType.CorporateIdentifier);
+
+ AutopilotMonitor.Agent.V2.Program.PostSessionStartedSignal(sink, result, BuildConfig(bootstrap: true), logger);
+
+ var payload = sink.Posted[0].Payload;
+ Assert.NotNull(payload);
+ Assert.Equal("CorporateIdentifier", payload!["validatedBy"]);
+ Assert.Equal("true", payload["isBootstrapSession"]);
+ Assert.Contains("enrollmentType", (System.Collections.Generic.IDictionary)payload);
+ Assert.Contains("isHybridJoin", (System.Collections.Generic.IDictionary)payload);
+ Assert.Contains("agentVersion", (System.Collections.Generic.IDictionary)payload);
+ }
+
+ [Fact]
+ public void Swallows_sink_exceptions()
+ {
+ // The helper is called on the hot startup path — a misbehaving sink must not
+ // crash the agent.
+ using var tmp = new TempDirectory();
+ var sink = new ThrowingSink();
+ var logger = NewLogger(tmp.Path);
+ var result = BuildSucceeded(ValidatorType.Bootstrap);
+
+ var ex = Record.Exception(() =>
+ AutopilotMonitor.Agent.V2.Program.PostSessionStartedSignal(sink, result, BuildConfig(bootstrap: false), logger));
+
+ Assert.Null(ex);
+ }
+
+ private sealed class ThrowingSink : AutopilotMonitor.Agent.V2.Core.Orchestration.ISignalIngressSink
+ {
+ public void Post(
+ DecisionSignalKind kind,
+ System.DateTime occurredAtUtc,
+ string sourceOrigin,
+ Evidence evidence,
+ System.Collections.Generic.IReadOnlyDictionary? payload = null,
+ int kindSchemaVersion = 1)
+ {
+ throw new System.InvalidOperationException("ingress unavailable");
+ }
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs
index 567362bd..3c2cd019 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs
@@ -16,6 +16,7 @@ using AutopilotMonitor.Agent.V2.Core.Termination;
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
using AutopilotMonitor.DecisionCore.Classifiers;
using AutopilotMonitor.DecisionCore.Engine;
+using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.DecisionCore.State;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
@@ -712,6 +713,17 @@ namespace AutopilotMonitor.Agent.V2
EmitSystemRebootDetectedEvent(orchestrator, agentConfig, previousExit, logger);
}
+ // V2 parity — post SessionStarted so the reducer establishes the session anchor
+ // (HandleSessionStartedV1 in DecisionEngine.Shared.cs). Skipped on:
+ // - WhiteGlove Part-2 resume: EnrollmentOrchestrator.Start already posts
+ // SessionRecovered, which triggers HandleWhiteGlovePart1To2Bridge.
+ // - Admin preemption: the AdminPreemptionDetected signal below drives the
+ // session straight to a terminal stage; SessionStarted first would be noise.
+ if (!isWhiteGloveResume && string.IsNullOrEmpty(registrationResult.AdminAction))
+ {
+ PostSessionStartedSignal(orchestrator.IngressSink, registrationResult, agentConfig, logger);
+ }
+
// V1 parity (MonitoringService.cs:388-413 — the "ADMIN OVERRIDE on startup"
// block) — if the register-session response carried an AdminAction the
// session was already marked terminal by an operator via the portal. Emit
@@ -950,6 +962,49 @@ namespace AutopilotMonitor.Agent.V2
}
}
+ ///
+ /// V2 parity — post a signal with the
+ /// tenant-registered session metadata (EnrollmentType / IsHybridJoin / ValidatedBy) so the
+ /// reducer's session-anchor handler runs. Without this the DecisionState.Stage stays at
+ /// the initial value and subsequent raw signals (ESP / Hello) see an uninitialised session.
+ ///
+ internal static void PostSessionStartedSignal(
+ ISignalIngressSink ingressSink,
+ SessionRegistrationResult registrationResult,
+ AgentConfiguration agentConfig,
+ AgentLogger logger)
+ {
+ try
+ {
+ var payload = new System.Collections.Generic.Dictionary(StringComparer.Ordinal)
+ {
+ ["enrollmentType"] = EnrollmentRegistryDetector.DetectEnrollmentType(),
+ ["isHybridJoin"] = EnrollmentRegistryDetector.DetectHybridJoin() ? "true" : "false",
+ ["validatedBy"] = registrationResult.ValidatedBy.ToString(),
+ ["agentVersion"] = GetAgentVersion(),
+ ["isBootstrapSession"] = agentConfig.UseBootstrapTokenAuth ? "true" : "false",
+ };
+
+ var evidence = new Evidence(
+ kind: EvidenceKind.Synthetic,
+ identifier: "register_session_success",
+ summary: "Session registration handshake succeeded; posting SessionStarted anchor for reducer.");
+
+ ingressSink.Post(
+ kind: DecisionSignalKind.SessionStarted,
+ occurredAtUtc: DateTime.UtcNow,
+ sourceOrigin: "Program.RunAgent",
+ evidence: evidence,
+ payload: payload);
+
+ logger.Debug($"SessionStarted signal posted (validatedBy={registrationResult.ValidatedBy}, enrollmentType={payload["enrollmentType"]}).");
+ }
+ catch (Exception ex)
+ {
+ logger.Warning($"SessionStarted post failed: {ex.Message}");
+ }
+ }
+
///
/// V1 parity — fire-and-forget agent_started event emitted after
/// . Carries a snapshot of the boot classification
diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
index 29d6383b..257e5632 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
+++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
@@ -1 +1 @@
-100
+102
diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
index 7e179677..e255a855 100644
--- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
+++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
@@ -1 +1 @@
-1071
+1072