refactor(decision-core): EnrollmentScenarioObservations as record with with-expressions

The 8-parameter constructor plus 7 hand-threaded With* methods meant every new
observation field touched 8 copy sites - a threading-bug risk flagged in the
daily commit review (2026-07-14). Now a sealed record with init-only properties:
the With* methods keep the set-once guard but their bodies collapse to one-line
`this with { ... }` expressions, so the next field addition is one property plus
one With* method.

- IsExternalInit polyfill (internal) added: DecisionCore targets netstandard2.0,
  which lacks the C# 9 marker type for init setters. Consumers never need it -
  init/with usage stays inside the assembly.
- Snapshot compatibility unchanged: identical JSON shape (property names +
  SignalFact {Value, SourceSignalOrdinal}); the record deserializes via init
  setters instead of the removed constructor. Pinned by a new StateSerializer
  round-trip test covering facts, ordinals, and unset-stays-null.
- Semantics unchanged: set-once still returns `this` (Assert.Same tests hold);
  `==` is not used on the type anywhere, so record value equality is inert.

Verified: DecisionCore 468 tests, Agent V2 Core 1776 tests on net48 (the
consumer that mandated the netstandard2.0 target), Backend 2928 tests - all
passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-07-14 14:02:02 +02:00
parent 3a4d80cd7f
commit 8a824d73fc
3 changed files with 66 additions and 110 deletions
@@ -474,6 +474,43 @@ namespace AutopilotMonitor.DecisionCore.Tests
Assert.Equal(7, p.EvidenceOrdinal);
Assert.Equal("test_round_trip", p.Reason);
}
[Fact]
public void ScenarioObservations_stateRoundtripsThroughStateSerializer()
{
// Snapshot-compat pin for the record conversion (2026-07-14): observations were a
// ctor-based class before; the record deserializes via init setters instead. The
// persisted JSON shape (property names + SignalFact {Value, SourceSignalOrdinal})
// is identical, so on-disk snapshots written by either shape must rehydrate — this
// round-trip guards facts, ordinals, and unset-stays-null across that seam.
var initial = DecisionState.CreateInitial("s", "t");
var state = initial
.ToBuilder()
.Apply(b => b.ScenarioObservations = b.ScenarioObservations
.WithShellCoreWhiteGloveSuccessSeen(sourceSignalOrdinal: 3)
.WithSkipUserEsp(value: true, sourceSignalOrdinal: 4)
.WithEspSyncFailureTimeoutMinutes(value: 90, sourceSignalOrdinal: 5)
.WithRegistrySelfDeployingProfile(value: false, sourceSignalOrdinal: 6))
.Build();
var json = StateSerializer.Serialize(state);
var o = StateSerializer.Deserialize(json).ScenarioObservations;
Assert.True(o.ShellCoreWhiteGloveSuccessSeen!.Value);
Assert.Equal(3, o.ShellCoreWhiteGloveSuccessSeen!.SourceSignalOrdinal);
Assert.True(o.SkipUserEsp!.Value);
Assert.Equal(4, o.SkipUserEsp!.SourceSignalOrdinal);
Assert.Equal(90, o.EspSyncFailureTimeoutMinutes!.Value);
Assert.Equal(5, o.EspSyncFailureTimeoutMinutes!.SourceSignalOrdinal);
Assert.False(o.RegistrySelfDeployingProfile!.Value);
Assert.Equal(6, o.RegistrySelfDeployingProfile!.SourceSignalOrdinal);
// Never-observed facts must rehydrate as null (null ≠ veto — session 62e603c9).
Assert.Null(o.WhiteGloveSealingPatternSeen);
Assert.Null(o.AadUserJoinWithUserObserved);
Assert.Null(o.SkipDeviceEsp);
Assert.Null(o.EspAllowContinueAnyway);
}
}
internal static class DecisionStateBuilderTestExtensions
@@ -0,0 +1,11 @@
// netstandard2.0 polyfill: the C# 9 compiler requires this marker type to emit
// init-only property setters (records / `with`-expressions). It ships in the BCL
// from .NET 5 onward but not in netstandard2.0. Internal on purpose — consumers
// (Agent net48, Backend net8.0) never need it: init/`with` usage stays inside
// this assembly, and net8.0 consumers resolve the BCL type anyway.
namespace System.Runtime.CompilerServices
{
internal static class IsExternalInit
{
}
}
@@ -23,43 +23,15 @@ namespace AutopilotMonitor.DecisionCore.State
/// </list>
/// </para>
/// </summary>
public sealed class EnrollmentScenarioObservations
public sealed record EnrollmentScenarioObservations
{
public static readonly EnrollmentScenarioObservations Empty = new EnrollmentScenarioObservations(
shellCoreWhiteGloveSuccessSeen: null,
whiteGloveSealingPatternSeen: null,
aadUserJoinWithUserObserved: null,
skipUserEsp: null,
skipDeviceEsp: null,
espSyncFailureTimeoutMinutes: null,
espAllowContinueAnyway: null,
registrySelfDeployingProfile: null);
public EnrollmentScenarioObservations(
SignalFact<bool>? shellCoreWhiteGloveSuccessSeen,
SignalFact<bool>? whiteGloveSealingPatternSeen,
SignalFact<bool>? aadUserJoinWithUserObserved,
SignalFact<bool>? skipUserEsp,
SignalFact<bool>? skipDeviceEsp,
SignalFact<int>? espSyncFailureTimeoutMinutes,
SignalFact<bool>? espAllowContinueAnyway,
SignalFact<bool>? registrySelfDeployingProfile)
{
ShellCoreWhiteGloveSuccessSeen = shellCoreWhiteGloveSuccessSeen;
WhiteGloveSealingPatternSeen = whiteGloveSealingPatternSeen;
AadUserJoinWithUserObserved = aadUserJoinWithUserObserved;
SkipUserEsp = skipUserEsp;
SkipDeviceEsp = skipDeviceEsp;
EspSyncFailureTimeoutMinutes = espSyncFailureTimeoutMinutes;
EspAllowContinueAnyway = espAllowContinueAnyway;
RegistrySelfDeployingProfile = registrySelfDeployingProfile;
}
public static readonly EnrollmentScenarioObservations Empty = new EnrollmentScenarioObservations();
/// <summary>True once <see cref="Signals.DecisionSignalKind.WhiteGloveShellCoreSuccess"/> has fired.</summary>
public SignalFact<bool>? ShellCoreWhiteGloveSuccessSeen { get; }
public SignalFact<bool>? ShellCoreWhiteGloveSuccessSeen { get; init; }
/// <summary>True once <see cref="Signals.DecisionSignalKind.WhiteGloveSealingPatternDetected"/> has fired.</summary>
public SignalFact<bool>? WhiteGloveSealingPatternSeen { get; }
public SignalFact<bool>? WhiteGloveSealingPatternSeen { get; init; }
/// <summary>
/// Payload-carrying observation from <see cref="Signals.DecisionSignalKind.AadUserJoinedLate"/>.
@@ -68,13 +40,13 @@ namespace AutopilotMonitor.DecisionCore.State
/// Independent of <see cref="EnrollmentJoinMode"/>, which reflects the
/// <c>SessionStarted</c> registry hint.
/// </summary>
public SignalFact<bool>? AadUserJoinWithUserObserved { get; }
public SignalFact<bool>? AadUserJoinWithUserObserved { get; init; }
/// <summary>Raw payload half-fact from <see cref="Signals.DecisionSignalKind.EspConfigDetected"/>.</summary>
public SignalFact<bool>? SkipUserEsp { get; }
public SignalFact<bool>? SkipUserEsp { get; init; }
/// <summary>Raw payload half-fact from <see cref="Signals.DecisionSignalKind.EspConfigDetected"/>.</summary>
public SignalFact<bool>? SkipDeviceEsp { get; }
public SignalFact<bool>? SkipDeviceEsp { get; init; }
/// <summary>
/// FirstSync <c>SyncFailureTimeout</c> in minutes — Intune ESP setting
@@ -83,7 +55,7 @@ namespace AutopilotMonitor.DecisionCore.State
/// with the actual timeout instead of a generic "ESP timed out" string.
/// Set-once from <see cref="Signals.DecisionSignalKind.EspConfigDetected"/>.
/// </summary>
public SignalFact<int>? EspSyncFailureTimeoutMinutes { get; }
public SignalFact<int>? EspSyncFailureTimeoutMinutes { get; init; }
/// <summary>
/// Decoded bit 4 of the FirstSync <c>BlockInStatusPage</c> bitmask — Intune ESP
@@ -94,7 +66,7 @@ namespace AutopilotMonitor.DecisionCore.State
/// reaching the desktop. Set-once from
/// <see cref="Signals.DecisionSignalKind.EspConfigDetected"/>.
/// </summary>
public SignalFact<bool>? EspAllowContinueAnyway { get; }
public SignalFact<bool>? EspAllowContinueAnyway { get; init; }
/// <summary>
/// Raw registry fact from <see cref="Signals.DecisionSignalKind.EnrollmentFactsObserved"/>
@@ -106,110 +78,46 @@ namespace AutopilotMonitor.DecisionCore.State
/// behavioural <c>device_only_esp_detection</c> deadline (session 62e603c9). <c>null</c>
/// means the fact was never observed and must NOT be treated as a veto.
/// </summary>
public SignalFact<bool>? RegistrySelfDeployingProfile { get; }
public SignalFact<bool>? RegistrySelfDeployingProfile { get; init; }
public EnrollmentScenarioObservations WithShellCoreWhiteGloveSuccessSeen(long sourceSignalOrdinal) =>
ShellCoreWhiteGloveSuccessSeen != null
? this
: new EnrollmentScenarioObservations(
new SignalFact<bool>(true, sourceSignalOrdinal),
WhiteGloveSealingPatternSeen,
AadUserJoinWithUserObserved,
SkipUserEsp,
SkipDeviceEsp,
EspSyncFailureTimeoutMinutes,
EspAllowContinueAnyway,
RegistrySelfDeployingProfile);
: this with { ShellCoreWhiteGloveSuccessSeen = new SignalFact<bool>(true, sourceSignalOrdinal) };
public EnrollmentScenarioObservations WithWhiteGloveSealingPatternSeen(long sourceSignalOrdinal) =>
WhiteGloveSealingPatternSeen != null
? this
: new EnrollmentScenarioObservations(
ShellCoreWhiteGloveSuccessSeen,
new SignalFact<bool>(true, sourceSignalOrdinal),
AadUserJoinWithUserObserved,
SkipUserEsp,
SkipDeviceEsp,
EspSyncFailureTimeoutMinutes,
EspAllowContinueAnyway,
RegistrySelfDeployingProfile);
: this with { WhiteGloveSealingPatternSeen = new SignalFact<bool>(true, sourceSignalOrdinal) };
public EnrollmentScenarioObservations WithAadUserJoinWithUserObserved(bool value, long sourceSignalOrdinal) =>
AadUserJoinWithUserObserved != null
? this
: new EnrollmentScenarioObservations(
ShellCoreWhiteGloveSuccessSeen,
WhiteGloveSealingPatternSeen,
new SignalFact<bool>(value, sourceSignalOrdinal),
SkipUserEsp,
SkipDeviceEsp,
EspSyncFailureTimeoutMinutes,
EspAllowContinueAnyway,
RegistrySelfDeployingProfile);
: this with { AadUserJoinWithUserObserved = new SignalFact<bool>(value, sourceSignalOrdinal) };
public EnrollmentScenarioObservations WithSkipUserEsp(bool value, long sourceSignalOrdinal) =>
SkipUserEsp != null
? this
: new EnrollmentScenarioObservations(
ShellCoreWhiteGloveSuccessSeen,
WhiteGloveSealingPatternSeen,
AadUserJoinWithUserObserved,
new SignalFact<bool>(value, sourceSignalOrdinal),
SkipDeviceEsp,
EspSyncFailureTimeoutMinutes,
EspAllowContinueAnyway,
RegistrySelfDeployingProfile);
: this with { SkipUserEsp = new SignalFact<bool>(value, sourceSignalOrdinal) };
public EnrollmentScenarioObservations WithSkipDeviceEsp(bool value, long sourceSignalOrdinal) =>
SkipDeviceEsp != null
? this
: new EnrollmentScenarioObservations(
ShellCoreWhiteGloveSuccessSeen,
WhiteGloveSealingPatternSeen,
AadUserJoinWithUserObserved,
SkipUserEsp,
new SignalFact<bool>(value, sourceSignalOrdinal),
EspSyncFailureTimeoutMinutes,
EspAllowContinueAnyway,
RegistrySelfDeployingProfile);
: this with { SkipDeviceEsp = new SignalFact<bool>(value, sourceSignalOrdinal) };
public EnrollmentScenarioObservations WithEspSyncFailureTimeoutMinutes(int value, long sourceSignalOrdinal) =>
EspSyncFailureTimeoutMinutes != null
? this
: new EnrollmentScenarioObservations(
ShellCoreWhiteGloveSuccessSeen,
WhiteGloveSealingPatternSeen,
AadUserJoinWithUserObserved,
SkipUserEsp,
SkipDeviceEsp,
new SignalFact<int>(value, sourceSignalOrdinal),
EspAllowContinueAnyway,
RegistrySelfDeployingProfile);
: this with { EspSyncFailureTimeoutMinutes = new SignalFact<int>(value, sourceSignalOrdinal) };
public EnrollmentScenarioObservations WithEspAllowContinueAnyway(bool value, long sourceSignalOrdinal) =>
EspAllowContinueAnyway != null
? this
: new EnrollmentScenarioObservations(
ShellCoreWhiteGloveSuccessSeen,
WhiteGloveSealingPatternSeen,
AadUserJoinWithUserObserved,
SkipUserEsp,
SkipDeviceEsp,
EspSyncFailureTimeoutMinutes,
new SignalFact<bool>(value, sourceSignalOrdinal),
RegistrySelfDeployingProfile);
: this with { EspAllowContinueAnyway = new SignalFact<bool>(value, sourceSignalOrdinal) };
public EnrollmentScenarioObservations WithRegistrySelfDeployingProfile(bool value, long sourceSignalOrdinal) =>
RegistrySelfDeployingProfile != null
? this
: new EnrollmentScenarioObservations(
ShellCoreWhiteGloveSuccessSeen,
WhiteGloveSealingPatternSeen,
AadUserJoinWithUserObserved,
SkipUserEsp,
SkipDeviceEsp,
EspSyncFailureTimeoutMinutes,
EspAllowContinueAnyway,
new SignalFact<bool>(value, sourceSignalOrdinal));
: this with { RegistrySelfDeployingProfile = new SignalFact<bool>(value, sourceSignalOrdinal) };
}
}