// SPDX-License-Identifier: MIT
// Copyright (c) 2026 AgentEval Contributors
using System.Text.Json;
using AgentEval.Guardrails;
using AgentEval.MAF.Gatekeeper;
using Microsoft.Agents.AI;
namespace AgentEval.Samples;
/// Offline, content-free timeline across call, run, session, and durable state boundaries.
public static class GatekeeperStatefulTimeline
{
private const string StableIdKey = "sample.logical-session";
public static async Task RunAsync()
{
GatekeeperSampleContractRenderer.Print("20");
Console.WriteLine("\n=== Gatekeeper — Stateful Gate Timeline (offline) ===\n");
PrintTimelineHeader();
await RunScopedBudgetTimelineAsync();
await SessionReloadTimelineAsync();
await DurableContainmentTimelineAsync();
Console.WriteLine("\n ✅ call/run/session/durable transitions matched their documented ownership and reset boundaries.");
}
private static async Task RunScopedBudgetTimelineAsync()
{
var gate = new RunBudgetGate(maxToolCalls: 1);
var session = new SampleSession();
ToolGateVerdict first;
ToolGateVerdict second;
using (AgentRunScope.Begin(session, "state-timeline", trace: null))
{
first = await gate.InspectAsync(Call("lookup"));
second = await gate.InspectAsync(Call("lookup"));
}
ToolGateVerdict nextRun;
using (AgentRunScope.Begin(session, "state-timeline", trace: null))
{
nextRun = await gate.InspectAsync(Call("lookup"));
}
Require(first.Action == ToolGateAction.Allow, "first call in a run must be admitted");
Require(second.Action == ToolGateAction.Block, "second call in the same run must exhaust the budget");
Require(nextRun.Action == ToolGateAction.Allow, "a new run must receive a new run ledger");
PrintTransition("1 run budget", "call #1: ALLOW", "call #2: BLOCK", "new run: ALLOW");
}
private static async Task SessionReloadTimelineAsync()
{
var clock = new SampleClock(DateTimeOffset.UnixEpoch);
var gate = new RateLimitGate(
maxRuns: 1,
window: TimeSpan.FromMinutes(1),
timeProvider: clock,
sessionKeySelector: StableId);
var original = Session("logical-42");
var reloaded = Session("logical-42");
GateVerdict first;
using (AgentRunScope.Begin(original, "state-timeline", trace: null))
{
first = await gate.InspectAsync("request");
}
GateVerdict sameLogicalSession;
using (AgentRunScope.Begin(reloaded, "state-timeline", trace: null))
{
sameLogicalSession = await gate.InspectAsync("request");
}
clock.Advance(TimeSpan.FromMinutes(2));
GateVerdict afterWindow;
using (AgentRunScope.Begin(reloaded, "state-timeline", trace: null))
{
afterWindow = await gate.InspectAsync("request");
}
Require(first.Action == GateAction.Allow, "first logical-session run must be admitted");
Require(sameLogicalSession.Action == GateAction.Block,
"a fresh session object with the same host-attested id must retain the rate counter");
Require(afterWindow.Action == GateAction.Allow, "the fixed rate window must reset after time advances");
PrintTransition("2 session rate", "run #1: ALLOW", "reload: BLOCK", "window: ALLOW");
}
private static async Task DurableContainmentTimelineAsync()
{
var directory = Path.Combine(
Path.GetTempPath(),
"agenteval-gatekeeper-state-" + Guid.NewGuid().ToString("N"));
var path = Path.Combine(directory, "containment.json");
var target = new ContainmentTarget.Session("sample-tenant", "logical-42");
var clock = new SampleClock(new DateTimeOffset(2026, 8, 4, 10, 0, 0, TimeSpan.Zero));
try
{
using (var store = new JsonFileContainmentStore(
path,
new SampleReleaseVerifier(),
new JsonFileContainmentStoreOptions { BootstrapIfMissing = true },
clock))
{
var result = await store.ContainAsync(
new ContainmentRequest(target, "sample_incident", "sample-evidence", "gatekeeper-sample"));
Require(result.Disposition == ContainmentMutationDisposition.Applied,
"containment must be durably applied");
Require(result.Snapshot.State == ContainmentSnapshotState.Active,
"applied containment must return an active snapshot");
}
using var reopened = new JsonFileContainmentStore(
path,
new SampleReleaseVerifier(),
timeProvider: clock);
Require(reopened.GetCurrent(target).State == ContainmentSnapshotState.Active,
"containment must survive store reopen");
var gate = new ContainedIdentityGate(reopened, _ => [target]);
GateVerdict admission;
using (AgentRunScope.Begin(new SampleSession(), "state-timeline", trace: null))
{
admission = await gate.InspectAsync("request");
}
Require(admission.Action == GateAction.Block,
"a reopened active containment record must refuse session admission");
PrintTransition("3 containment", "inactive", "persist: ACTIVE", "reopen: BLOCK");
}
finally
{
DeleteOwnedTemporaryDirectory(directory);
}
}
private static GatedToolCall Call(string name) =>
new(name, Arguments: null, AgentName: "state-timeline", Iteration: 0,
FunctionCallIndex: 0, FunctionCount: 1, IsStreaming: false, Messages: null);
private static SampleSession Session(string stableId)
{
var session = new SampleSession();
session.StateBag.SetValue(StableIdKey, stableId, JsonSerializerOptions.Default);
return session;
}
private static string? StableId(AgentSession session) =>
session.StateBag.TryGetValue(StableIdKey, out var id, JsonSerializerOptions.Default) ? id : null;
private static void PrintTimelineHeader()
{
Console.WriteLine(" Scope First observation Boundary observation After reset/reload");
Console.WriteLine(" ───────────────────── ─────────────────────── ─────────────────────── ───────────────────");
}
private static void PrintTransition(string scope, string before, string decision, string after) =>
Console.WriteLine($" {scope,-22} {before,-24} {decision,-24} {after}");
private static void DeleteOwnedTemporaryDirectory(string directory)
{
var full = Path.GetFullPath(directory);
var tempRoot = Path.GetFullPath(Path.GetTempPath());
if (!full.StartsWith(tempRoot, StringComparison.OrdinalIgnoreCase) ||
!Path.GetFileName(full).StartsWith("agenteval-gatekeeper-state-", StringComparison.Ordinal))
{
throw new InvalidOperationException("Refusing to delete a directory not owned by this sample.");
}
if (Directory.Exists(full))
{
Directory.Delete(full, recursive: true);
}
}
private static void Require(bool condition, string message)
{
if (!condition)
{
throw new InvalidOperationException("State timeline sample failed: " + message + ".");
}
}
private sealed class SampleClock(DateTimeOffset now) : TimeProvider
{
private DateTimeOffset _now = now;
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan duration) => _now += duration;
}
private sealed class SampleReleaseVerifier : IContainmentReleaseAuthorizationVerifier
{
public bool Verify(
ContainmentReleaseAuthorization authorization,
ReadOnlyMemory canonicalPayload) => false;
}
private sealed class SampleSession : AgentSession;
}