refactor(decisioncore): centralize completion-gate weaving (ARCH-F1)

Wave 4 / ARCH-F1. The cross-cutting "may the session complete now?" AND-gate
was hand-inlined at every terminal site across the Classic / Shared engine
partials — each with a near-identical RealmJoinGateOpen check plus its own
":RealmJoinGateClosed" defer block. That duplication was the origin of the
premature/duplicate enrollment_complete regressions (8b8d611d, 08c99638,
330f73f3) and meant a second completion gate (WDP v2 device-association
settle) would have to be woven into every site.

New DecisionEngine.Completion.cs introduces:
  * CompletionGate (name + IsOpen predicate) + an append-only s_completionGates
    collection (RealmJoin is the sole gate today) + FirstClosedCompletionGate.
  * CompleteThroughFinalizingOrDefer — the single router every "both
    prerequisites in -> finish via Finalizing" site now calls. All gates open
    -> TransitionToFinalizing; a gate closed -> the canonical defer step with
    the trigger tagged ":<GateName>Closed" (derived from the gate, not
    hardcoded).

The four identical Classic/Shared sites (HelloResolved, DesktopArrived normal
+ Hello-disabled fast-path, Hello-safety timeout) now route through the helper.
The SelfDeploying deadline path keeps its gate-specific deferral (it owns the
SelfDeployingDeferredCompletion flag, released by the RealmJoin handlers); the
RealmJoin release path completes directly because the completion gates read the
pre-resolution state where RJ is still closed.

No behavior change — proven by the replay-deterministic scenario tests passing
bit-identically (same terminal stages, outcomes, hashes). Adding a gate is now
one entry in s_completionGates. New forward-proof test locks the gate-name ->
trigger-suffix derivation.

