fix(decision-core): parked-tripwire dwell, IME evidence upgrade rule, advisory anchor fix (M1, M2, L5, L8)

Delta-review 2026-07-02 findings:
- M1: session_parked_without_deadline evaluated the parked predicate on the
  very step that produced the state, so the healthy Classic window
  (HelloResolved -> AwaitingDesktop, no deadline, desktop seconds away) fired
  a spurious Warning AND consumed the one-shot, masking a real variant-4 park
  later in the run. The tripwire now only arms a 10-min dwell timer; it fires
  (and consumes the one-shot) only if the session is still parked when the
  dwell elapses. Steps that clear the parked condition cancel the pending
  arm; parked pass-throughs do not re-base it. Processor is now IDisposable
  (timer), disposed by the orchestrator.
- L8 (rides along): the tripwire post moved off the ingress worker onto the
  timer thread — a full channel can no longer self-deadlock on it.
- M2: ImeUserSessionCompletedUtc was set-once, so an early defaultuser0
  completion permanently failed the AdvisoryCompletion conjunction and
  resolved genuinely-completed sessions as Failed. New rule: first
  post-AccountSetup observation wins (one upgrade of a pre-anchor stamp,
  then frozen). Pure function of (state, signal) — replay-safe.
- L5: HandleAdvisoryCompletionDeadlineFired`s esp-exit anchor is presence-only
  now; the old >= AccountSetupEntered comparison rejected fires whose arming
  exit carried a backfilled source timestamp, re-opening the dead-end.
  deadlineStillArmed remains the stray-fire guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-07-03 10:45:22 +02:00
parent 1eb13e30b2
commit 4af0adb3ed
6 changed files with 356 additions and 46 deletions
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
@@ -18,11 +19,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
/// itself via a one-shot <c>session_parked_without_deadline</c> Warning. The parked states
/// here are built artificially via the builder — production arming sites now prevent them,
/// which is exactly why the tripwire exists (it catches the unknown variant 4).
/// <para>
/// M1 (delta review 2026-07-02): the tripwire is dwell-based — a parked step only ARMS a
/// timer; the Warning fires (and consumes the one-shot) only if the session is still parked
/// when the dwell elapses. A healthy transient park (HelloResolved → AwaitingDesktop,
/// desktop arrives seconds later) must neither fire nor burn the one-shot.
/// </para>
/// </summary>
[Collection("SerialThreading")] // timer-driven assertions — serialise against other timing-sensitive suites
public sealed class DecisionStepProcessorParkedTripwireTests : IDisposable
{
private static DateTime At => new DateTime(2026, 6, 12, 10, 0, 0, DateTimeKind.Utc);
/// <summary>Short dwell so fire-path tests complete quickly; suppression tests wait ≥10× this.</summary>
private static readonly TimeSpan TestDwell = TimeSpan.FromMilliseconds(25);
private static readonly TimeSpan FireWait = TimeSpan.FromSeconds(5);
private static readonly TimeSpan SuppressWait = TimeSpan.FromMilliseconds(400);
private readonly TempDirectory _tmp = new TempDirectory();
private readonly AgentLogger _logger;
private readonly FakeJournalWriter _journal = new FakeJournalWriter();
@@ -31,23 +44,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
private readonly FakeQuarantineSink _quarantine = new FakeQuarantineSink();
private readonly FakeSignalIngressSink _sink = new FakeSignalIngressSink();
private readonly DecisionEngine _engine = new DecisionEngine();
private DecisionStepProcessor _sut;
public DecisionStepProcessorParkedTripwireTests()
{
_logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
}
public void Dispose() => _tmp.Dispose();
public void Dispose()
{
_sut?.Dispose();
_tmp.Dispose();
}
private DecisionStepProcessor Build(DecisionState initialState) =>
new DecisionStepProcessor(
private DecisionStepProcessor Build(DecisionState initialState, TimeSpan? dwell = null) =>
_sut = new DecisionStepProcessor(
initialState: initialState,
journal: _journal,
effectRunner: _effects,
snapshot: _snapshot,
quarantineSink: _quarantine,
logger: _logger,
informationalEvents: new InformationalEventPost(_sink, new FixedClock(At), _logger));
informationalEvents: new InformationalEventPost(_sink, new FixedClock(At), _logger),
parkedTripwireDwell: dwell ?? TestDwell);
/// <summary>
/// Baseline parked shape: AccountSetup entered, ESP exited finally AFTER that entry,
@@ -88,7 +107,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
return (step, signal);
}
private FakeSignalIngressSink.PostedSignal? FindTripwirePost()
private FakeSignalIngressSink.PostedSignal FindTripwirePost()
{
foreach (var posted in _sink.Posted)
{
@@ -117,28 +136,67 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
return count;
}
/// <summary>Blocks until at least one tripwire post landed (dwell elapsed) or times out.</summary>
private bool WaitForTripwire() =>
SpinWait.SpinUntil(() => CountTripwirePosts() >= 1, FireWait);
/// <summary>Proves absence: waits well past the dwell, then asserts no post arrived.</summary>
private void AssertNoTripwireAfterDwell()
{
Thread.Sleep(SuppressWait);
Assert.Equal(0, CountTripwirePosts());
}
// ===================================================================== Fires
[Fact]
public void Parked_state_fires_tripwire_exactly_once()
public void Parked_state_fires_tripwire_exactly_once_after_dwell()
{
var sut = Build(BuildParkedState());
var (s1, sig1) = ReduceInformational(sut, 10);
sut.ApplyStep(s1, sig1);
Assert.True(WaitForTripwire(), "tripwire did not fire within the wait budget");
// Further parked pass-throughs after the fire must not emit again (one-shot).
var (s2, sig2) = ReduceInformational(sut, 11);
sut.ApplyStep(s2, sig2);
Thread.Sleep(SuppressWait);
Assert.Equal(1, CountTripwirePosts());
var post = FindTripwirePost()!;
var post = FindTripwirePost();
Assert.Equal(DecisionSignalKind.InformationalEvent, post.Kind);
Assert.Equal(nameof(SessionStage.EspAccountSetup), post.Payload!["stage"]);
Assert.Equal(nameof(SessionStage.EspAccountSetup), post.Payload["stage"]);
Assert.Equal("Warning", post.Payload[SignalPayloadKeys.Severity]);
Assert.Equal("true", post.Payload[SignalPayloadKeys.ImmediateUpload]);
Assert.Contains("esp_final_exit", post.Payload["signalsSeen"]);
Assert.Contains("account_setup_entered", post.Payload["signalsSeen"]);
Assert.Equal(string.Empty, post.Payload["armedDeadlines"]);
Assert.True(post.Payload.ContainsKey("dwellSeconds"));
}
[Fact]
public void Parked_passthrough_steps_do_not_rebase_the_dwell()
{
// A truly parked session still produces telemetry pass-throughs; if each of them
// re-based the dwell the tripwire would never fire exactly when it matters most.
var sut = Build(BuildParkedState(), dwell: TimeSpan.FromMilliseconds(150));
var (s1, sig1) = ReduceInformational(sut, 10);
sut.ApplyStep(s1, sig1);
// Keep feeding parked pass-throughs faster than the dwell.
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
long ordinal = 11;
while (CountTripwirePosts() == 0 && DateTime.UtcNow < deadline)
{
var (s, sig) = ReduceInformational(sut, ordinal++);
sut.ApplyStep(s, sig);
Thread.Sleep(20);
}
Assert.Equal(1, CountTripwirePosts());
}
[Fact]
@@ -149,6 +207,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
var (step, signal) = ReduceInformational(sut, 10);
sut.ApplyStep(step, signal);
Assert.True(WaitForTripwire());
Assert.Equal(1, CountTripwirePosts());
}
@@ -162,24 +221,67 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
var (step, signal) = ReduceInformational(sut, 10);
sut.ApplyStep(step, signal);
Assert.Equal(1, CountTripwirePosts());
Assert.Equal(DeadlineNames.ClassifierTick, FindTripwirePost()!.Payload!["armedDeadlines"]);
Assert.True(WaitForTripwire());
Assert.Equal(DeadlineNames.ClassifierTick, FindTripwirePost().Payload["armedDeadlines"]);
}
[Fact]
public void AwaitingDesktop_parked_fires_with_stage_in_data()
{
// Deliberate design decision (plan PR1): AwaitingDesktop has no deadline today, so a
// parked AwaitingDesktop session IS visible via the tripwire. The stage field keeps
// it distinguishable in the field; a dedicated DesktopSafety deadline is a possible
// later iteration — NOT part of PR1.
// parked AwaitingDesktop session IS visible via the tripwire — but only after the
// dwell, so the healthy Hello→Desktop window stays silent (see the dwell tests).
var sut = Build(BuildParkedState(stage: SessionStage.AwaitingDesktop));
var (step, signal) = ReduceInformational(sut, 10);
sut.ApplyStep(step, signal);
Assert.True(WaitForTripwire());
Assert.Equal(nameof(SessionStage.AwaitingDesktop), FindTripwirePost().Payload["stage"]);
}
// ===================================================================== Dwell semantics
[Fact]
public void Transient_park_resolved_before_dwell_neither_fires_nor_burns_the_one_shot()
{
// The healthy Classic window: HelloResolved cancels HelloSafety → AwaitingDesktop
// with no deadline; DesktopArrived (here: a resolution deadline appearing) follows
// before the dwell elapses.
var sut = Build(BuildParkedState(stage: SessionStage.AwaitingDesktop), dwell: TimeSpan.FromMilliseconds(250));
var (s1, sig1) = ReduceInformational(sut, 10);
sut.ApplyStep(s1, sig1);
// Park resolves quickly: next step carries a resolution-capable deadline.
var resolvedState = BuildParkedState(
stage: SessionStage.AwaitingDesktop,
deadlines: Deadline(DeadlineNames.AdvisoryCompletion));
var (s2Template, sig2) = ReduceInformational(sut, 11);
sut.ApplyStep(new DecisionStep(resolvedState, s2Template.Transition, Array.Empty<DecisionEffect>()), sig2);
AssertNoTripwireAfterDwell(); // no false alarm
// A REAL park later in the same run must still be able to fire — the one-shot was
// not consumed by the transient window.
var parkedAgain = BuildParkedState(stage: SessionStage.AwaitingDesktop);
var (s3Template, sig3) = ReduceInformational(sut, 12);
sut.ApplyStep(new DecisionStep(parkedAgain, s3Template.Transition, Array.Empty<DecisionEffect>()), sig3);
Assert.True(WaitForTripwire(), "genuine park after a transient window must still fire");
Assert.Equal(1, CountTripwirePosts());
Assert.Equal(nameof(SessionStage.AwaitingDesktop), FindTripwirePost()!.Payload!["stage"]);
}
[Fact]
public void Dispose_cancels_a_pending_dwell()
{
var sut = Build(BuildParkedState());
var (step, signal) = ReduceInformational(sut, 10);
sut.ApplyStep(step, signal);
sut.Dispose();
AssertNoTripwireAfterDwell();
}
// ===================================================================== Suppressed
@@ -192,7 +294,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
var (step, signal) = ReduceInformational(sut, 10);
sut.ApplyStep(step, signal);
Assert.Equal(0, CountTripwirePosts());
AssertNoTripwireAfterDwell();
}
[Fact]
@@ -204,7 +306,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
var (step, signal) = ReduceInformational(sut, 10);
sut.ApplyStep(step, signal);
Assert.Equal(0, CountTripwirePosts());
AssertNoTripwireAfterDwell();
}
[Fact]
@@ -215,7 +317,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
var (step, signal) = ReduceInformational(sut, 10);
sut.ApplyStep(step, signal);
Assert.Equal(0, CountTripwirePosts());
AssertNoTripwireAfterDwell();
}
[Fact]
@@ -226,7 +328,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
var (step, signal) = ReduceInformational(sut, 10);
sut.ApplyStep(step, signal);
Assert.Equal(0, CountTripwirePosts());
AssertNoTripwireAfterDwell();
}
[Fact]
@@ -238,7 +340,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
var (step, signal) = ReduceInformational(sut, 10);
sut.ApplyStep(step, signal);
Assert.Equal(0, CountTripwirePosts());
AssertNoTripwireAfterDwell();
}
[Fact]
@@ -252,7 +354,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
var (step, signal) = ReduceInformational(sut, 10);
sut.ApplyStep(step, signal);
Assert.Equal(0, CountTripwirePosts());
AssertNoTripwireAfterDwell();
}
[Fact]
@@ -269,24 +371,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
var (step, signal) = ReduceInformational(sut, 10);
sut.ApplyStep(step, signal);
Assert.Equal(0, CountTripwirePosts());
AssertNoTripwireAfterDwell();
}
[Fact]
public void Without_informational_post_wired_parked_state_is_tolerated()
{
// Legacy ctor shape (no InformationalEventPost) — the tripwire is simply disabled.
var sut = new DecisionStepProcessor(
var sut = _sut = new DecisionStepProcessor(
initialState: BuildParkedState(),
journal: _journal,
effectRunner: _effects,
snapshot: _snapshot,
quarantineSink: _quarantine,
logger: _logger);
logger: _logger,
parkedTripwireDwell: TestDwell);
var (step, signal) = ReduceInformational(sut, 10);
sut.ApplyStep(step, signal);
Thread.Sleep(SuppressWait);
Assert.Empty(_sink.Posted);
}
@@ -45,11 +45,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
/// nächsten Reduce, ebenfalls unlockbar sicher.
/// </para>
/// </summary>
public sealed class DecisionStepProcessor : IDecisionStepProcessor
public sealed class DecisionStepProcessor : IDecisionStepProcessor, IDisposable
{
/// <summary>Default — 3 Failures hintereinander → Quarantine.</summary>
public const int DefaultQuarantineThreshold = 3;
/// <summary>
/// M1 (delta review 2026-07-02) — dwell before the parked tripwire fires. The parked
/// predicate is true during a HEALTHY transient window: Classic HelloResolved (or the
/// HelloSafety timeout) cancels the only armed deadline and moves to AwaitingDesktop
/// seconds-to-minutes before DesktopArrived; firing on the step that produces that state
/// is a false alarm AND consumes the one-shot, masking a real variant-4 park later in
/// the run. 10 min comfortably clears DesktopArrivalDetector's validation latency while
/// staying far below the 360-min max-lifetime watchdog.
/// </summary>
public static readonly TimeSpan DefaultParkedTripwireDwell = TimeSpan.FromMinutes(10);
/// <summary>
/// H1a (Wave 2) — the snapshot is a recovery CACHE, not the source of truth (SignalLog +
/// Journal are, both still fsync'd per step). Pure InformationalEvent pass-throughs (Stage
@@ -77,6 +88,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
private int _passThroughStepsSinceSnapshot; // H1a — pass-through steps skipped since last snapshot
private bool _parkedTripwireFired; // liveness PR1 — one-shot per agent run, no state-schema touch
// M1 — parked-tripwire dwell machinery. The timer thread is also what fixes L8: the
// tripwire post no longer runs on the ingress worker, so a full channel cannot
// self-deadlock on it. Guarded by _parkedLock because the callback races ApplyStep.
private readonly object _parkedLock = new object();
private readonly TimeSpan _parkedTripwireDwell;
private Timer? _parkedDwellTimer;
private DecisionSignal? _parkedCandidateSignal;
private bool _disposed;
public DecisionStepProcessor(
DecisionState initialState,
IJournalWriter journal,
@@ -87,7 +107,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
int quarantineThreshold = DefaultQuarantineThreshold,
Action<DecisionState>? onTerminalStageReached = null,
TelemetryTransitionEmitter? transitionEmitter = null,
InformationalEventPost? informationalEvents = null)
InformationalEventPost? informationalEvents = null,
TimeSpan? parkedTripwireDwell = null)
{
if (quarantineThreshold <= 0)
{
@@ -106,6 +127,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
_onTerminalStageReached = onTerminalStageReached;
_transitionEmitter = transitionEmitter;
_informationalEvents = informationalEvents;
_parkedTripwireDwell = parkedTripwireDwell ?? DefaultParkedTripwireDwell;
// If recovery loaded a state that already sits on a terminal stage (e.g. a crash
// after a success-path step but before Stop()), treat it as already-notified so we
@@ -278,18 +300,95 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
// arming sites; this invariant check is the tripwire for variant 4. Expected to
// NEVER fire in production — every occurrence is a bug report. Skipped on abort
// steps: the caller is about to synthesise a terminal signal anyway.
if (!_parkedTripwireFired
&& _informationalEvents != null
&& !effectResult.SessionMustAbort
&& IsParkedWithoutDeadline(_currentState))
//
// M1 (delta review 2026-07-02): the predicate is true during a HEALTHY transient
// window (HelloResolved → AwaitingDesktop, no deadline, desktop arrives seconds
// later), so a parked state only ARMS a dwell timer here; the tripwire fires —
// and consumes its one-shot — only if the session is STILL parked when the dwell
// elapses. Any step that leaves the parked condition cancels the pending arm.
if (_informationalEvents != null && !_parkedTripwireFired)
{
_parkedTripwireFired = true;
EmitParkedTripwire(_currentState, signal);
if (!effectResult.SessionMustAbort && IsParkedWithoutDeadline(_currentState))
ArmParkedDwell(signal);
else
CancelParkedDwell();
}
return effectResult;
}
/// <summary>
/// Arms the parked dwell timer if not already pending. Deliberately does NOT re-base an
/// already-armed timer: a truly parked session still produces InformationalEvent
/// pass-through steps (perf snapshots, IME telemetry), and re-basing on each of them
/// would push the dwell forever. The first parked step of an episode starts the clock;
/// only a step that clears the parked condition resets it.
/// </summary>
private void ArmParkedDwell(DecisionSignal signal)
{
lock (_parkedLock)
{
if (_disposed || _parkedTripwireFired || _parkedDwellTimer != null) return;
_parkedCandidateSignal = signal;
_parkedDwellTimer = new Timer(OnParkedDwellElapsed, null, _parkedTripwireDwell, Timeout.InfiniteTimeSpan);
}
}
private void CancelParkedDwell()
{
lock (_parkedLock)
{
if (_parkedDwellTimer == null) return;
_parkedDwellTimer.Dispose();
_parkedDwellTimer = null;
_parkedCandidateSignal = null;
}
}
private void OnParkedDwellElapsed(object? _)
{
try
{
DecisionState state;
DecisionSignal? armingSignal;
lock (_parkedLock)
{
if (_disposed || _parkedTripwireFired || _parkedDwellTimer == null) return;
_parkedDwellTimer.Dispose();
_parkedDwellTimer = null;
armingSignal = _parkedCandidateSignal;
_parkedCandidateSignal = null;
// Re-check against the LIVE state: a step that resolved the park in the last
// instant may not have reached CancelParkedDwell yet. _currentState is an
// immutable object written by the single ingress worker; the reference read
// is atomic.
state = _currentState;
if (armingSignal == null || !IsParkedWithoutDeadline(state)) return;
_parkedTripwireFired = true;
}
EmitParkedTripwire(state, armingSignal);
}
catch (Exception ex)
{
// Observability must never break anything — and this runs on a timer thread.
_logger.Error("DecisionStepProcessor: parked-dwell evaluation failed.", ex);
}
}
public void Dispose()
{
lock (_parkedLock)
{
if (_disposed) return;
_disposed = true;
_parkedDwellTimer?.Dispose();
_parkedDwellTimer = null;
_parkedCandidateSignal = null;
}
}
/// <summary>
/// Liveness invariant (plan PR1): a non-terminal session that has entered the
/// post-AccountSetup dead-end zone must always have a resolution-capable deadline armed.
@@ -327,7 +426,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
try
{
var census = DecisionStateSignalCensus.Build(state);
var data = new Dictionary<string, string>(capacity: 6, comparer: StringComparer.Ordinal)
var data = new Dictionary<string, string>(capacity: 7, comparer: StringComparer.Ordinal)
{
["stage"] = state.Stage.ToString(),
["stepIndex"] = state.StepIndex.ToString(CultureInfo.InvariantCulture),
@@ -336,13 +435,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
.ToString("o", CultureInfo.InvariantCulture),
["signalsSeen"] = string.Join(",", census.SignalsSeen),
["armedDeadlines"] = string.Join(",", state.Deadlines.Select(d => d.Name)),
["dwellSeconds"] = _parkedTripwireDwell.TotalSeconds.ToString("F0", CultureInfo.InvariantCulture),
};
_informationalEvents!.Emit(
eventType: Constants.EventTypes.SessionParkedWithoutDeadline,
source: "DecisionStepProcessor",
message: $"Session is parked without a resolution-capable deadline at stage {state.Stage} — " +
"no signal is guaranteed to ever re-ask the completion question.",
message: $"Session has been parked without a resolution-capable deadline at stage {state.Stage} " +
$"for {_parkedTripwireDwell.TotalMinutes:F0} min — no signal is guaranteed to ever " +
"re-ask the completion question.",
severity: EventSeverity.Warning,
immediateUpload: true,
data: data);
@@ -974,6 +974,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
try { _transport?.Dispose(); } catch { /* best-effort */ }
try { _ingress?.Dispose(); } catch { /* best-effort */ }
try { _drainCts?.Dispose(); } catch { /* best-effort */ }
try { _processor?.Dispose(); } catch { /* best-effort — parked-dwell timer */ }
_logger.Info("EnrollmentOrchestrator: stopped.");
}
@@ -345,10 +345,10 @@ namespace AutopilotMonitor.DecisionCore.Tests
[Fact]
public void DeadlineFired_WithoutAnyAnchor_DeadEnds()
{
// No advisory recorded AND the only esp_exiting was the intermediate Device→Account
// handoff (before AccountSetup entry) — neither arming anchor exists.
// Neither anchor fact exists: no advisory recorded and no esp_exiting ever observed.
var engine = new DecisionEngine();
var state = SetupAdvisoryEligibleSession(engine);
var state = DecisionState.CreateInitial("sess-noanchor", "tenant-noanchor", T0);
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
var step = engine.Reduce(state, DeadlineFired(70, T0.AddMinutes(57.5), DeadlineNames.AdvisoryCompletion));
@@ -358,6 +358,44 @@ namespace AutopilotMonitor.DecisionCore.Tests
Assert.Equal(state.Stage, step.NewState.Stage);
}
[Fact]
public void DeadlineFired_IntermediateExitOnly_DeadEndsAsStaleNotArmed()
{
// L5 (delta review 2026-07-02): the anchor is presence-only now — the intermediate
// pre-AccountSetup exit plus AccountSetup entry passes the anchor check, and the
// UNARMED window is what dead-ends this stray fire.
var engine = new DecisionEngine();
var state = SetupAdvisoryEligibleSession(engine);
var step = engine.Reduce(state, DeadlineFired(70, T0.AddMinutes(57.5), DeadlineNames.AdvisoryCompletion));
Assert.False(step.Transition.Taken);
Assert.Equal("advisory_completion_stale_deadline_not_armed", step.Transition.DeadEndReason);
Assert.Empty(step.Effects);
Assert.Equal(state.Stage, step.NewState.Stage);
}
[Fact]
public void DeadlineFired_EspExitVariant_BackdatedExitTimestamp_StillResolves()
{
// L5 (delta review 2026-07-02): a guard-blocked post-AccountSetup exit whose SOURCE
// timestamp predates AccountSetup entry (replayed CMTrace content / clamped clock)
// arms the window; the fire must resolve the session, not dead-end on
// advisory_completion_without_anchor (which re-opened the idle-until-max-lifetime
// dead-end this deadline exists to close).
var engine = new DecisionEngine();
var state = SetupAdvisoryEligibleSession(engine); // desktop + post-anchor IME evidence
var armStep = engine.Reduce(state, MakeSignal(50, DecisionSignalKind.EspExiting, T0.AddMinutes(16)));
Assert.NotNull(FindDeadline(armStep.NewState, DeadlineNames.AdvisoryCompletion));
Assert.True(armStep.NewState.EspFinalExitUtc!.Value < armStep.NewState.AccountSetupEnteredUtc!.Value);
var step = engine.Reduce(armStep.NewState, DeadlineFired(70, T0.AddMinutes(46), DeadlineNames.AdvisoryCompletion));
Assert.True(step.Transition.Taken);
Assert.Equal(SessionStage.Finalizing, step.NewState.Stage);
}
[Fact]
public void DeadlineFired_AfterNormalCompletion_DeadEndsViaPostTerminalGuard()
{
@@ -580,7 +618,8 @@ namespace AutopilotMonitor.DecisionCore.Tests
Assert.Equal(T0.AddMinutes(5), state.ImeUserSessionCompletedUtc!.Value);
Assert.Equal(10, state.ImeUserSessionCompletedUtc!.SourceSignalOrdinal);
// A replayed second observation keeps the original anchor.
// A replayed second observation keeps the original anchor (no AccountSetup entry
// recorded — the post-anchor upgrade rule cannot apply).
state = engine.Reduce(state, MakeSignal(
20, DecisionSignalKind.ImeUserSessionCompleted, T0.AddMinutes(9),
new Dictionary<string, string> { [SignalPayloadKeys.ImePatternId] = "IME-USER-SESSION-COMPLETED" })).NewState;
@@ -589,6 +628,50 @@ namespace AutopilotMonitor.DecisionCore.Tests
Assert.Equal(10, state.ImeUserSessionCompletedUtc!.SourceSignalOrdinal);
}
[Fact]
public void ImeUserSessionCompleted_PreAnchorGhost_UpgradedByFirstPostAnchorObservation()
{
// M2 (delta review 2026-07-02): a defaultuser0 completion observed BEFORE
// AccountSetup entry must not permanently poison the conjunction — the first
// at-or-after-anchor observation upgrades the fact, then it freezes.
var engine = new DecisionEngine();
var state = SetupAdvisoryEligibleSession(engine, imeUserSessionCompletedAt: T0.AddMinutes(10));
Assert.Equal(T0.AddMinutes(10), state.ImeUserSessionCompletedUtc!.Value); // ghost recorded
// Real user's IME session completes post-AccountSetup (+17) → upgrade.
state = engine.Reduce(state, MakeSignal(
45, DecisionSignalKind.ImeUserSessionCompleted, T0.AddMinutes(27),
new Dictionary<string, string> { [SignalPayloadKeys.ImePatternId] = "IME-USER-SESSION-COMPLETED" })).NewState;
Assert.Equal(T0.AddMinutes(27), state.ImeUserSessionCompletedUtc!.Value);
Assert.Equal(45, state.ImeUserSessionCompletedUtc!.SourceSignalOrdinal);
// Frozen after the upgrade: a third observation does not re-base the genuine stamp.
state = engine.Reduce(state, MakeSignal(
48, DecisionSignalKind.ImeUserSessionCompleted, T0.AddMinutes(29),
new Dictionary<string, string> { [SignalPayloadKeys.ImePatternId] = "IME-USER-SESSION-COMPLETED" })).NewState;
Assert.Equal(T0.AddMinutes(27), state.ImeUserSessionCompletedUtc!.Value);
Assert.Equal(45, state.ImeUserSessionCompletedUtc!.SourceSignalOrdinal);
}
[Fact]
public void DeadlineFired_GhostThenRealImeCompletion_CompletesThroughFinalizing()
{
// End-to-end M2: ghost pre-anchor completion, then the real user's post-anchor
// completion — the AdvisoryCompletion conjunction must hold and the session must
// complete instead of resolving Failed on poisoned evidence.
var engine = new DecisionEngine();
var state = SetupAdvisoryEligibleSession(engine, imeUserSessionCompletedAt: T0.AddMinutes(10));
state = engine.Reduce(state, MakeSignal(
45, DecisionSignalKind.ImeUserSessionCompleted, T0.AddMinutes(27),
new Dictionary<string, string> { [SignalPayloadKeys.ImePatternId] = "IME-USER-SESSION-COMPLETED" })).NewState;
state = ApplyEspTerminalFailure(engine, state, ordinal: 50);
var step = engine.Reduce(state, DeadlineFired(70, T0.AddMinutes(57.5), DeadlineNames.AdvisoryCompletion));
Assert.Equal(SessionStage.Finalizing, step.NewState.Stage);
Assert.Equal("Skipped", step.NewState.HelloOutcome!.Value);
}
// ================================================== serialization compat ====
[Fact]
@@ -506,7 +506,8 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// is a strong UserDriven-v1 indicator), and records the matched pattern id.
/// <para>
/// Additionally records <see cref="DecisionState.ImeUserSessionCompletedUtc"/>
/// (set-once — the first observation wins, replays keep the original anchor). The fact
/// (first post-AccountSetup observation wins — a pre-AccountSetup defaultuser0 stamp is
/// upgraded once by the first at-or-after-anchor observation, then frozen). The fact
/// is NOT a completion trigger here: the IME "user session" can run under
/// <c>defaultuser0</c>, so the raw signal proves nothing about the real user. The
/// <c>AdvisoryCompletion</c> deadline handler consumes it lazily inside a correlation
@@ -529,7 +530,22 @@ namespace AutopilotMonitor.DecisionCore.Engine
builder.ImeMatchedPatternId = new SignalFact<string>(patternId!, signal.SessionSignalOrdinal);
}
if (state.ImeUserSessionCompletedUtc == null)
// Recording rule: first POST-AccountSetup observation wins (M2, delta review
// 2026-07-02). Plain set-once let an early defaultuser0 completion (pre-AccountSetup
// frame) permanently poison the AdvisoryCompletion conjunction — the real user's
// later completion was skipped, `imeUserSessionGenuine` could never hold, and a
// session with genuine evidence resolved Failed. Upgrade exactly once: a pre-anchor
// stamp is replaced by the first at-or-after-anchor observation; a genuine stamp is
// never downgraded or re-based. Pure function of (state, signal) — replay-safe.
var recordImeUserSession = state.ImeUserSessionCompletedUtc == null;
if (!recordImeUserSession
&& state.AccountSetupEnteredUtc != null
&& state.ImeUserSessionCompletedUtc!.Value < state.AccountSetupEnteredUtc.Value
&& signal.OccurredAtUtc >= state.AccountSetupEnteredUtc.Value)
{
recordImeUserSession = true;
}
if (recordImeUserSession)
{
builder.ImeUserSessionCompletedUtc = new SignalFact<DateTime>(signal.OccurredAtUtc, signal.SessionSignalOrdinal);
}
@@ -356,12 +356,17 @@ namespace AutopilotMonitor.DecisionCore.Engine
var deadlineStillArmed = HasAdvisoryCompletionDeadline(state);
var hasAdvisoryAnchor = state.EspAdvisoryFailureRecordedUtc != null;
// The esp-exit arming site only runs for a final exit observed at-or-after
// AccountSetup entry; an intermediate Device→Account exit (recorded earlier than
// AccountSetupEnteredUtc) does not count as an anchor.
// Presence-only anchor (L5, delta review 2026-07-02). The esp-exit arming site only
// runs when AccountSetupEnteredUtc is already set, so both facts being present is
// the anchor; `deadlineStillArmed` below is what rejects stray timer fires. The old
// additional `EspFinalExitUtc >= AccountSetupEnteredUtc` comparison rejected fires
// whose arming exit carried a backfilled/out-of-order source timestamp — returning
// the session to the idle-until-max-lifetime dead-end this deadline exists to close.
// An intermediate Device→Account exit cannot slip in here: it happens BEFORE
// AccountSetup entry, never arms the window, and thus dead-ends on
// `advisory_completion_stale_deadline_not_armed`.
var hasEspExitAnchor = state.EspFinalExitUtc != null
&& state.AccountSetupEnteredUtc != null
&& state.EspFinalExitUtc.Value >= state.AccountSetupEnteredUtc.Value;
&& state.AccountSetupEnteredUtc != null;
string? staleReason = null;
if (!hasAdvisoryAnchor && !hasEspExitAnchor) staleReason = "advisory_completion_without_anchor";