/** * AgentGuard® — model-agnostic provenance that survives hot-swap. * * The "headless" / model-fungibility problem (raised on All-In E280): teams want * to hot-swap models — frontier → open-weight → self-hosted — per task to cut * cost, but "no one has figured out how to abstract memory/context/AUDIT away * from the model." A provider console can't help: it only sees its own tokens, * and the trail breaks the moment you route to a different provider. * * AgentGuard's signed decision log already records, per call, which model served * it (provenance) inside one continuous hash chain. This module reads that log * and produces a PORTABLE, model-agnostic provenance manifest per workload: the * ordered model lineage, every swap, the frontier/dark origin mix, and a single * tamper-evidence verdict that spans the whole swap sequence. The audit is keyed * to the workload, not the model — so it survives the swap. * * Honest scope note (same as the dashboard): the signed SpendDecision does not * carry a first-class workload/agent id (only `triggeredScopeKey`). Callers pass * a `keyOf` selector to group; the default uses `triggeredScopeKey`. We never * invent a grouping the ledger does not support. * * Patent notice: signed hash-chained decision log (U.S. application filed May * 2026); composes with DAG Trust Attestation, Patent D §7.3 (App. No. * 63/984,626). AgentGuard® is a U.S. registered trademark (Reg. No. 8281464) of * Dunecrest Ventures Inc. */ import type { SignedDecisionLogEntry, SpendDecision } from '../types'; import { classifyOrigin, decisionCents, type TokenOrigin } from '../dashboard/aggregate'; export interface ModelUse { model: string; origin: TokenOrigin; provider: string; calls: number; cents: number; } export interface ModelSwap { atSequence: number; fromModel: string; toModel: string; fromOrigin: TokenOrigin; toOrigin: TokenOrigin; /** True when the swap crossed the frontier↔dark boundary (the audit-risky kind). */ crossesBoundary: boolean; } export interface WorkloadProvenance { key: string; calls: number; spentCents: number; frontierCents: number; darkCents: number; firstAt: string | null; lastAt: string | null; models: ModelUse[]; /** Ordered model lineage, e.g. ['claude-5','gpt-5-mini','glm-5.2']. */ lineage: string[]; swaps: ModelSwap[]; } export interface WorkloadProvenanceReport { workloads: WorkloadProvenance[]; /** Chain-level tamper evidence spans ALL swaps — the point of the manifest. */ continuity: { verified: boolean; entries: number; reason?: string }; } const defaultKeyOf = (d: SpendDecision): string => d.triggeredScopeKey ?? 'default'; /** * Build the portable per-workload provenance manifest from signed ledger * entries. Pass the async `verifyChain` result as `continuity` so this stays a * pure, synchronous function (never null-continuity in production — a manifest * without tamper evidence is not the product). */ export function workloadProvenance( entries: SignedDecisionLogEntry[], continuity: WorkloadProvenanceReport['continuity'] = { verified: false, entries: 0, reason: 'NOT_CHECKED' }, keyOf: (d: SpendDecision) => string = defaultKeyOf, ): WorkloadProvenanceReport { interface Group { calls: number; spent: number; frontier: number; dark: number; first: string | null; last: string | null; models: Map; ordered: Array<{ seq: number; model: string; origin: TokenOrigin }>; } const groups = new Map(); for (const entry of entries) { const d = entry.decision; const kind = d.entryType ?? 'decision'; if (kind !== 'decision') continue; // settlements/outcomes don't add model steps const key = keyOf(d); const grp: Group = groups.get(key) ?? { calls: 0, spent: 0, frontier: 0, dark: 0, first: null, last: null, models: new Map(), ordered: [], }; const model = d.modelResolved || d.modelRequested || 'unknown'; const { origin } = classifyOrigin(d); const cents = decisionCents(d); grp.calls += 1; grp.spent += cents; if (origin === 'frontier') grp.frontier += cents; else if (origin === 'dark') grp.dark += cents; if (d.timestamp) { if (!grp.first || d.timestamp < grp.first) grp.first = d.timestamp; if (!grp.last || d.timestamp > grp.last) grp.last = d.timestamp; } const mu = grp.models.get(model) ?? { model, origin, provider: d.provider ?? 'unknown', calls: 0, cents: 0 }; mu.calls += 1; mu.cents += cents; grp.models.set(model, mu); grp.ordered.push({ seq: entry.sequence, model, origin }); groups.set(key, grp); } const workloads: WorkloadProvenance[] = []; for (const [key, grp] of groups) { grp.ordered.sort((a, b) => a.seq - b.seq); const swaps: ModelSwap[] = []; for (let i = 1; i < grp.ordered.length; i++) { const prev = grp.ordered[i - 1]; const cur = grp.ordered[i]; if (cur.model !== prev.model) { swaps.push({ atSequence: cur.seq, fromModel: prev.model, toModel: cur.model, fromOrigin: prev.origin, toOrigin: cur.origin, crossesBoundary: prev.origin !== cur.origin && prev.origin !== 'unknown' && cur.origin !== 'unknown', }); } } // lineage = models in first-appearance order (collapse consecutive repeats) const lineage: string[] = []; for (const step of grp.ordered) if (lineage[lineage.length - 1] !== step.model) lineage.push(step.model); workloads.push({ key, calls: grp.calls, spentCents: grp.spent, frontierCents: grp.frontier, darkCents: grp.dark, firstAt: grp.first, lastAt: grp.last, models: [...grp.models.values()].sort((a, b) => b.cents - a.cents), lineage, swaps, }); } workloads.sort((a, b) => b.spentCents - a.spentCents); return { workloads, continuity }; } /** Human-readable lineage, e.g. "claude-5 → gpt-5-mini → glm-5.2". */ export const formatLineage = (w: WorkloadProvenance): string => w.lineage.join(' → ');