DecisionCore.Tests: 359/359 (was 358 + 1 new). Full solution builds; V2.Core
engine integration green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-06-10 20:50:09 +02:00
parent c40e136660
commit 1126913d1c
7 changed files with 179 additions and 87 deletions
@@ -1 +1 @@
1041
1043
@@ -1 +1 @@
1439
1440
@@ -112,6 +112,25 @@ namespace AutopilotMonitor.DecisionCore.Tests
Assert.NotNull(step.NewState.DesktopArrivedUtc); // fact still recorded
}
[Fact]
public void Closed_gate_tags_the_deferred_transition_with_the_gate_name_suffix()
{
// ARCH-F1 forward-proof: the defer trigger suffix is derived from the closed gate's
// CompletionGate.Name (":<Name>Closed") inside CompleteThroughFinalizingOrDefer, not
// hardcoded at the call site. A new gate registered in s_completionGates inherits the
// same deferral shape with its own suffix — this test locks the derivation so a rename
// or a regression back to per-site hardcoding is caught.
var engine = new DecisionEngine();
var state = PrimeClassicAwaitingDesktop(engine);
state = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(5),
new Dictionary<string, string> { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "100" })).NewState;
var step = engine.Reduce(state, MakeSignal(6, DecisionSignalKind.DesktopArrived, T0.AddMinutes(6)));
Assert.True(step.Transition.Taken);
Assert.Equal(nameof(DecisionSignalKind.DesktopArrived) + ":RealmJoinGateClosed", step.Transition.Trigger);
}
[Fact]
public void RealmJoinResolved_after_hello_and_desktop_triggers_finalizing_via_classic_path()
{
@@ -227,40 +227,23 @@ namespace AutopilotMonitor.DecisionCore.Engine
// This lets the backend see phase_transition(FinalizingSetup) + enrollment_complete
// before IsTerminal() fires and EnrollmentTerminationHandler tears down the spool.
//
// RealmJoin gate: while RJ is detected and unresolved, the Finalizing transition
// is deferred. Hello+Desktop facts are still recorded; the next RealmJoinResolved
// or RealmJoinTimeout signal releases the deferred completion via
// Completion gates (ARCH-F1): while a gate (e.g. an active RealmJoin deployment) is
// closed the Finalizing transition is deferred. Hello+Desktop facts are still
// recorded; the gate's release handler re-attempts completion via
// CompleteIfDeferredOrBookkeep().
if (desktopAlreadyArrived && RealmJoinGateOpen(state))
if (desktopAlreadyArrived)
{
return TransitionToFinalizing(
return CompleteThroughFinalizingOrDefer(
state: state,
signal: signal,
preparedBuilder: builder,
nextStepIndex: nextStep,
trigger: nameof(DecisionSignalKind.HelloResolved),
extraLeadingEffects: helloSafetyCancelEffect != null
leadingEffects: helloSafetyCancelEffect != null
? new[] { helloSafetyCancelEffect }
: null);
}
if (desktopAlreadyArrived)
{
// RJ gate closed: defer Finalizing. Stay in the current stage; the RJ
// resolved/timeout handler will route through CompleteIfDeferredOrBookkeep().
var deferredState = builder.Build();
var deferredTransition = BuildTakenTransition(
before: state,
signal: signal,
toStage: state.Stage,
nextStepIndex: nextStep,
trigger: nameof(DecisionSignalKind.HelloResolved) + ":RealmJoinGateClosed");
var deferredEffects = helloSafetyCancelEffect != null
? new[] { helloSafetyCancelEffect }
: Array.Empty<DecisionEffect>();
return new DecisionStep(deferredState, deferredTransition, deferredEffects);
}
builder.WithStage(SessionStage.AwaitingDesktop);
var newState = builder.Build();
@@ -377,30 +360,15 @@ namespace AutopilotMonitor.DecisionCore.Engine
};
}
if (RealmJoinGateOpen(state))
{
return TransitionToFinalizing(
state: state,
signal: signal,
preparedBuilder: builder,
nextStepIndex: nextStep,
trigger: nameof(DecisionSignalKind.DesktopArrived) + ":HelloDisabledFastPath",
extraLeadingEffects: extraEffects);
}
// RJ gate closed: synthetic Hello + Desktop are recorded; defer Finalizing
// until the RJ resolved/timeout handler releases the gate.
var fastPathDeferredState = builder.Build();
var fastPathDeferredTransition = BuildTakenTransition(
before: state,
// Completion gates (ARCH-F1): synthetic Hello + Desktop are recorded; complete
// through Finalizing when all gates are open, else defer until a gate releases.
return CompleteThroughFinalizingOrDefer(
state: state,
signal: signal,
toStage: state.Stage,
preparedBuilder: builder,
nextStepIndex: nextStep,
trigger: nameof(DecisionSignalKind.DesktopArrived) + ":HelloDisabledFastPath:RealmJoinGateClosed");
var fastPathDeferredEffects = extraEffects != null
? extraEffects.ToArray()
: Array.Empty<DecisionEffect>();
return new DecisionStep(fastPathDeferredState, fastPathDeferredTransition, fastPathDeferredEffects);
trigger: nameof(DecisionSignalKind.DesktopArrived) + ":HelloDisabledFastPath",
leadingEffects: extraEffects);
}
// Plan §5 Fix 6: mirror of HandleHelloResolvedV1. When both prerequisites are in,
@@ -420,31 +388,17 @@ namespace AutopilotMonitor.DecisionCore.Engine
builder.CancelDeadline(DeadlineNames.HelloSafety);
}
if (RealmJoinGateOpen(state))
{
return TransitionToFinalizing(
state: state,
signal: signal,
preparedBuilder: builder,
nextStepIndex: nextStep,
trigger: nameof(DecisionSignalKind.DesktopArrived),
extraLeadingEffects: helloSafetyCancelEffect != null
? new[] { helloSafetyCancelEffect }
: null);
}
// RJ gate closed: Desktop fact is recorded; defer Finalizing.
var deferredState = builder.Build();
var deferredTransition = BuildTakenTransition(
before: state,
// Completion gates (ARCH-F1): Desktop fact recorded; complete through Finalizing
// when all gates are open, else defer.
return CompleteThroughFinalizingOrDefer(
state: state,
signal: signal,
toStage: state.Stage,
preparedBuilder: builder,
nextStepIndex: nextStep,
trigger: nameof(DecisionSignalKind.DesktopArrived) + ":RealmJoinGateClosed");
var deferredEffects = helloSafetyCancelEffect != null
? new[] { helloSafetyCancelEffect }
: Array.Empty<DecisionEffect>();
return new DecisionStep(deferredState, deferredTransition, deferredEffects);
trigger: nameof(DecisionSignalKind.DesktopArrived),
leadingEffects: helloSafetyCancelEffect != null
? new[] { helloSafetyCancelEffect }
: null);
}
// Desktop came first: keep current stage (AwaitingHello or EspAccountSetup) until
@@ -0,0 +1,125 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.DecisionCore.State;
namespace AutopilotMonitor.DecisionCore.Engine
{
// Completion-gate weaving (ARCH-F1). The cross-cutting "may the session complete now?"
// AND-gate used to be hand-inlined at every terminal site across the Classic / Shared /
// SelfDeploying / RealmJoin engine partials, each with its own near-identical
// RealmJoinGateOpen check + ":RealmJoinGateClosed" defer block. That duplication was the
// origin of the premature/duplicate enrollment_complete regressions (8b8d611d, 08c99638,
// 330f73f3). This partial centralizes the gate SET and the deferral shape so adding a new
// completion precondition (e.g. WDP v2 device-association settle) is one entry in
// s_completionGates rather than an edit at every completion site.
public sealed partial class DecisionEngine
{
/// <summary>
/// A cross-cutting completion precondition. Completion is deferred while any gate is
/// closed; the deferring transition is tagged <c>:&lt;Name&gt;Closed</c> until a later
/// signal re-opens the gate and a completion-release handler re-attempts.
/// </summary>
private readonly struct CompletionGate
{
public CompletionGate(string name, Func<DecisionState, bool> isOpen)
{
Name = name;
IsOpen = isOpen;
}
/// <summary>Stable gate identifier; forms the <c>:&lt;Name&gt;Closed</c> trigger suffix.</summary>
public string Name { get; }
/// <summary>Returns <c>true</c> when this precondition no longer blocks completion.</summary>
public Func<DecisionState, bool> IsOpen { get; }
}
/// <summary>
/// The ordered set of completion preconditions. <b>Append-only</b>: a new enrollment
/// scenario registers one gate here and every completion site that routes through
/// <see cref="CompleteThroughFinalizingOrDefer"/> inherits it — no per-site edits.
/// RealmJoin is the only gate today (the SelfDeploying deadline path additionally owns a
/// gate-specific deferral; see <c>HandleDeviceOnlyEspDetectionDeadlineFired</c>).
/// </summary>
private static readonly CompletionGate[] s_completionGates =
{
// An active RealmJoin deployment (detected, not yet resolved / timed-out) blocks
// completion until RealmJoinResolved (phase 110) or the 60-min hard timeout fires.
new CompletionGate("RealmJoinGate", RealmJoinGateOpen),
};
/// <summary>
/// Returns the <see cref="CompletionGate.Name"/> of the first closed completion gate, or
/// <c>null</c> when every gate is open (completion may proceed). Centralizes the
/// cross-cutting AND-gate (ARCH-F1).
/// </summary>
private static string? FirstClosedCompletionGate(DecisionState state)
{
foreach (var gate in s_completionGates)
{
if (!gate.IsOpen(state)) return gate.Name;
}
return null;
}
/// <summary>
/// Shared completion router for every "both prerequisites in → finish via Finalizing"
/// site (Classic Hello/Desktop handlers, the Hello-disabled fast-path, the Hello-safety
/// timeout, and the RealmJoin release path). When all completion gates are open this
/// routes through <see cref="TransitionToFinalizing"/>; when a gate is closed it emits
/// the canonical defer step — current stage unchanged, trigger tagged
/// <c>:&lt;Gate&gt;Closed</c>, the same <paramref name="leadingEffects"/> preserved, no
/// deadline armed — so a later gate-release handler re-attempts completion.
/// <para>
/// The caller has already populated <paramref name="preparedBuilder"/> with its
/// signal-specific facts (HelloResolvedUtc / DesktopArrivedUtc / Hello-safety cancel).
/// </para>
/// </summary>
private DecisionStep CompleteThroughFinalizingOrDefer(
DecisionState state,
DecisionSignal signal,
DecisionStateBuilder preparedBuilder,
int nextStepIndex,
string trigger,
IReadOnlyList<DecisionEffect>? leadingEffects = null)
{
var closedGate = FirstClosedCompletionGate(state);
if (closedGate == null)
{
return TransitionToFinalizing(
state: state,
signal: signal,
preparedBuilder: preparedBuilder,
nextStepIndex: nextStepIndex,
trigger: trigger,
extraLeadingEffects: leadingEffects);
}
// Gate closed: defer Finalizing. Stay in the current stage; the gate's resolved/
// timeout handler routes back through completion once it re-opens.
var deferredState = preparedBuilder.Build();
var deferredTransition = BuildTakenTransition(
before: state,
signal: signal,
toStage: state.Stage,
nextStepIndex: nextStepIndex,
trigger: trigger + ":" + closedGate + "Closed");
return new DecisionStep(deferredState, deferredTransition, MaterializeEffects(leadingEffects));
}
/// <summary>
/// Materialize an optional effect list into the array shape the <see cref="DecisionStep"/>
/// defer path expects. Returns the shared empty array when there is nothing to emit, and
/// passes a backing array through untouched to avoid a copy.
/// </summary>
private static DecisionEffect[] MaterializeEffects(IReadOnlyList<DecisionEffect>? effects)
{
if (effects == null || effects.Count == 0) return Array.Empty<DecisionEffect>();
if (effects is DecisionEffect[] arr) return arr;
var copy = new DecisionEffect[effects.Count];
for (var i = 0; i < effects.Count; i++) copy[i] = effects[i];
return copy;
}
}
}
@@ -412,6 +412,12 @@ namespace AutopilotMonitor.DecisionCore.Engine
if (classicReady)
{
// This IS the gate-release path: the RealmJoin gate has just opened (the caller
// wrote WithResolved / WithTimeoutOutcome into preparedBuilder), so complete
// directly. Note the completion gates read `state` (pre-resolution), where RJ is
// still closed — routing this through CompleteThroughFinalizingOrDefer would
// re-defer on the very gate we just released. A future second gate that must
// re-block here would re-check the *post* state (WDP-v2 follow-up, ARCH-F1).
var extra = leadingEffect != null ? new[] { leadingEffect } : null;
return TransitionToFinalizing(
state: state,
@@ -409,12 +409,12 @@ namespace AutopilotMonitor.DecisionCore.Engine
// synthetic-timeout terminal event shares the same grace window + phase-declaration
// pathway as the happy-path handlers. AwaitingDesktop path is unchanged.
//
// RealmJoin gate: while RJ is detected and unresolved, defer Finalizing. The
// synthetic Hello-timeout fact stays recorded; the RJ resolved/timeout handler
// routes through CompleteIfDeferredOrBookkeep when both gates are ready.
if (desktopAlreadyArrived && RealmJoinGateOpen(state))
// Completion gates (ARCH-F1): while a gate (e.g. an active RealmJoin deployment) is
// closed, defer Finalizing. The synthetic Hello-timeout fact stays recorded; the
// gate's release handler routes through CompleteIfDeferredOrBookkeep.
if (desktopAlreadyArrived)
{
return TransitionToFinalizing(
return CompleteThroughFinalizingOrDefer(
state: state,
signal: signal,
preparedBuilder: builder,
@@ -422,18 +422,6 @@ namespace AutopilotMonitor.DecisionCore.Engine
trigger: $"DeadlineFired:{DeadlineNames.HelloSafety}");
}
if (desktopAlreadyArrived)
{
var deferredState = builder.Build();
var deferredTransition = BuildTakenTransition(
before: state,
signal: signal,
toStage: state.Stage,
nextStepIndex: nextStep,
trigger: $"DeadlineFired:{DeadlineNames.HelloSafety}:RealmJoinGateClosed");
return new DecisionStep(deferredState, deferredTransition, Array.Empty<DecisionEffect>());
}
builder.WithStage(SessionStage.AwaitingDesktop);
var newState = builder.Build();