/** * Unified trace system for gates, loops, and tools. * * TraceRecord is the single source of truth for replayable execution history. * Each record links to a traceId generated at gate evaluation or loop start, * and carries the full event chain needed to reconstruct state. * * Trace IDs follow the format: * - Gate: "gate-{timestamp}-{hex6}" * - Loop: "loop-{timestamp}-{hex6}" * - Tool: "tool-{timestamp}-{hex6}" */ import type { GateCheckpoint, GateSeverity } from "../gates/taxonomy.ts"; // ── Gate trace ─────────────────────────────────────────────────────────────── /** * Captures the full lifecycle of a single gate evaluation. * * Created when a gate is requested, updated with decision and findings. */ export interface GateTrace { /** Unique trace ID, format "gate-{ts}-{hex}". */ traceId: string; /** ISO timestamp when gate evaluation was requested. */ gateRequestedAt: string; /** The checkpoint being evaluated. */ checkpoint: GateCheckpoint; /** Human-readable context summary (e.g. "pre-tool: kd_search"). */ contextSummary: string; /** The final decision: true = allowed, false = blocked. */ decision: boolean; /** ISO timestamp when the decision was made. */ decisionAt: string; /** All findings produced during evaluation. */ findings: GateTraceFinding[]; } /** * A finding snapshot embedded in a GateTrace. * Mirrors GateFinding but is a plain data record (no runtime dependency). */ export interface GateTraceFinding { id: string; severity: GateSeverity; policy: string; message: string; nextAction?: string; } // ── Loop trace ─────────────────────────────────────────────────────────────── /** * Captures the full execution trace of a loop run. * * Each step records the action taken, the gate decision that permitted it, * and the observation after execution. */ export interface LoopTrace { /** Unique trace ID, format "loop-{ts}-{hex}". */ traceId: string; /** The run ID this loop belongs to. */ runId: string; /** Ordered sequence of loop steps. */ steps: LoopTraceStep[]; /** ISO timestamp when the loop started. */ startAt: string; /** ISO timestamp when the loop stopped, if completed. */ stopAt?: string; /** Reason the loop stopped (completed, blocked, stuck, budget_exhausted). */ stopHook?: string; } /** * A single step within a LoopTrace. */ export interface LoopTraceStep { /** What action was taken in this step. */ action: string; /** Gate decision that permitted this step (traceId link). */ gateDecision?: string; /** Observation or result after the action completed. */ observation: string; /** ISO timestamp of this step. */ timestamp: string; } // ── Unified trace record ───────────────────────────────────────────────────── /** * Discriminated union of all trace kinds. * * TraceRecord is the top-level entry stored in the trace index. * It links to the original ledger events via the embedded event seq numbers. */ export type TraceRecord = GateTraceRecord | LoopTraceRecord | ToolTraceRecord; export interface GateTraceRecord { kind: "gate"; id: string; traceId: string; events: TraceEventRef[]; replayed: boolean; gate: GateTrace; } export interface LoopTraceRecord { kind: "loop"; id: string; traceId: string; events: TraceEventRef[]; replayed: boolean; loop: LoopTrace; } export interface ToolTraceRecord { kind: "tool"; id: string; traceId: string; events: TraceEventRef[]; replayed: boolean; toolName: string; action: string; result?: string; } /** * Reference to a ledger event by sequence number. */ export interface TraceEventRef { seq: number; type: string; timestamp: string; } // ── Trace ID generation ────────────────────────────────────────────────────── /** * Generate a trace ID for the given kind. * Format: "{kind}-{timestamp}-{random_hex_6}" */ export function generateTraceId(kind: "gate" | "loop" | "tool"): string { const timestamp = Date.now(); const randomHex = Math.floor(Math.random() * 0xffffff).toString(16).padStart(6, "0"); return `${kind}-${timestamp}-${randomHex}`; } // ── Trace replay ───────────────────────────────────────────────────────────── /** * Replay state accumulator used by traceReplay(). */ export interface TraceReplayState { /** All gate traces indexed by traceId. */ gates: Map; /** All loop traces indexed by traceId. */ loops: Map; /** Reconstructed decision history: traceId → allowed. */ decisions: Map; /** Number of events replayed. */ eventCount: number; /** Any errors encountered during replay. */ errors: string[]; } /** * Reconstruct trace state from an ordered sequence of ledger events. * * This function replays the event stream and builds GateTrace and LoopTrace * objects from the recorded data. It is idempotent: replaying the same * events produces the same state. * * @param events Ordered ledger events (must have .seq and .type). * @returns The reconstructed trace state. */ export function traceReplay( events: ReadonlyArray<{ seq: number; type: string; timestamp: string; data?: Record }>, ): TraceReplayState { const state: TraceReplayState = { gates: new Map(), loops: new Map(), decisions: new Map(), eventCount: 0, errors: [], }; for (const event of events) { state.eventCount++; try { switch (event.type) { case "gate.requested": replayGateRequested(state, event); break; case "gate.decision": replayGateDecision(state, event); break; case "gate.blocked": replayGateBlocked(state, event); break; case "gate.warning": replayGateWarning(state, event); break; case "loop.start": replayLoopStart(state, event); break; case "loop.step": replayLoopStep(state, event); break; case "loop.completed": case "loop.blocked": case "loop.stuck": case "loop.budget_exhausted": replayLoopStop(state, event); break; default: // Non-trace event, skip. break; } } catch (err) { state.errors.push(`Failed to replay event #${event.seq} (${event.type}): ${err}`); } } return state; } // ── Replay helpers ─────────────────────────────────────────────────────────── function replayGateRequested( state: TraceReplayState, event: { seq: number; type: string; timestamp: string; data?: Record }, ): void { const traceId = extractString(event.data, "traceId"); if (!traceId) return; const existing = state.gates.get(traceId); if (existing) { // Already have a later record; update requestedAt if earlier. if (event.timestamp < existing.gateRequestedAt) { existing.gateRequestedAt = event.timestamp; } return; } state.gates.set(traceId, { traceId, gateRequestedAt: event.timestamp, checkpoint: (extractString(event.data, "checkpoint") ?? "pre-input") as GateCheckpoint, contextSummary: extractString(event.data, "summary") ?? "", decision: false, decisionAt: "", findings: [], }); } function replayGateDecision( state: TraceReplayState, event: { seq: number; type: string; timestamp: string; data?: Record }, ): void { const traceId = extractString(event.data, "traceId"); if (!traceId) return; const allowed = extractBoolean(event.data, "allowed"); const checkpoint = extractString(event.data, "checkpoint") as GateCheckpoint | undefined; const findings = extractFindings(event.data); let gate = state.gates.get(traceId); if (!gate) { // Decision arrived before requested; create stub. gate = { traceId, gateRequestedAt: event.timestamp, checkpoint: checkpoint ?? "pre-input", contextSummary: "", decision: allowed ?? false, decisionAt: event.timestamp, findings, }; state.gates.set(traceId, gate); } else { gate.decision = allowed ?? false; gate.decisionAt = event.timestamp; gate.findings = findings; if (checkpoint) gate.checkpoint = checkpoint; } state.decisions.set(traceId, gate.decision); } function replayGateBlocked( state: TraceReplayState, event: { seq: number; type: string; timestamp: string; data?: Record }, ): void { const traceId = extractString(event.data, "traceId"); if (!traceId) return; let gate = state.gates.get(traceId); if (!gate) { gate = { traceId, gateRequestedAt: event.timestamp, checkpoint: (extractString(event.data, "checkpoint") ?? "pre-input") as GateCheckpoint, contextSummary: extractString(event.data, "summary") ?? "", decision: false, decisionAt: event.timestamp, findings: extractFindings(event.data), }; state.gates.set(traceId, gate); } else { gate.decision = false; gate.decisionAt = event.timestamp; } state.decisions.set(traceId, false); } function replayGateWarning( state: TraceReplayState, event: { seq: number; type: string; timestamp: string; data?: Record }, ): void { const traceId = extractString(event.data, "traceId"); if (!traceId) return; let gate = state.gates.get(traceId); if (!gate) { gate = { traceId, gateRequestedAt: event.timestamp, checkpoint: (extractString(event.data, "checkpoint") ?? "pre-input") as GateCheckpoint, contextSummary: extractString(event.data, "summary") ?? "", decision: true, decisionAt: event.timestamp, findings: extractFindings(event.data), }; state.gates.set(traceId, gate); } else { // Warning does not change decision. } // Warnings don't block, so only set decision if not already set. if (!state.decisions.has(traceId)) { state.decisions.set(traceId, true); } } function replayLoopStart( state: TraceReplayState, event: { seq: number; type: string; timestamp: string; data?: Record }, ): void { const traceId = extractString(event.data, "traceId"); if (!traceId) return; if (state.loops.has(traceId)) return; state.loops.set(traceId, { traceId, runId: extractString(event.data, "runId") ?? "", steps: [], startAt: event.timestamp, }); } function replayLoopStep( state: TraceReplayState, event: { seq: number; type: string; timestamp: string; data?: Record }, ): void { const traceId = extractString(event.data, "traceId"); if (!traceId) return; const loop = state.loops.get(traceId); if (!loop) return; loop.steps.push({ action: extractString(event.data, "action") ?? "", gateDecision: extractString(event.data, "gateDecision"), observation: extractString(event.data, "observation") ?? "", timestamp: event.timestamp, }); } function replayLoopStop( state: TraceReplayState, event: { seq: number; type: string; timestamp: string; data?: Record }, ): void { const traceId = extractString(event.data, "traceId"); if (!traceId) return; const loop = state.loops.get(traceId); if (!loop) return; loop.stopAt = event.timestamp; loop.stopHook = event.type; } // ── Data extraction helpers ────────────────────────────────────────────────── function extractString(data: Record | undefined, key: string): string | undefined { if (!data) return undefined; const val = data[key]; return typeof val === "string" ? val : undefined; } function extractBoolean(data: Record | undefined, key: string): boolean | undefined { if (!data) return undefined; const val = data[key]; return typeof val === "boolean" ? val : undefined; } function extractFindings(data: Record | undefined): GateTraceFinding[] { if (!data) return []; const raw = data["findings"]; if (!Array.isArray(raw)) return []; const findings: GateTraceFinding[] = []; for (const item of raw) { if (!item || typeof item !== "object") continue; const obj = item as Record; if (typeof obj.id !== "string" || typeof obj.severity !== "string" || typeof obj.policy !== "string" || typeof obj.message !== "string") { continue; } findings.push({ id: obj.id, severity: obj.severity as GateSeverity, policy: obj.policy, message: obj.message, nextAction: typeof obj.nextAction === "string" ? obj.nextAction : undefined, }); } return findings; }