mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
8da8dc649d
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>
120 lines
5.7 KiB
C#
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}'");
|
|
}
|
|
}
|
|
}
|
|
}
|