Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionReducerVerificationFunction.cs
T
Oliver Kieselbach 8da8dc649d v2 codex-06: ReducerVerifier semantic replay — reducer-agreement CI gate
Closes Codex review follow-up #6. Until now
GetSessionReducerVerificationFunction only ran structural checks
(ordinal contiguity, step-index contiguity, ReducerVersion drift,
orphaned SignalOrdinalRef). That catches corruption but never verifies
the backend's reducer agrees with the agent-produced journal under the
same ReducerVersion — exactly the invariant the whole
(newState, transition, effects) architecture rests on.

Unblocked by Codex #1 phase 1: ReducerReplay.Replay exists in
DecisionCore, and the backend already references DecisionCore.

Flow:
  1. Skip preconditions: drift, ordinal gaps, step-index gaps, empty
     session, or head PayloadJson deserialization failure → record
     SemanticReplaySkipReason + optionally emit an Info 'replay_skipped'
     issue. Never throws.
  2. Deserialise each SignalRecord.PayloadJson via SignalSerializer.
  3. Fold through a transient DecisionEngine: state = Reduce(state, sig).
  4. Compare each produced transition against the stored one on
     (StepIndex, Trigger, FromStage, ToStage, Taken, DeadEndReason).
     Discrepancies surface as 'replay_divergence' issues (capped at
     20 individual entries; overflow message follows the same pattern
     as the structural gap reports).
  5. Compare replayed final state's Stage to the last stored
     transition's ToStage — mismatch surfaces as 'replay_final_stage_mismatch'
     at Error severity.

Report additions (backward-compatible):
  - SemanticReplayPerformed
  - SemanticReplaySkipReason
  - SemanticReplayFinalStageMatches
  - ReplayedFinalStage
  - TransitionDivergenceCount

Side change: GetSessionReducerVerificationFunction XML-doc no longer
claims "structural only" — now documents both layers and the skip
rules.

Tests (+7, ReducerVerifierSemanticReplayTests):
  - Skip on ReducerVersion drift.
  - Skip on signal-ordinal gap.
  - Skip on step-index gap.
  - Perfect agreement (reducer-seeded journal round-trips).
  - Divergence: tampered Trigger → one replay_divergence issue.
  - Divergence: tampered last-ToStage → replay_final_stage_mismatch Error.
  - Mid-stream deserialization failure tolerated without aborting.

Existing structural ReducerVerifierTests unchanged — their stub
PayloadJson='{}' deserialization failures now silently set
SemanticReplaySkipReason='deserialization_failure' without polluting
Issues, preserving the happy-path empty-issues assertion.

Suites green: DecisionCore 151/151, Agent.V2.Core 643/643,
Functions 734/734 (+7). Build 0 warnings.

Plan: .claude/plans/codex-06-reducer-verifier-semantic-replay.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:32:56 +02:00

120 lines
5.7 KiB
C#

using System.Net;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Sessions
{
/// <summary>
/// <c>GET /api/sessions/{sessionId}/reducer-verification</c> — admin/ops endpoint that
/// runs structural + semantic verification on a session's persisted SignalLog +
/// DecisionTransitions journal (Plan §M5). Not tenant-exposed: gated by
/// <c>GlobalAdminOnly</c> in the <c>EndpointAccessPolicyCatalog</c>.
/// <para>
/// <b>Structural checks</b> — always run: ordinal contiguity, step-index contiguity,
/// ReducerVersion drift, orphaned <c>SignalOrdinalRef</c>.
/// </para>
/// <para>
/// <b>Semantic replay</b> (Codex follow-up #6) — folds the persisted signal stream
/// through the live backend <c>DecisionEngine</c> via <c>SignalSerializer.Deserialize</c>
/// and compares the produced transitions to the stored journal on the semantic fields
/// (Trigger / FromStage / ToStage / Taken / DeadEndReason / StepIndex). Skipped
/// automatically when the stored ReducerVersion differs, the signal stream has gaps,
/// or deserialisation fails at the head — those are structural conditions where a
/// replay would not be meaningful.
/// </para>
/// </summary>
public class GetSessionReducerVerificationFunction
{
/// <summary>Hard caps — match the other M5 read endpoints.</summary>
internal const int MaxSignalsToLoad = 5000;
internal const int MaxTransitionsToLoad = 5000;
private readonly ILogger<GetSessionReducerVerificationFunction> _logger;
private readonly ISignalRepository _signalRepo;
private readonly IDecisionTransitionRepository _transitionRepo;
private readonly ISessionRepository _sessionRepo;
public GetSessionReducerVerificationFunction(
ILogger<GetSessionReducerVerificationFunction> logger,
ISignalRepository signalRepo,
IDecisionTransitionRepository transitionRepo,
ISessionRepository sessionRepo)
{
_logger = logger;
_signalRepo = signalRepo;
_transitionRepo = transitionRepo;
_sessionRepo = sessionRepo;
}
[Function("GetSessionReducerVerification")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "sessions/{sessionId}/reducer-verification")] HttpRequestData req,
string sessionId)
{
if (string.IsNullOrWhiteSpace(sessionId))
{
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
await badRequest.WriteAsJsonAsync(new { success = false, message = "SessionId is required" });
return badRequest;
}
var sessionPrefix = $"[Session: {sessionId.Substring(0, Math.Min(8, sessionId.Length))}]";
_logger.LogInformation("{Prefix} GetSessionReducerVerification", sessionPrefix);
try
{
var requestCtx = req.GetRequestContext();
var tenantIdForLoad = requestCtx.TargetTenantId;
var signals = await _signalRepo.QueryBySessionAsync(
tenantIdForLoad, sessionId, MaxSignalsToLoad);
var transitions = await _transitionRepo.QueryBySessionAsync(
tenantIdForLoad, sessionId, MaxTransitionsToLoad);
// Global Admin cross-tenant fallback — same pattern as the other session
// detail read endpoints. Resolve via SessionsIndex when the effective tenant
// has no data. (GlobalAdminOnly gate means we don't need to check IsGlobalAdmin
// again, but the SessionsIndex lookup is still the cleanest way to find the
// actual tenant for a loose sessionId.)
if (signals.Count == 0 && transitions.Count == 0)
{
var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
if (resolvedTenantId != null &&
!string.Equals(resolvedTenantId, tenantIdForLoad, StringComparison.OrdinalIgnoreCase))
{
signals = await _signalRepo.QueryBySessionAsync(
resolvedTenantId, sessionId, MaxSignalsToLoad);
transitions = await _transitionRepo.QueryBySessionAsync(
resolvedTenantId, sessionId, MaxTransitionsToLoad);
tenantIdForLoad = resolvedTenantId;
}
}
// Read the live reducer's version via a transient DecisionEngine instance.
// The engine has no heavy construction cost (no DI, no state) so this is
// cheaper than threading a singleton through.
var currentReducerVersion = new DecisionEngine().ReducerVersion;
var report = ReducerVerifier.Verify(
tenantIdForLoad, sessionId, signals, transitions, currentReducerVersion);
return await req.OkAsync(new
{
success = true,
truncated = signals.Count >= MaxSignalsToLoad || transitions.Count >= MaxTransitionsToLoad,
report,
});
}
catch (Exception ex)
{
return await req.InternalServerErrorAsync(_logger, ex, $"Reducer verification for session '{sessionId}'");
}
}
}
}