merge: black box launch spine (lane 2/5)

This commit is contained in:
Sam Valladares 2026-06-24 17:34:54 -05:00
commit d5002c314b
43 changed files with 6857 additions and 18 deletions

View file

@ -13,6 +13,8 @@
// ◎ ◈ ◉ ◷ across multiple items; that bug is dead here).
// ═══════════════════════════════════════════════════════════════════
export type IconName =
| 'blackbox'
| 'memorypr'
| 'graph'
| 'reasoning'
| 'memories'
@ -41,6 +43,10 @@
// Each entry is the inner markup of a 24×24 SVG. Strokes inherit
// currentColor; fills are explicit where a solid accent reads better.
export const ICON_PATHS: Record<IconName, string> = {
// Flight recorder — a radar-pulse sweep inside a recorder box.
blackbox: `<rect x="3.5" y="6" width="17" height="12" rx="2.2"/><circle cx="12" cy="12" r="3.4"/><path d="M12 12 14.6 9.4" /><circle cx="12" cy="12" r="0.9" fill="currentColor" stroke="none"/><path d="M7 19.5h10" opacity=".5"/>`,
// Git-branch with a review check — approve changes to the brain.
memorypr: `<circle cx="6.5" cy="6" r="2"/><circle cx="6.5" cy="18" r="2"/><circle cx="17" cy="9" r="2"/><path d="M6.5 8v8M6.5 12h6.2A2.3 2.3 0 0 0 15 9.7V11"/><path d="m14.8 17 1.5 1.6 3-3.4" fill="none"/>`,
// Connected nodes — a literal knowledge graph.
graph: `<circle cx="6" cy="7" r="2.1"/><circle cx="18" cy="6" r="2.1"/><circle cx="12" cy="17.5" r="2.3"/><path d="M7.7 8.4 10.6 15.5M16.4 7.6 13.2 15.6M8 7l8-1"/>`,
// Branching logic tree with a spark — deduction.

View file

@ -0,0 +1,218 @@
<script lang="ts">
// ═══════════════════════════════════════════════════════════════════════
// MEMORY RECEIPT CARD — the nutrition label for a retrieval.
// ───────────────────────────────────────────────────────────────────────
// Shows what was retrieved, what was suppressed and why, the activation
// path, the trust floor (the weakest link the answer rests on), and the
// decay risk. "Open receipt in Cinema" deep-links to the graph centered on
// the receipt's primary memory, starting the (protected) Cinema flythrough
// over the exact memory set the receipt names.
// ═══════════════════════════════════════════════════════════════════════
import { goto } from '$app/navigation';
import Icon from './Icon.svelte';
import type { Receipt } from '$lib/stores/api';
interface Props {
receipt: Receipt;
compact?: boolean;
}
let { receipt, compact = false }: Props = $props();
const riskColor: Record<Receipt['decay_risk'], string> = {
low: 'var(--color-recall, #10b981)',
medium: '#f59e0b',
high: '#f43f5e'
};
function openInCinema() {
const primary = receipt.retrieved[0];
if (!primary) return;
const focus = receipt.retrieved.join(',');
goto(`/graph?center=${encodeURIComponent(primary)}&focus=${encodeURIComponent(focus)}`);
}
</script>
<div class="receipt" class:compact style:--risk={riskColor[receipt.decay_risk]}>
<div class="r-head">
<code class="r-id">{receipt.receipt_id}</code>
<span class="r-risk" style:color={riskColor[receipt.decay_risk]}>
decay: {receipt.decay_risk}
</span>
</div>
<div class="r-metrics">
<div class="metric">
<span class="m-val">{receipt.retrieved.length}</span>
<span class="m-label">retrieved</span>
</div>
<div class="metric">
<span class="m-val">{receipt.suppressed.length}</span>
<span class="m-label">suppressed</span>
</div>
<div class="metric">
<span class="m-val">{(receipt.trust_floor * 100).toFixed(0)}%</span>
<span class="m-label">trust floor</span>
</div>
</div>
{#if !compact}
{#if receipt.activation_path.length}
<div class="r-section">
<span class="r-section-title">Activation path</span>
{#each receipt.activation_path as path (path)}
<div class="path">{path}</div>
{/each}
</div>
{/if}
{#if receipt.retrieved.length}
<div class="r-section">
<span class="r-section-title">Retrieved</span>
<div class="chips">
{#each receipt.retrieved as id (id)}
<code class="chip recall">{id.slice(0, 8)}</code>
{/each}
</div>
</div>
{/if}
{#if receipt.suppressed.length}
<div class="r-section">
<span class="r-section-title">Suppressed</span>
<div class="chips">
{#each receipt.suppressed as s (s.id)}
<code class="chip suppress" title={s.reason}>
{s.id.slice(0, 8)} · {s.reason.replace('_', ' ')}
</code>
{/each}
</div>
</div>
{/if}
{/if}
<button class="cinema-btn" onclick={openInCinema} disabled={!receipt.retrieved.length}>
<Icon name="sparkle" size={14} />
Open receipt in Cinema
</button>
</div>
<style>
.receipt {
border: 1px solid color-mix(in oklab, var(--risk) 30%, transparent);
border-left: 3px solid var(--risk);
border-radius: 12px;
padding: 14px 16px;
background: color-mix(in oklab, var(--color-void, #050510) 50%, transparent);
display: flex;
flex-direction: column;
gap: 12px;
}
.receipt.compact {
gap: 10px;
padding: 12px 14px;
}
.r-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
}
.r-id {
font-size: 0.78rem;
color: var(--color-synapse-glow, #818cf8);
word-break: break-all;
}
.r-risk {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
white-space: nowrap;
}
.r-metrics {
display: flex;
gap: 20px;
}
.metric {
display: flex;
flex-direction: column;
gap: 1px;
}
.m-val {
font-size: 1.25rem;
font-weight: 800;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.m-label {
font-size: 0.64rem;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--color-text-dim, #8b8ba7);
}
.r-section {
display: flex;
flex-direction: column;
gap: 6px;
}
.r-section-title {
font-size: 0.66rem;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--color-text-dim, #8b8ba7);
}
.path {
font-size: 0.8rem;
font-family: var(--font-mono, monospace);
color: var(--color-text, #e2e2f0);
padding: 4px 8px;
border-radius: 6px;
background: color-mix(in oklab, var(--color-synapse) 8%, transparent);
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.chip {
font-size: 0.72rem;
padding: 2px 8px;
border-radius: 6px;
}
.chip.recall {
color: var(--color-recall, #10b981);
background: color-mix(in oklab, var(--color-recall) 12%, transparent);
border: 1px solid color-mix(in oklab, var(--color-recall) 28%, transparent);
}
.chip.suppress {
color: #a78bfa;
background: color-mix(in oklab, #a78bfa 12%, transparent);
border: 1px solid color-mix(in oklab, #a78bfa 28%, transparent);
text-decoration: line-through;
text-decoration-color: color-mix(in oklab, #a78bfa 50%, transparent);
}
.cinema-btn {
margin-top: 2px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
padding: 8px 14px;
font-size: 0.8rem;
font-weight: 600;
border-radius: 9px;
border: 1px solid color-mix(in oklab, var(--color-synapse) 40%, transparent);
background: color-mix(in oklab, var(--color-synapse) 12%, transparent);
color: var(--color-synapse-glow, #818cf8);
cursor: pointer;
transition: all 0.18s ease;
}
.cinema-btn:hover:not(:disabled) {
background: color-mix(in oklab, var(--color-synapse) 24%, transparent);
transform: translateY(-1px);
}
.cinema-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>

View file

@ -0,0 +1,134 @@
// ═══════════════════════════════════════════════════════════════════════════
// AGENT BLACK BOX — presentation helpers
// ───────────────────────────────────────────────────────────────────────────
// Pure functions that turn a raw `TraceEvent` into the label, color, glyph,
// and one-line summary the Black Box timeline renders. Kept out of the
// component so they are unit-testable and reused by the Proof Mode header.
// ═══════════════════════════════════════════════════════════════════════════
import type { TraceEvent } from '$lib/stores/api';
export type TraceKind = TraceEvent['type'];
/** The accent color for each trace-event kind (CSS color value). */
export function eventColor(kind: TraceKind): string {
switch (kind) {
case 'mcp.call':
return 'var(--color-synapse-glow, #818cf8)';
case 'memory.retrieve':
return 'var(--color-recall, #10b981)';
case 'memory.suppress':
return '#a78bfa'; // violet — the forgetting hue
case 'memory.write':
return '#38bdf8'; // sky — a new write
case 'contradiction.detected':
return '#fb7185'; // rose — tension
case 'sanhedrin.veto':
return '#f43f5e'; // red — a block
case 'dream.patch':
return '#c084fc'; // purple — dream
default:
return 'var(--color-synapse, #6366f1)';
}
}
/** A short human label for each kind. */
export function eventLabel(kind: TraceKind): string {
switch (kind) {
case 'mcp.call':
return 'Tool Call';
case 'memory.retrieve':
return 'Retrieved';
case 'memory.suppress':
return 'Suppressed';
case 'memory.write':
return 'Wrote';
case 'contradiction.detected':
return 'Contradiction';
case 'sanhedrin.veto':
return 'Veto';
case 'dream.patch':
return 'Dream Patch';
default:
return kind;
}
}
/** A single glyph (emoji-free SVG path is overkill here; a compact symbol). */
export function eventGlyph(kind: TraceKind): string {
switch (kind) {
case 'mcp.call':
return '⟐';
case 'memory.retrieve':
return '◉';
case 'memory.suppress':
return '⊘';
case 'memory.write':
return '✎';
case 'contradiction.detected':
return '⚡';
case 'sanhedrin.veto':
return '⛔';
case 'dream.patch':
return '☾';
default:
return '•';
}
}
/** A one-line summary of what an event did, for the timeline row. */
export function eventSummary(ev: TraceEvent): string {
switch (ev.type) {
case 'mcp.call':
return `${ev.tool} · args ${ev.argsHash.slice(0, 8)}`;
case 'memory.retrieve':
return `${ev.ids.length} ${ev.ids.length === 1 ? 'memory' : 'memories'} surfaced`;
case 'memory.suppress':
return `${ev.id.slice(0, 8)}${ev.reason.replace('_', ' ')}`;
case 'memory.write':
return `${ev.id.slice(0, 8)}${ev.source}`;
case 'contradiction.detected':
return ev.detail;
case 'sanhedrin.veto':
return `"${ev.claim}" (conf ${(ev.confidence * 100).toFixed(0)}%)`;
case 'dream.patch':
return `${ev.proposalIds.length} consolidation proposal(s)`;
default:
return '';
}
}
/** The memory ids an event touched (for graph-pulse replay). */
export function eventMemoryIds(ev: TraceEvent): string[] {
switch (ev.type) {
case 'memory.retrieve':
return ev.ids;
case 'memory.suppress':
case 'memory.write':
return [ev.id];
case 'contradiction.detected':
return ev.ids;
case 'sanhedrin.veto':
return ev.evidenceIds;
case 'dream.patch':
return ev.proposalIds;
default:
return [];
}
}
/** Format a millisecond timestamp as a clock time. */
export function formatAt(at: number): string {
if (!Number.isFinite(at) || at <= 0) return '—';
const d = new Date(at);
return d.toLocaleTimeString(undefined, {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
/** Elapsed milliseconds of an event relative to the run's first event. */
export function relativeMs(at: number, startAt: number): number {
return Math.max(0, at - startAt);
}

View file

@ -133,5 +133,122 @@ export const api = {
method: 'POST',
body: JSON.stringify({ reason, note, claimId, receiptId })
})
},
// Agent Black Box (v2.2): replayable agent-run traces. The runId in a tool
// result threads through here unchanged — one id, end to end.
traces: {
list: (limit = 50) => fetcher<TraceRunListResponse>(`/traces?limit=${limit}`),
get: (runId: string) => fetcher<TraceDetail>(`/traces/${encodeURIComponent(runId)}`),
exportUrl: (runId: string) => `${BASE}/traces/${encodeURIComponent(runId)}/export`
},
// Memory Receipts (v2.2): the nutrition label for a retrieval.
receipts: {
list: (limit = 50) => fetcher<ReceiptListResponse>(`/receipts?limit=${limit}`),
// B5: scope to one run so the Black Box panel shows that run's receipts.
listForRun: (runId: string, limit = 50) =>
fetcher<ReceiptListResponse>(
`/receipts?run=${encodeURIComponent(runId)}&limit=${limit}`
),
get: (receiptId: string) => fetcher<Receipt>(`/receipts/${encodeURIComponent(receiptId)}`)
},
// Memory PRs (v2.2): the risk-gated brain-change review queue.
memoryPrs: {
list: (status?: string, limit = 100) => {
const qs = new URLSearchParams();
if (status) qs.set('status', status);
qs.set('limit', String(limit));
return fetcher<MemoryPrListResponse>(`/memory-prs?${qs.toString()}`);
},
get: (id: string) => fetcher<MemoryPr>(`/memory-prs/${encodeURIComponent(id)}`),
act: (id: string, action: MemoryPrAction) =>
fetcher<Record<string, unknown>>(`/memory-prs/${encodeURIComponent(id)}/${action}`, {
method: 'POST'
}),
getMode: () => fetcher<{ mode: ReviewMode; pendingCount: number }>('/memory-prs/mode'),
setMode: (mode: ReviewMode) =>
fetcher<{ mode: ReviewMode }>('/memory-prs/mode', {
method: 'POST',
body: JSON.stringify({ mode })
})
}
};
// ---------------------------------------------------------------------------
// Agent Black Box / Receipts / Memory PR types
// ---------------------------------------------------------------------------
export type TraceRunSummary = {
runId: string;
firstTool: string | null;
eventCount: number;
retrievedCount: number;
suppressedCount: number;
writeCount: number;
vetoCount: number;
startedAt: number;
lastAt: number;
};
export type TraceRunListResponse = { total: number; runs: TraceRunSummary[] };
/** One trace event — discriminated on `type`, matching the Rust schema. */
export type TraceEvent =
| { type: 'mcp.call'; runId: string; tool: string; argsHash: string; at: number }
| { type: 'memory.retrieve'; runId: string; ids: string[]; activation: Record<string, number>; at: number }
| { type: 'memory.suppress'; runId: string; id: string; reason: string; at: number }
| { type: 'memory.write'; runId: string; id: string; diff: unknown; source: string; at: number }
| { type: 'contradiction.detected'; runId: string; ids: string[]; winnerId?: string; detail: string; at: number }
| { type: 'sanhedrin.veto'; runId: string; claim: string; evidenceIds: string[]; confidence: number; at: number }
| { type: 'dream.patch'; runId: string; proposalIds: string[]; at: number };
export type TraceDetail = {
runId: string;
summary: Omit<TraceRunSummary, 'runId'> | null;
events: TraceEvent[];
};
export type Receipt = {
receipt_id: string;
retrieved: string[];
suppressed: { id: string; reason: string }[];
activation_path: string[];
trust_floor: number;
decay_risk: 'low' | 'medium' | 'high';
mutations: { id: string; kind: string; note?: string }[];
};
export type ReceiptListResponse = { total: number; receipts: Receipt[] };
export type MemoryPrAction =
| 'promote'
| 'merge'
| 'supersede'
| 'quarantine'
| 'forget'
| 'ask_agent_why';
export type ReviewMode = 'fast' | 'risk_gated' | 'paranoid';
export type MemoryPr = {
id: string;
kind: string;
status: string;
title: string;
diff: Record<string, unknown>;
signals: { code: string; detail: string }[];
subject_id?: string;
run_id?: string;
created_at: string;
decided_at?: string;
decision?: string;
};
export type MemoryPrListResponse = {
total: number;
pendingCount: number;
mode: ReviewMode;
prs: MemoryPr[];
};

View file

@ -132,6 +132,30 @@ export const uptimeSeconds = derived(websocket, $ws =>
($ws.lastHeartbeat?.data?.uptime_secs as number) ?? 0
);
// Agent Black Box (v2.2): the live stream of trace events, newest first. Each
// is a real `VestigeEvent::TraceEvent` backed by a persisted `agent_traces`
// row — the dashboard pulse is only ever driven by these, never by fakes.
export const traceEvents = derived(websocket, $ws =>
$ws.events.filter((e) => e.type === 'TraceEvent')
);
// The most recent runId seen on the live feed — the "current run" indicator in
// Proof Mode / the Black Box live header.
export const liveRunId = derived(websocket, $ws => {
const latest = $ws.events.find((e) => e.type === 'TraceEvent');
return (latest?.data?.run_id as string) ?? null;
});
// The single most recent trace event (for the "last event" readout).
export const lastTraceEvent = derived(websocket, $ws =>
$ws.events.find((e) => e.type === 'TraceEvent') ?? null
);
// Live Memory PR notifications (opened / decided) for the queue badge + toasts.
export const memoryPrEvents = derived(websocket, $ws =>
$ws.events.filter((e) => e.type === 'MemoryPrOpened' || e.type === 'MemoryPrDecided')
);
export function formatUptime(secs: number): string {
if (!Number.isFinite(secs) || secs < 0) return '—';
const d = Math.floor(secs / 86_400);

View file

@ -168,6 +168,9 @@ export type VestigeEventType =
| 'ImportanceScored'
| 'DeepReferenceCompleted'
| 'HookVerdictRecorded'
| 'TraceEvent'
| 'MemoryPrOpened'
| 'MemoryPrDecided'
| 'Heartbeat';
export interface VestigeEvent {

View file

@ -0,0 +1,954 @@
<script lang="ts">
// ═══════════════════════════════════════════════════════════════════════
// AGENT BLACK BOX — the flight recorder for agent cognition.
// ───────────────────────────────────────────────────────────────────────
// Watch the agent think. Watch memory change. Watch the receipt prove why.
//
// Every MCP tool call carries a runId that threads, unbroken, through the
// tool output → SQLite trace rows → WebSocket → this page → the export →
// Cinema. This tab replays that exact run: a timeline scrubber, per-event
// detail, the suppressed memories, trust scores, contradiction decisions,
// and a one-click `.vestige-trace.json` export.
//
// Live events are real — they arrive over the WebSocket backed by trace
// rows. No fake demo events.
// ═══════════════════════════════════════════════════════════════════════
import { onMount } from 'svelte';
import PageHeader from '$components/PageHeader.svelte';
import Icon from '$components/Icon.svelte';
import AnimatedNumber from '$components/AnimatedNumber.svelte';
import { reveal } from '$lib/actions/reveal';
import ReceiptCard from '$components/ReceiptCard.svelte';
import {
api,
type TraceRunSummary,
type TraceEvent,
type TraceDetail,
type Receipt
} from '$lib/stores/api';
import { isConnected, liveRunId, lastTraceEvent, traceEvents } from '$lib/stores/websocket';
import {
eventColor,
eventLabel,
eventGlyph,
eventSummary,
eventMemoryIds,
formatAt,
relativeMs
} from '$components/blackbox-helpers';
// ---- state ----------------------------------------------------------
let runs = $state<TraceRunSummary[]>([]);
let selectedRunId = $state<string | null>(null);
let detail = $state<TraceDetail | null>(null);
let loading = $state(false);
let error = $state<string | null>(null);
let scrubIndex = $state(0); // index into detail.events
let proofMode = $state(false);
let receipts = $state<Receipt[]>([]);
// The events up to and including the scrubber position — what the agent had
// "experienced" at that moment in the run.
const visibleEvents = $derived(detail ? detail.events.slice(0, scrubIndex + 1) : []);
const currentEvent = $derived<TraceEvent | null>(
detail && detail.events.length ? detail.events[scrubIndex] : null
);
const startAt = $derived(detail?.events[0]?.at ?? 0);
// Memory ids that have been touched up to the scrubber — the live pulse set.
const pulsedIds = $derived(
Array.from(new Set(visibleEvents.flatMap(eventMemoryIds)))
);
// Honest producer status for this run. Two event kinds depend on optional
// upstream producers that are off by default — we say so explicitly instead
// of rendering a confusing empty space.
const hasVeto = $derived(detail?.events.some((e) => e.type === 'sanhedrin.veto') ?? false);
const hasDream = $derived(detail?.events.some((e) => e.type === 'dream.patch') ?? false);
const hasContradiction = $derived(
detail?.events.some((e) => e.type === 'contradiction.detected') ?? false
);
async function loadRuns() {
try {
const res = await api.traces.list(100);
runs = res.runs;
if (!selectedRunId && runs.length) selectRun(runs[0].runId);
} catch (e) {
error = String(e);
}
}
async function selectRun(runId: string) {
selectedRunId = runId;
loading = true;
error = null;
try {
detail = await api.traces.get(runId);
scrubIndex = Math.max(0, (detail.events.length || 1) - 1);
// Receipts are the proof behind THIS run's retrievals — scoped to
// the selected run (B5), not the global latest.
receipts = (await api.receipts.listForRun(runId, 8)).receipts;
} catch (e) {
error = String(e);
detail = null;
} finally {
loading = false;
}
}
function exportTrace() {
if (!selectedRunId) return;
// Direct browser download of the .vestige-trace.json artifact.
window.location.href = api.traces.exportUrl(selectedRunId);
}
// Live: when a trace event for the *currently open* run arrives, refresh it
// so the timeline grows in real time. Also refresh the run list so new runs
// appear at the top.
$effect(() => {
const last = $lastTraceEvent;
if (!last) return;
const evRunId = last.data?.run_id as string | undefined;
if (evRunId && evRunId === selectedRunId) {
// Re-fetch the open run (cheap; trace rows are local SQLite).
api.traces.get(selectedRunId).then((d) => {
detail = d;
// Keep the scrubber pinned to the newest event in live mode.
scrubIndex = Math.max(0, d.events.length - 1);
});
}
});
onMount(loadRuns);
</script>
<div class="mx-auto max-w-6xl px-5 py-6">
<PageHeader
icon="blackbox"
title="Agent Black Box"
subtitle="Watch the agent think. Watch memory change. Watch the receipt prove why."
accent="synapse"
>
<button
class="mode-toggle"
class:on={proofMode}
onclick={() => (proofMode = !proofMode)}
title="Proof Mode: a clean launch-footage view"
>
<Icon name="sparkle" size={14} />
Proof Mode
</button>
<button class="export-btn" onclick={exportTrace} disabled={!selectedRunId}>
<Icon name="feed" size={14} />
Export .vestige-trace.json
</button>
</PageHeader>
<!-- ░░ LIVE SPINE HEADER — the proof line: one runId, end to end ░░ -->
<div class="spine glass" use:reveal>
<div class="spine-item">
<span class="spine-label">WebSocket</span>
<span class="spine-value" class:live={$isConnected}>
<span class="dot" class:live={$isConnected}></span>
{$isConnected ? 'Connected' : 'Offline'}
</span>
</div>
<div class="spine-item">
<span class="spine-label">Live runId</span>
<code class="spine-run">{$liveRunId ?? '—'}</code>
</div>
<div class="spine-item">
<span class="spine-label">Last event</span>
<span class="spine-value">
{#if $lastTraceEvent}
<span class="ev-chip" style:--c={eventColor(($lastTraceEvent.data?.event as TraceEvent)?.type)}>
{eventLabel(($lastTraceEvent.data?.event as TraceEvent)?.type)}
</span>
{:else}
<span class="text-dim">awaiting…</span>
{/if}
</span>
</div>
<div class="spine-item">
<span class="spine-label">Events seen</span>
<span class="spine-value">
<AnimatedNumber value={$traceEvents.length} />
</span>
</div>
</div>
{#if !proofMode}
<div class="layout">
<!-- ░░ RUN PICKER ░░ -->
<aside class="runs glass" use:reveal>
<h2 class="panel-title">Runs</h2>
{#if runs.length === 0}
<p class="empty">
No agent runs recorded yet. Make an MCP tool call — every call is
recorded here.
</p>
{:else}
<ul>
{#each runs as run (run.runId)}
<li>
<button
class="run-row"
class:active={run.runId === selectedRunId}
onclick={() => selectRun(run.runId)}
>
<div class="run-top">
<code class="run-id">{run.runId.replace('run_', '').slice(0, 10)}</code>
<span class="run-tool">{run.firstTool ?? '—'}</span>
</div>
<div class="run-stats">
<span title="events">{run.eventCount} ev</span>
{#if run.retrievedCount}<span class="s-recall">{run.retrievedCount}</span>{/if}
{#if run.suppressedCount}<span class="s-suppress">{run.suppressedCount}</span>{/if}
{#if run.writeCount}<span class="s-write">{run.writeCount}</span>{/if}
{#if run.vetoCount}<span class="s-veto">{run.vetoCount}</span>{/if}
</div>
</button>
</li>
{/each}
</ul>
{/if}
</aside>
<!-- ░░ REPLAY ░░ -->
<section class="replay">
{#if loading}
<div class="glass center-msg">Loading trace…</div>
{:else if error}
<div class="glass center-msg err">{error}</div>
{:else if !detail}
<div class="glass center-msg">Select a run to replay.</div>
{:else}
<!-- Scrubber -->
<div class="scrubber glass" use:reveal>
<div class="scrub-head">
<span class="scrub-title">
Step <strong>{scrubIndex + 1}</strong> / {detail.events.length}
</span>
{#if currentEvent}
<span class="scrub-time">+{relativeMs(currentEvent.at, startAt)}ms</span>
{/if}
</div>
<input
type="range"
min="0"
max={Math.max(0, detail.events.length - 1)}
bind:value={scrubIndex}
class="scrub-range"
/>
<!-- A tick row colored by event kind — the run at a glance. -->
<div class="ticks">
{#each detail.events as ev, i (i)}
<button
class="tick"
class:past={i <= scrubIndex}
style:--c={eventColor(ev.type)}
onclick={() => (scrubIndex = i)}
title={eventLabel(ev.type)}
aria-label={`Step ${i + 1}: ${eventLabel(ev.type)}`}
></button>
{/each}
</div>
</div>
<!-- Current event detail -->
{#if currentEvent}
<div class="event-detail glass" use:reveal style:--c={eventColor(currentEvent.type)}>
<div class="ed-head">
<span class="ed-glyph">{eventGlyph(currentEvent.type)}</span>
<span class="ed-label">{eventLabel(currentEvent.type)}</span>
<code class="ed-time">{formatAt(currentEvent.at)}</code>
</div>
<p class="ed-summary">{eventSummary(currentEvent)}</p>
{#if currentEvent.type === 'memory.retrieve'}
<div class="ids-grid">
{#each currentEvent.ids as id (id)}
<span class="id-chip" style:--a={currentEvent.activation[id] ?? 0}>
<code>{id.slice(0, 8)}</code>
{#if currentEvent.activation[id] != null}
<small>{(currentEvent.activation[id] * 100).toFixed(0)}%</small>
{/if}
</span>
{/each}
</div>
{:else if currentEvent.type === 'contradiction.detected'}
<div class="contra">
<span class="winner">kept {currentEvent.winnerId?.slice(0, 8)}</span>
<span class="vs">vs</span>
{#each currentEvent.ids.filter((i) => i !== currentEvent.winnerId) as id (id)}
<span class="loser">{id.slice(0, 8)}</span>
{/each}
</div>
{:else if currentEvent.type === 'sanhedrin.veto'}
<div class="veto-evidence">
{#each currentEvent.evidenceIds as id (id)}
<code>{id.slice(0, 8)}</code>
{/each}
</div>
{/if}
</div>
{/if}
<!-- Pulse set: the memories touched so far -->
<div class="pulse glass" use:reveal>
<h3 class="panel-title">
Memory pulse <span class="text-dim">— touched this run</span>
</h3>
{#if pulsedIds.length === 0}
<p class="empty">No memories touched yet.</p>
{:else}
<div class="pulse-grid">
{#each pulsedIds as id (id)}
<code class="pulse-node">{id.slice(0, 8)}</code>
{/each}
</div>
{/if}
</div>
<!-- Producer status — honest about what's live vs. off-by-default -->
<div class="producers glass" use:reveal>
<h3 class="panel-title">Event producers <span class="text-dim">— this run</span></h3>
<ul class="producer-list">
<li class="producer ok">
<span class="p-dot"></span> mcp.call · memory.write · memory.retrieve · memory.suppress
<span class="p-state">live</span>
</li>
<li class="producer" class:ok={hasContradiction}>
<span class="p-dot"></span> contradiction.detected
<span class="p-state">
{hasContradiction ? 'fired this run' : 'no contradiction in this run'}
</span>
</li>
<li class="producer caveat" class:ok={hasDream}>
<span class="p-dot"></span> dream.patch
<span class="p-state">
{hasDream ? 'fired this run' : 'No dream run in this trace'}
</span>
</li>
<li class="producer caveat" class:ok={hasVeto}>
<span class="p-dot"></span> sanhedrin.veto
<span class="p-state">
{hasVeto ? 'fired this run' : 'No veto producer connected (optional Sanhedrin hook, off by default)'}
</span>
</li>
</ul>
</div>
<!-- Receipts — the nutrition label behind this run's retrievals -->
{#if receipts.length}
<div class="receipts-panel glass" use:reveal>
<h3 class="panel-title">
Receipts <span class="text-dim">— proof behind retrievals</span>
</h3>
<div class="receipts-grid">
{#each receipts.slice(0, 2) as r (r.receipt_id)}
<ReceiptCard receipt={r} />
{/each}
</div>
</div>
{/if}
<!-- Full event log -->
<div class="log glass" use:reveal>
<h3 class="panel-title">Event log</h3>
<ol class="log-list">
{#each detail.events as ev, i (i)}
<li
class="log-row"
class:active={i === scrubIndex}
class:dim={i > scrubIndex}
style:--c={eventColor(ev.type)}
>
<button class="log-btn" onclick={() => (scrubIndex = i)}>
<span class="log-glyph">{eventGlyph(ev.type)}</span>
<span class="log-label">{eventLabel(ev.type)}</span>
<span class="log-summary">{eventSummary(ev)}</span>
<span class="log-t">+{relativeMs(ev.at, startAt)}ms</span>
</button>
</li>
{/each}
</ol>
</div>
{/if}
</section>
</div>
{:else}
<!-- ░░ PROOF MODE — clean launch-footage view ░░ -->
<div class="proof-stage glass" use:reveal>
<div class="proof-headline">
<span class="dot big" class:live={$isConnected}></span>
<code class="proof-run">{$liveRunId ?? 'awaiting run…'}</code>
</div>
{#if $lastTraceEvent}
{@const ev = $lastTraceEvent.data?.event as TraceEvent}
<div class="proof-event" style:--c={eventColor(ev?.type)}>
<span class="proof-glyph">{eventGlyph(ev?.type)}</span>
<div>
<div class="proof-ev-label">{eventLabel(ev?.type)}</div>
<div class="proof-ev-sum">{eventSummary(ev)}</div>
</div>
</div>
{/if}
<div class="proof-counter">
<AnimatedNumber value={$traceEvents.length} />
<span class="proof-counter-label">trace events</span>
</div>
<p class="proof-tagline">Watch the agent think. Watch memory change. Watch the receipt prove why.</p>
</div>
{/if}
</div>
<style>
.mode-toggle,
.export-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
font-size: 0.78rem;
font-weight: 600;
border-radius: 9px;
border: 1px solid color-mix(in oklab, var(--color-synapse) 30%, transparent);
background: color-mix(in oklab, var(--color-synapse) 8%, transparent);
color: var(--color-synapse-glow);
cursor: pointer;
transition: all 0.18s ease;
}
.mode-toggle:hover,
.export-btn:hover:not(:disabled) {
background: color-mix(in oklab, var(--color-synapse) 18%, transparent);
transform: translateY(-1px);
}
.mode-toggle.on {
background: var(--color-synapse);
color: white;
}
.export-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Spine header */
.spine {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1px;
border-radius: 14px;
padding: 14px 18px;
margin-bottom: 18px;
overflow: hidden;
}
.spine-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 2px 14px;
}
.spine-label {
font-size: 0.66rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-dim, #8b8ba7);
}
.spine-value {
font-size: 0.92rem;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 7px;
}
.spine-run {
font-size: 0.82rem;
color: var(--color-synapse-glow);
word-break: break-all;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #64748b;
box-shadow: 0 0 0 0 transparent;
}
.dot.live {
background: var(--color-recall, #10b981);
animation: ping 2s ease-in-out infinite;
}
.dot.big {
width: 12px;
height: 12px;
}
@keyframes ping {
0%,
100% {
box-shadow: 0 0 0 0 color-mix(in oklab, var(--color-recall) 60%, transparent);
}
50% {
box-shadow: 0 0 0 6px transparent;
}
}
.ev-chip {
font-size: 0.74rem;
font-weight: 700;
padding: 2px 8px;
border-radius: 6px;
color: var(--c);
background: color-mix(in oklab, var(--c) 14%, transparent);
border: 1px solid color-mix(in oklab, var(--c) 35%, transparent);
}
/* Two-column layout */
.layout {
display: grid;
grid-template-columns: 260px 1fr;
gap: 16px;
align-items: start;
}
@media (max-width: 860px) {
.layout {
grid-template-columns: 1fr;
}
}
.glass {
background: color-mix(in oklab, var(--color-void, #050510) 55%, transparent);
border: 1px solid color-mix(in oklab, white 8%, transparent);
backdrop-filter: blur(12px);
border-radius: 14px;
}
.panel-title {
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.02em;
margin: 0 0 12px;
}
.text-dim {
color: var(--color-text-dim, #8b8ba7);
font-weight: 400;
}
.empty {
font-size: 0.82rem;
color: var(--color-text-dim, #8b8ba7);
line-height: 1.5;
}
.runs {
padding: 16px;
position: sticky;
top: 16px;
max-height: calc(100vh - 40px);
overflow-y: auto;
}
.runs ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.run-row {
width: 100%;
text-align: left;
padding: 9px 11px;
border-radius: 10px;
border: 1px solid transparent;
background: color-mix(in oklab, white 3%, transparent);
cursor: pointer;
transition: all 0.16s ease;
}
.run-row:hover {
background: color-mix(in oklab, var(--color-synapse) 12%, transparent);
}
.run-row.active {
border-color: color-mix(in oklab, var(--color-synapse) 45%, transparent);
background: color-mix(in oklab, var(--color-synapse) 16%, transparent);
}
.run-top {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
}
.run-id {
font-size: 0.78rem;
color: var(--color-synapse-glow);
}
.run-tool {
font-size: 0.7rem;
color: var(--color-text-dim, #8b8ba7);
white-space: nowrap;
}
.run-stats {
display: flex;
gap: 8px;
margin-top: 5px;
font-size: 0.68rem;
color: var(--color-text-dim, #8b8ba7);
}
.s-recall {
color: var(--color-recall, #10b981);
}
.s-suppress {
color: #a78bfa;
}
.s-write {
color: #38bdf8;
}
.s-veto {
color: #f43f5e;
}
.replay {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
}
.center-msg {
padding: 40px;
text-align: center;
color: var(--color-text-dim, #8b8ba7);
}
.center-msg.err {
color: #f87171;
}
/* Scrubber */
.scrubber {
padding: 16px 18px;
}
.scrub-head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 10px;
font-size: 0.82rem;
}
.scrub-time {
color: var(--color-synapse-glow);
font-variant-numeric: tabular-nums;
}
.scrub-range {
width: 100%;
accent-color: var(--color-synapse);
}
.ticks {
display: flex;
gap: 2px;
margin-top: 10px;
height: 22px;
align-items: stretch;
}
.tick {
flex: 1;
min-width: 2px;
border: none;
border-radius: 2px;
background: color-mix(in oklab, var(--c) 22%, transparent);
cursor: pointer;
transition: all 0.16s ease;
padding: 0;
}
.tick.past {
background: var(--c);
box-shadow: 0 0 6px -1px var(--c);
}
.tick:hover {
transform: scaleY(1.25);
}
/* Event detail */
.event-detail {
padding: 16px 18px;
border-left: 3px solid var(--c);
}
.ed-head {
display: flex;
align-items: center;
gap: 10px;
}
.ed-glyph {
font-size: 1.2rem;
color: var(--c);
}
.ed-label {
font-weight: 700;
color: var(--c);
}
.ed-time {
margin-left: auto;
font-size: 0.74rem;
color: var(--color-text-dim, #8b8ba7);
}
.ed-summary {
margin: 10px 0 0;
font-size: 0.9rem;
line-height: 1.5;
}
.ids-grid {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 12px;
}
.id-chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 8px;
border-radius: 7px;
background: color-mix(in oklab, var(--color-recall) calc(var(--a, 0) * 30%), transparent);
border: 1px solid color-mix(in oklab, var(--color-recall) 30%, transparent);
font-size: 0.74rem;
}
.id-chip small {
color: var(--color-recall, #10b981);
font-weight: 700;
}
.contra {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
font-size: 0.8rem;
}
.winner {
color: var(--color-recall, #10b981);
font-weight: 700;
}
.vs {
color: var(--color-text-dim, #8b8ba7);
}
.loser {
color: #fb7185;
text-decoration: line-through;
opacity: 0.7;
}
.veto-evidence {
display: flex;
gap: 6px;
margin-top: 12px;
flex-wrap: wrap;
}
.veto-evidence code {
padding: 2px 7px;
border-radius: 6px;
background: color-mix(in oklab, #f43f5e 12%, transparent);
font-size: 0.74rem;
}
/* Pulse */
.pulse {
padding: 16px 18px;
}
.pulse-grid {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.pulse-node {
padding: 3px 8px;
border-radius: 7px;
background: color-mix(in oklab, var(--color-synapse) 12%, transparent);
border: 1px solid color-mix(in oklab, var(--color-synapse) 28%, transparent);
font-size: 0.74rem;
color: var(--color-synapse-glow);
animation: pulse-in 0.4s ease;
}
@keyframes pulse-in {
from {
transform: scale(0.85);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
/* Receipts panel */
.receipts-panel {
padding: 16px 18px;
}
.receipts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 12px;
}
/* Producers — honest event-source status */
.producers {
padding: 16px 18px;
}
.producer-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 7px;
}
.producer {
display: flex;
align-items: center;
gap: 9px;
font-size: 0.78rem;
color: var(--color-text-dim, #8b8ba7);
}
.producer .p-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #475569;
flex-shrink: 0;
}
.producer.ok {
color: var(--color-text, #e2e2f0);
}
.producer.ok .p-dot {
background: var(--color-recall, #10b981);
box-shadow: 0 0 6px -1px var(--color-recall, #10b981);
}
.producer.caveat:not(.ok) .p-dot {
background: #f59e0b;
opacity: 0.6;
}
.p-state {
margin-left: auto;
font-size: 0.7rem;
font-style: italic;
text-align: right;
color: var(--color-text-dim, #8b8ba7);
}
.producer.caveat:not(.ok) .p-state {
color: #f59e0b;
}
/* Log */
.log {
padding: 16px 18px;
}
.log-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.log-row {
border-radius: 8px;
border-left: 2px solid var(--c);
transition: all 0.16s ease;
}
.log-row.active {
background: color-mix(in oklab, var(--c) 14%, transparent);
}
.log-row.dim {
opacity: 0.4;
}
.log-btn {
width: 100%;
display: grid;
grid-template-columns: 22px 110px 1fr auto;
align-items: center;
gap: 8px;
padding: 7px 10px;
background: none;
border: none;
cursor: pointer;
text-align: left;
font-size: 0.8rem;
}
.log-glyph {
color: var(--c);
text-align: center;
}
.log-label {
font-weight: 600;
color: var(--c);
}
.log-summary {
color: var(--color-text-dim, #8b8ba7);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.log-t {
font-size: 0.7rem;
color: var(--color-text-dim, #8b8ba7);
font-variant-numeric: tabular-nums;
}
/* Proof mode */
.proof-stage {
padding: 60px 40px;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 26px;
min-height: 50vh;
justify-content: center;
}
.proof-headline {
display: flex;
align-items: center;
gap: 12px;
}
.proof-run {
font-size: 1.4rem;
color: var(--color-synapse-glow);
font-weight: 700;
}
.proof-event {
display: flex;
align-items: center;
gap: 16px;
padding: 18px 26px;
border-radius: 14px;
border: 1px solid color-mix(in oklab, var(--c) 40%, transparent);
background: color-mix(in oklab, var(--c) 10%, transparent);
}
.proof-glyph {
font-size: 2rem;
color: var(--c);
}
.proof-ev-label {
font-size: 1.1rem;
font-weight: 700;
color: var(--c);
}
.proof-ev-sum {
font-size: 0.85rem;
color: var(--color-text-dim, #8b8ba7);
}
.proof-counter {
font-size: 3.4rem;
font-weight: 800;
line-height: 1;
color: var(--color-synapse-glow);
}
.proof-counter-label {
display: block;
font-size: 0.8rem;
font-weight: 500;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-text-dim, #8b8ba7);
margin-top: 8px;
}
.proof-tagline {
font-size: 1rem;
color: var(--color-text-dim, #c0c0d8);
max-width: 32ch;
line-height: 1.5;
}
</style>

View file

@ -102,11 +102,17 @@
}
onMount(() => {
const requestedMode = new URLSearchParams(window.location.search).get('colorMode');
const sp = new URLSearchParams(window.location.search);
const requestedMode = sp.get('colorMode');
if (isColorMode(requestedMode)) {
colorMode = requestedMode;
}
void loadGraph();
// "Open receipt in Cinema" deep-links here with ?center=<memoryId>, so
// the graph loads centered on the receipt's primary memory and the
// (protected) Cinema flythrough starts from that exact node. We do not
// touch MemoryCinema itself — only seed the graph it renders.
const center = sp.get('center');
void loadGraph(undefined, center || undefined);
});
function isColorMode(value: string | null): value is ColorMode {

View file

@ -0,0 +1,740 @@
<script lang="ts">
// ═══════════════════════════════════════════════════════════════════════
// MEMORY PRs — approve changes to an agent's brain like code.
// ───────────────────────────────────────────────────────────────────────
// Vestige auto-remembers ordinary context, but opens a Memory PR when the
// agent tries to rewrite its own brain. This is the cognitive immune
// system: a GitHub-style diff UI for cognition, with Promote / Merge /
// Supersede / Quarantine / Forget / Ask Agent Why, plus a one-click
// Fast / Risk-Gated / Paranoid mode toggle.
// ═══════════════════════════════════════════════════════════════════════
import { onMount } from 'svelte';
import PageHeader from '$components/PageHeader.svelte';
import Icon from '$components/Icon.svelte';
import AnimatedNumber from '$components/AnimatedNumber.svelte';
import { reveal } from '$lib/actions/reveal';
import { toasts } from '$lib/stores/toast';
import {
api,
type MemoryPr,
type MemoryPrAction,
type ReviewMode
} from '$lib/stores/api';
import { memoryPrEvents } from '$lib/stores/websocket';
let prs = $state<MemoryPr[]>([]);
let pendingCount = $state(0);
let mode = $state<ReviewMode>('risk_gated');
let statusFilter = $state<string>('pending');
let selected = $state<MemoryPr | null>(null);
let why = $state<{ code: string; detail: string }[] | null>(null);
let loading = $state(false);
let busy = $state<string | null>(null);
const modes: { id: ReviewMode; label: string; blurb: string }[] = [
{ id: 'fast', label: 'Fast', blurb: 'Every write auto-lands. No review.' },
{
id: 'risk_gated',
label: 'Risk-Gated',
blurb: 'Ordinary writes land; risky ones open a PR.'
},
{ id: 'paranoid', label: 'Paranoid', blurb: 'Every write waits for approval.' }
];
const statuses = ['pending', 'promoted', 'merged', 'superseded', 'quarantined', 'forgotten'];
const kindLabel: Record<string, string> = {
new_fact: 'New fact',
strengthened_fact: 'Strengthened',
contradiction_detected: 'Contradiction',
memory_superseded: 'Supersede',
edge_added: 'New edge',
node_decayed: 'Decayed',
dream_consolidation: 'Dream merge'
};
const actions: { id: MemoryPrAction; label: string; cls: string }[] = [
{ id: 'promote', label: 'Promote', cls: 'promote' },
{ id: 'merge', label: 'Merge', cls: 'merge' },
{ id: 'supersede', label: 'Supersede', cls: 'supersede' },
{ id: 'quarantine', label: 'Quarantine', cls: 'quarantine' },
{ id: 'forget', label: 'Forget', cls: 'forget' },
{ id: 'ask_agent_why', label: 'Ask Agent Why', cls: 'why' }
];
async function load() {
loading = true;
try {
const res = await api.memoryPrs.list(statusFilter || undefined, 100);
prs = res.prs;
pendingCount = res.pendingCount;
mode = res.mode;
if (selected) selected = prs.find((p) => p.id === selected!.id) ?? null;
} finally {
loading = false;
}
}
async function setMode(m: ReviewMode) {
const res = await api.memoryPrs.setMode(m);
mode = res.mode;
toasts.push({
type: 'MemoryPromoted',
title: 'Review mode updated',
body: `Memory PR gating is now ${modes.find((x) => x.id === m)?.label}.`,
color: '#6366f1',
dwellMs: 4000
});
}
async function act(pr: MemoryPr, action: MemoryPrAction) {
busy = `${pr.id}:${action}`;
try {
if (action === 'ask_agent_why') {
const res = (await api.memoryPrs.act(pr.id, action)) as {
why: { code: string; detail: string }[];
};
why = res.why;
selected = pr;
return;
}
await api.memoryPrs.act(pr.id, action);
toasts.push({
type: 'MemoryPromoted',
title: `PR ${action}d`,
body: pr.title,
color: actionColor(action),
dwellMs: 4500
});
why = null;
await load();
} catch (e) {
toasts.push({
type: 'MemoryDemoted',
title: 'Action failed',
body: String(e),
color: '#f43f5e',
dwellMs: 5000
});
} finally {
busy = null;
}
}
function actionColor(action: MemoryPrAction): string {
switch (action) {
case 'promote':
return '#10b981';
case 'merge':
return '#6366f1';
case 'supersede':
return '#38bdf8';
case 'quarantine':
return '#f59e0b';
case 'forget':
return '#f43f5e';
default:
return '#818cf8';
}
}
function select(pr: MemoryPr) {
selected = pr;
why = null;
}
// Live: a Memory PR opened or was decided elsewhere — refresh the queue.
$effect(() => {
if ($memoryPrEvents.length) void load();
});
onMount(load);
// Diff rendering helpers
function diffContent(pr: MemoryPr): string {
const node = pr.diff?.node as { content?: string } | undefined;
return node?.content ?? '';
}
function diffNodeType(pr: MemoryPr): string {
const node = pr.diff?.node as { nodeType?: string } | undefined;
return node?.nodeType ?? '';
}
function diffTags(pr: MemoryPr): string[] {
const node = pr.diff?.node as { tags?: string[] } | undefined;
return node?.tags ?? [];
}
</script>
<div class="mx-auto max-w-6xl px-5 py-6">
<PageHeader
icon="memorypr"
title="Memory PRs"
subtitle="Approve changes to the agent's brain like code."
accent="synapse"
>
<span class="pending-badge" class:has={pendingCount > 0}>
<AnimatedNumber value={pendingCount} /> pending
</span>
</PageHeader>
<!-- ░░ THE KILLER LINE ░░ -->
<div class="manifesto" use:reveal>
Vestige <strong>auto-remembers ordinary context</strong>, but opens a
<strong>Memory PR</strong> when the agent tries to <strong>rewrite its own brain</strong>.
<span class="manifesto-note">
Risky writes are <strong>quarantine-reviewed</strong>: recorded for audit, but held
out of retrieval until you decide — influence suspended, history preserved.
</span>
</div>
<!-- ░░ MODE TOGGLE ░░ -->
<div class="modes glass" use:reveal>
{#each modes as m (m.id)}
<button class="mode" class:on={mode === m.id} onclick={() => setMode(m.id)}>
<span class="mode-label">{m.label}</span>
<span class="mode-blurb">{m.blurb}</span>
</button>
{/each}
</div>
<!-- ░░ STATUS FILTER ░░ -->
<div class="filters" use:reveal>
{#each statuses as s (s)}
<button
class="filter"
class:on={statusFilter === s}
onclick={() => {
statusFilter = s;
load();
}}
>
{s}
</button>
{/each}
<button
class="filter"
class:on={statusFilter === ''}
onclick={() => {
statusFilter = '';
load();
}}
>
all
</button>
</div>
<div class="layout">
<!-- ░░ PR LIST ░░ -->
<aside class="pr-list glass" use:reveal>
{#if loading}
<p class="empty">Loading…</p>
{:else if prs.length === 0}
<p class="empty">
{#if statusFilter === 'pending'}
No pending Memory PRs. The brain is up to date — ordinary writes
are landing automatically.
{:else}
No {statusFilter || ''} PRs.
{/if}
</p>
{:else}
<ul>
{#each prs as pr (pr.id)}
<li>
<button
class="pr-row"
class:active={selected?.id === pr.id}
onclick={() => select(pr)}
>
<div class="pr-row-top">
<span class="pr-kind kind-{pr.kind}">{kindLabel[pr.kind] ?? pr.kind}</span>
<span class="pr-status st-{pr.status}">{pr.status}</span>
</div>
<div class="pr-title">{pr.title}</div>
{#if pr.signals.length}
<div class="pr-sig-count">{pr.signals.length} risk signal{pr.signals.length > 1 ? 's' : ''}</div>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
</aside>
<!-- ░░ PR DIFF DETAIL ░░ -->
<section class="pr-detail">
{#if !selected}
<div class="glass center-msg">Select a Memory PR to review the diff.</div>
{:else}
<div class="glass diff-card" use:reveal>
<div class="diff-head">
<span class="pr-kind kind-{selected.kind}">{kindLabel[selected.kind] ?? selected.kind}</span>
<span class="pr-status st-{selected.status}">{selected.status}</span>
{#if selected.run_id}
<a class="run-link" href={`/blackbox`} title="View the run that produced this">
<Icon name="blackbox" size={13} /> {selected.run_id.replace('run_', '').slice(0, 8)}
</a>
{/if}
</div>
<h2 class="diff-title">{selected.title}</h2>
<!-- The cognition diff -->
<div class="diff-body">
<div class="diff-meta">
{#if diffNodeType(selected)}
<span class="meta-pill">type: {diffNodeType(selected)}</span>
{/if}
{#each diffTags(selected) as t (t)}
<span class="meta-pill tag">#{t}</span>
{/each}
</div>
{#if diffContent(selected)}
<pre class="diff-add"><span class="gutter">+</span>{diffContent(selected)}</pre>
{/if}
</div>
<!-- Self-explaining risk signals -->
{#if selected.signals.length}
<div class="signals">
<span class="signals-title">Why this opened</span>
{#each selected.signals as sig (sig.code)}
<div class="signal">
<code class="sig-code">{sig.code}</code>
<span class="sig-detail">{sig.detail}</span>
</div>
{/each}
</div>
{/if}
<!-- Ask Agent Why response -->
{#if why}
<div class="why-box">
<span class="why-title">Agent's reasoning</span>
{#each why as w (w.code)}
<div class="signal">
<code class="sig-code">{w.code}</code>
<span class="sig-detail">{w.detail}</span>
</div>
{/each}
</div>
{/if}
<!-- Action buttons -->
{#if selected.status === 'pending'}
<div class="actions">
{#each actions as a (a.id)}
<button
class="action {a.cls}"
disabled={busy === `${selected.id}:${a.id}`}
onclick={() => act(selected!, a.id)}
>
{a.label}
</button>
{/each}
</div>
{:else}
<div class="decided">
Decided: <strong>{selected.decision ?? selected.status}</strong>
{#if selected.decided_at}
<span class="text-dim">· {new Date(selected.decided_at).toLocaleString()}</span>
{/if}
</div>
{/if}
</div>
{/if}
</section>
</div>
</div>
<style>
.pending-badge {
font-size: 0.8rem;
font-weight: 700;
padding: 5px 11px;
border-radius: 8px;
color: var(--color-text-dim, #8b8ba7);
border: 1px solid color-mix(in oklab, white 10%, transparent);
}
.pending-badge.has {
color: #f59e0b;
border-color: color-mix(in oklab, #f59e0b 40%, transparent);
background: color-mix(in oklab, #f59e0b 10%, transparent);
}
.manifesto {
font-size: 1.05rem;
line-height: 1.55;
color: var(--color-text, #e2e2f0);
padding: 16px 20px;
border-radius: 12px;
margin-bottom: 16px;
background: linear-gradient(
100deg,
color-mix(in oklab, var(--color-synapse) 14%, transparent),
transparent 70%
);
border: 1px solid color-mix(in oklab, var(--color-synapse) 20%, transparent);
}
.manifesto strong {
color: var(--color-synapse-glow, #818cf8);
}
.manifesto-note {
display: block;
margin-top: 8px;
font-size: 0.82rem;
line-height: 1.5;
color: var(--color-text-dim, #c0c0d8);
}
.manifesto-note strong {
color: #f59e0b;
}
.glass {
background: color-mix(in oklab, var(--color-void, #050510) 55%, transparent);
border: 1px solid color-mix(in oklab, white 8%, transparent);
backdrop-filter: blur(12px);
border-radius: 14px;
}
.modes {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
padding: 8px;
margin-bottom: 14px;
}
@media (max-width: 640px) {
.modes {
grid-template-columns: 1fr;
}
}
.mode {
display: flex;
flex-direction: column;
gap: 3px;
padding: 11px 14px;
border-radius: 10px;
border: 1px solid transparent;
background: color-mix(in oklab, white 3%, transparent);
cursor: pointer;
text-align: left;
transition: all 0.16s ease;
}
.mode:hover {
background: color-mix(in oklab, var(--color-synapse) 10%, transparent);
}
.mode.on {
border-color: color-mix(in oklab, var(--color-synapse) 50%, transparent);
background: color-mix(in oklab, var(--color-synapse) 18%, transparent);
}
.mode-label {
font-weight: 700;
font-size: 0.9rem;
}
.mode-blurb {
font-size: 0.72rem;
color: var(--color-text-dim, #8b8ba7);
line-height: 1.35;
}
.filters {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 14px;
}
.filter {
font-size: 0.74rem;
padding: 5px 11px;
border-radius: 7px;
border: 1px solid color-mix(in oklab, white 8%, transparent);
background: transparent;
color: var(--color-text-dim, #8b8ba7);
cursor: pointer;
text-transform: capitalize;
transition: all 0.16s ease;
}
.filter:hover {
color: var(--color-text, #e2e2f0);
}
.filter.on {
color: var(--color-synapse-glow, #818cf8);
border-color: color-mix(in oklab, var(--color-synapse) 45%, transparent);
background: color-mix(in oklab, var(--color-synapse) 12%, transparent);
}
.layout {
display: grid;
grid-template-columns: 300px 1fr;
gap: 16px;
align-items: start;
}
@media (max-width: 860px) {
.layout {
grid-template-columns: 1fr;
}
}
.pr-list {
padding: 12px;
position: sticky;
top: 16px;
max-height: calc(100vh - 40px);
overflow-y: auto;
}
.pr-list ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.empty {
font-size: 0.82rem;
color: var(--color-text-dim, #8b8ba7);
line-height: 1.5;
padding: 8px;
}
.pr-row {
width: 100%;
text-align: left;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid transparent;
background: color-mix(in oklab, white 3%, transparent);
cursor: pointer;
transition: all 0.16s ease;
}
.pr-row:hover {
background: color-mix(in oklab, var(--color-synapse) 10%, transparent);
}
.pr-row.active {
border-color: color-mix(in oklab, var(--color-synapse) 45%, transparent);
background: color-mix(in oklab, var(--color-synapse) 15%, transparent);
}
.pr-row-top {
display: flex;
justify-content: space-between;
gap: 8px;
margin-bottom: 5px;
}
.pr-kind {
font-size: 0.66rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 2px 7px;
border-radius: 5px;
background: color-mix(in oklab, var(--color-synapse) 16%, transparent);
color: var(--color-synapse-glow, #818cf8);
}
.kind-contradiction_detected {
background: color-mix(in oklab, #fb7185 16%, transparent);
color: #fb7185;
}
.kind-memory_superseded {
background: color-mix(in oklab, #38bdf8 16%, transparent);
color: #38bdf8;
}
.kind-dream_consolidation {
background: color-mix(in oklab, #c084fc 16%, transparent);
color: #c084fc;
}
.pr-status {
font-size: 0.64rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-dim, #8b8ba7);
align-self: center;
}
.st-pending {
color: #f59e0b;
}
.st-promoted {
color: #10b981;
}
.st-forgotten,
.st-quarantined {
color: #f43f5e;
}
.pr-title {
font-size: 0.84rem;
line-height: 1.35;
color: var(--color-text, #e2e2f0);
}
.pr-sig-count {
font-size: 0.7rem;
color: #f59e0b;
margin-top: 5px;
}
.pr-detail {
min-width: 0;
}
.center-msg {
padding: 50px;
text-align: center;
color: var(--color-text-dim, #8b8ba7);
}
.diff-card {
padding: 20px 22px;
}
.diff-head {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.run-link {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: auto;
font-size: 0.74rem;
color: var(--color-synapse-glow, #818cf8);
text-decoration: none;
padding: 3px 8px;
border-radius: 6px;
background: color-mix(in oklab, var(--color-synapse) 10%, transparent);
}
.diff-title {
font-size: 1.15rem;
font-weight: 700;
margin: 12px 0 16px;
line-height: 1.4;
}
.diff-body {
margin-bottom: 16px;
}
.diff-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 10px;
}
.meta-pill {
font-size: 0.72rem;
padding: 2px 8px;
border-radius: 6px;
background: color-mix(in oklab, white 6%, transparent);
color: var(--color-text-dim, #c0c0d8);
}
.meta-pill.tag {
color: var(--color-synapse-glow, #818cf8);
}
.diff-add {
margin: 0;
padding: 12px 14px 12px 36px;
position: relative;
border-radius: 9px;
background: color-mix(in oklab, #10b981 9%, transparent);
border-left: 3px solid #10b981;
font-size: 0.85rem;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--font-mono, monospace);
color: var(--color-text, #e2e2f0);
}
.diff-add .gutter {
position: absolute;
left: 12px;
color: #10b981;
font-weight: 700;
}
.signals,
.why-box {
margin-bottom: 16px;
padding: 12px 14px;
border-radius: 10px;
background: color-mix(in oklab, #f59e0b 8%, transparent);
border: 1px solid color-mix(in oklab, #f59e0b 22%, transparent);
}
.why-box {
background: color-mix(in oklab, var(--color-synapse) 8%, transparent);
border-color: color-mix(in oklab, var(--color-synapse) 22%, transparent);
}
.signals-title,
.why-title {
display: block;
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #f59e0b;
margin-bottom: 8px;
}
.why-title {
color: var(--color-synapse-glow, #818cf8);
}
.signal {
display: flex;
gap: 8px;
align-items: baseline;
padding: 3px 0;
}
.sig-code {
font-size: 0.7rem;
color: #f59e0b;
white-space: nowrap;
}
.sig-detail {
font-size: 0.82rem;
line-height: 1.4;
color: var(--color-text, #e2e2f0);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.action {
font-size: 0.8rem;
font-weight: 600;
padding: 8px 14px;
border-radius: 9px;
border: 1px solid color-mix(in oklab, var(--ac, #6366f1) 40%, transparent);
background: color-mix(in oklab, var(--ac, #6366f1) 10%, transparent);
color: var(--ac, #818cf8);
cursor: pointer;
transition: all 0.16s ease;
}
.action:hover:not(:disabled) {
background: color-mix(in oklab, var(--ac, #6366f1) 22%, transparent);
transform: translateY(-1px);
}
.action:disabled {
opacity: 0.5;
cursor: wait;
}
.action.promote {
--ac: #10b981;
}
.action.merge {
--ac: #6366f1;
}
.action.supersede {
--ac: #38bdf8;
}
.action.quarantine {
--ac: #f59e0b;
}
.action.forget {
--ac: #f43f5e;
}
.action.why {
--ac: #818cf8;
}
.decided {
font-size: 0.88rem;
padding: 10px 14px;
border-radius: 9px;
background: color-mix(in oklab, white 4%, transparent);
}
.text-dim {
color: var(--color-text-dim, #8b8ba7);
}
</style>

View file

@ -98,6 +98,8 @@
// set reused the same Unicode glyph across multiple items; every entry here
// now has a distinct silhouette that reads instantly.
const nav: { href: string; label: string; icon: IconName; shortcut: string }[] = [
{ href: '/blackbox', label: 'Black Box', icon: 'blackbox', shortcut: 'B' },
{ href: '/memory-prs', label: 'Memory PRs', icon: 'memorypr', shortcut: 'Q' },
{ href: '/graph', label: 'Graph', icon: 'graph', shortcut: 'G' },
{ href: '/reasoning', label: 'Reasoning', icon: 'reasoning', shortcut: 'R' },
{ href: '/memories', label: 'Memories', icon: 'memories', shortcut: 'M' },

View file

@ -0,0 +1,87 @@
# Vestige Agent Black Box — Proof Pack (2026-06-22)
> **Public claim (frozen):** Vestige records real MCP memory activity into a
> replayable local trace, with receipts and reviewable risky writes.
>
> We do **not** claim Sanhedrin vetoes or dream patches are live by default.
> Those producers are optional and off by default — the UI says so explicitly.
This pack is captured from a **live** Vestige build on branch
`feat/agent-black-box` — a real `vestige-mcp` process with the dashboard
enabled, driven by real MCP `tools/call` traffic. Nothing here is mocked.
## The receipt chain — one runId, every hop
The money guarantee: a single `runId` (`run_proof`) crosses every layer,
byte-identical. Verified two ways — by the files in this folder, and by the
deterministic regression test `test_full_spine_one_runid_crosses_every_hop`
(crates/vestige-mcp/src/server.rs).
| Hop | Layer | Evidence in this pack |
|----|-------|------|
| 1 | MCP tool output (`runId` + `traceUri`) | every tool result; see test HOP 1 |
| 2 | SQLite `agent_traces` rows | `trace.json` (`runId: run_proof`, 10 events) |
| 3 | WebSocket broadcast | `websocket-events.jsonl` (6 `TraceEvent` lines, each with `run_id`) |
| 4 | `/api/traces/:runId` response | `trace.json` is the export of that endpoint |
| 5 | dashboard render | screenshots (Black Box timeline = the 10 events) |
| 6 | `vestige://trace/{runId}` MCP resource | test HOP 5 resolves the same id |
## Files
| File | What it proves |
|------|----------------|
| `status.json` | the live server health at capture time |
| `trace.json` | the full `.vestige-trace.json` export — 10 real events in order |
| `receipt.json` | a real retrieval receipt (`r_2026_06_22_runproof`, 5 retrieved, decay medium) |
| `memory_pr.json` | the risky auth write → Memory PR, **promoted** through UI→API→SQLite, signal `sensitive_topic` |
| `websocket-events.jsonl` | the live WS stream: `TraceEvent`×6, `MemoryPrOpened`, `MemoryPrDecided`, `MemoryCreated`, `MemoryUpdated` |
| `screenshots/` | Graph, Black Box, Receipts (in PR), Memory PRs — see `screenshots/README.md` |
## Per-feature honesty: real / caveat / stub
| Feature | Status | Notes |
|---------|--------|-------|
| `mcp.call` trace | **REAL** | every tools/call records one; args **hashed**, never stored raw |
| `memory.write` trace | **REAL** | fires on smart_ingest/ingest, memory promote/demote/edit, codebase remember_*, AND destructive purge/delete |
| `memory.retrieve` trace | **REAL** | fires on deep_reference/search, with per-id activation |
| `memory.suppress` trace | **REAL** | recorded path; fires when retrieval suppresses |
| `contradiction.detected` trace | **REAL** | fires when deep_reference surfaces a contradiction pair; UI says "no contradiction in this run" when none |
| Memory Receipts | **REAL** | built from real scored memories + trust, persisted, attached to output |
| Risk-gated Memory PRs | **REAL** | quarantine review: commit-then-suppress, audit preserved, influence suspended. Promote verified end-to-end (releases the memory, even past the 24h window). Destructive purge/delete also open a PR. PR content is **redacted** for sensitive writes (preview + hash, never the raw secret) |
| Fast / Risk-Gated / Paranoid modes | **REAL** | persisted to `<data_dir>/review_mode.json`; Risk-Gated is the default |
| WebSocket broadcast | **REAL** | proven by `websocket-events.jsonl` + a unit test |
| `vestige://trace/{runId}` resource | **REAL** | proven by the full-spine test |
| `sanhedrin.veto` trace | **CAVEAT** | extraction code is real + unit-tested, but the Sanhedrin verifier is an optional hook, **off by default** — no producer is connected, and the UI says exactly that |
| `dream.patch` trace | **REAL** (proven 2026-06-22) | a real `dream` run over 6 memories produced one `dream.patch` event under `run_dream_proof` — see `dream-trace.json` (last event), `dream-websocket-events.jsonl`, and `screenshots/dream-producers.png` where the row flips to "fired this run". The UI still shows "No dream run in this trace" for runs where no dream executed. |
| Graph-pulse "Open receipt in Cinema" | **REAL (deep-link)** | navigates the graph centered on the receipt's primary memory; MemoryCinema itself is unchanged |
No feature is stubbed. The two CAVEATs are real plumbing whose upstream
producer is intentionally off by default — surfaced as explicit UI states, not
empty mystery.
## dream.patch — proven with a real dream run (2026-06-22)
Bounded follow-up: a single real `dream` consolidation flipped the `dream.patch`
producer from "quiet" to a recorded live event, same runId, every hop.
- 6 related memories seeded under `run_dream_proof`, then one `dream` call.
- The dream produced one consolidation insight → one `dream.patch` event:
`dream:RecurringPattern:5d941c7f+a41aca72+b029fe53+6167f2c3+1117dd4e+e0782442`
(the real insight type + the six source memories it bridged).
- SQLite: `dream-trace.json` (14 events, last is `dream.patch`).
- API: `/api/traces/run_dream_proof/export``dream-trace.json`.
- WebSocket: `dream-websocket-events.jsonl` (the `dream.patch` TraceEvent).
- Dashboard: `screenshots/black-box-dream.png` + `screenshots/dream-producers.png`
(the producers row shows **dream.patch · fired this run**).
`dream.patch` is real but not live-by-default: it fires only when a dream
actually runs. The UI says so for runs where it didn't.
## Reproduce
1. `VESTIGE_DATA_DIR=<tmp> VESTIGE_DASHBOARD_ENABLED=true vestige-mcp` (stdio).
2. `initialize`, then drive `smart_ingest` / `deep_reference` calls with a
`runId` argument.
3. A sensitive-topic write (auth/security/money/identity/…) opens a Memory PR.
4. `curl /api/traces/<runId>/export` → the `.vestige-trace.json`.
5. `cargo test -p vestige-mcp test_full_spine_one_runid_crosses_every_hop`.

View file

@ -0,0 +1,198 @@
# Agent Black Box — Review Bundle
**Branch:** `feat/agent-black-box` (head = branch tip)
**Base (review against):** `9e92a5999ada37bed9b4820bb25b7748b417411c` (the
`feat/dashboard-bleeding-edge` tip this branched from)
**Status:** feature work **frozen**. No quarantine-constellation work has
started.
Start here, then read `PROOF.md` (the per-feature real/caveat/stub ledger) and
open the screenshots.
> **Update — review findings addressed.** A full multi-agent review found 7
> real issues (4 blockers). All are fixed and tested; this bundle was
> **re-captured from a single run (`run_proof`)** so trace.json,
> websocket-events.jsonl, and memory_pr.json now all carry the same runId. See
> "Review findings addressed" below.
---
## Frozen public claim
> Vestige records real MCP memory activity into a replayable local trace, with
> receipts and reviewable risky writes.
We do **not** claim Sanhedrin vetoes are live by default. `dream.patch` is real
but fires only when a dream actually runs (proven below; the UI says so when no
dream ran).
---
## What's in this bundle
| File | What it is |
|------|------------|
| `REVIEW.md` | this file — the entry point |
| `PROOF.md` | per-feature real/caveat/stub ledger + reproduce steps |
| `status.json` | live server `/api/health` at capture time |
| `trace.json` | `.vestige-trace.json` export of `run_proof` (10 real events) |
| `receipt.json` | a real retrieval receipt (`r_2026_06_22_runproof`) |
| `memory_pr.json` | the risky auth write → Memory PR, **promoted** UI→API→SQLite |
| `websocket-events.jsonl` | live WS stream: `TraceEvent`×6, `MemoryPrOpened`, `MemoryPrDecided`, … |
| `dream-trace.json` | `run_dream_proof` export — 14 events, last is `dream.patch` |
| `dream-websocket-events.jsonl` | live WS stream containing the `dream.patch` `TraceEvent` |
| `screenshots/black-box.png` | Black Box tab (spine header, scrubber, producers, log) |
| `screenshots/receipts.png` | the `ReceiptCard` with real data + "Open receipt in Cinema" |
| `screenshots/memory-prs.png` | Memory PRs: diff, "Why this opened", `Decided: promote` |
| `screenshots/graph.png` | live graph constellation + Memory Cinema (unchanged) |
| `screenshots/black-box-dream.png` | Black Box on the dream run |
| `screenshots/dream-producers.png` | producers panel — `dream.patch · fired this run` |
---
## Caveat ledger (the honest part)
| Producer | Status | Why |
|----------|--------|-----|
| `mcp.call`, `memory.write`, `memory.retrieve`, `memory.suppress` | **REAL** | fire on real tool traffic; args hashed, never stored raw |
| `contradiction.detected` | **REAL** | fires when deep_reference surfaces a contradiction; UI shows "no contradiction in this run" otherwise |
| Memory Receipts | **REAL** | built from real scored memories + trust, persisted, attached to output |
| Risk-gated Memory PRs (quarantine review) | **REAL** | commit-then-suppress; audit preserved, influence suspended; Promote verified end-to-end |
| WebSocket broadcast | **REAL** | `websocket-events.jsonl` + unit test |
| `vestige://trace/{runId}` resource | **REAL** | full-spine test hop 5 |
| `dream.patch` | **REAL** (not live-by-default) | proven by `run_dream_proof`; fires only when a dream runs; UI says so otherwise |
| `sanhedrin.veto` | **CAVEAT** | extraction is real + unit-tested, but the Sanhedrin verifier is an **optional hook, off by default** — no producer connected; UI says exactly that |
No feature is stubbed. The one remaining caveat is surfaced as an explicit UI
state, not an empty space.
---
## Command receipts (run live at 2026-06-22T22:57:59Z)
Toolchain: `rustc 1.95.0` · `cargo 1.95.0` · `node v24.12.0` · `pnpm 10.33.0`.
```
$ cargo test --workspace --lib
test result: ok. 529 passed; 0 failed; 0 ignored; 0 measured (vestige-core)
test result: ok. 33 passed; 0 failed; 0 ignored; 0 measured (tests/e2e unit)
test result: ok. 428 passed; 0 failed; 0 ignored; 0 measured (vestige-mcp)
# 990 lib tests, 0 failures
$ cargo clippy --workspace -- -D warnings
Finished `dev` profile ... (EXIT 0, zero warnings)
$ pnpm --filter @vestige/dashboard check
COMPLETED 905 FILES 0 ERRORS 0 WARNINGS 0 FILES_WITH_PROBLEMS
$ pnpm --filter @vestige/dashboard build
✓ built in 4.15s ... ✔ done
$ cargo test -p vestige-mcp test_full_spine_one_runid_crosses_every_hop
test server::tests::test_full_spine_one_runid_crosses_every_hop ... ok
$ cargo test -p vestige-mcp trace_recorder::tests::extract_dream
test ...extract_dream_proposals_empty_when_not_dream_tool ... ok
test ...extract_dream_proposals_from_real_insights_shape ... ok
$ cargo test -p vestige-core trace
test result: ok. 27 passed; 0 failed (trace schema, receipt, review)
```
Only statuses with a receipt above are credited. Nothing is claimed from memory.
---
## Review surface (what changed)
The Black Box work sits in a series of commits on top of the base. To see the
exact, current diff (build artifacts + this proof bundle excluded):
```
$ git diff --stat 9e92a59..HEAD -- ':!apps/dashboard/build' ':!blackbox-proof-2026-06-22'
# ~27 source files (Rust + SvelteKit). Run this against the branch tip for the
# live count — it grows as review fixes land.
```
Commits (oldest first) — run `git log --oneline 9e92a59..HEAD` for the live,
authoritative list; the series so far:
- `80c823a` feat: Agent Black Box + Receipts + risk-gated Memory PRs
- `b89beee` proof: Proof Lock — full-spine test, honest UI states, proof pack
- `140b15f` proof: dream.patch proven live with a real dream run
- `cadffb4` docs: package the review bundle — REVIEW.md entry point
- `8f7bed0` fix: review blockers B1B7 + re-capture proof bundle
- `6a0173d` fix: C1 unconditional quarantine release + C2 trace destructive writes
- `…` fix: C2-deep (gate destructive writes post-delete) + PRIV (redact PR content)
The hashes above are point-in-time; the branch tip is the source of truth.
Key files to review:
- **Core (pure logic):** `crates/vestige-core/src/trace/{mod,receipt,review}.rs`
- **Persistence:** `crates/vestige-core/src/storage/trace_store.rs` + `migrations.rs` (V18)
- **MCP wiring:** `crates/vestige-mcp/src/trace_recorder.rs`, `server.rs` (dispatch),
`resources/trace.rs`
- **Dashboard API:** `crates/vestige-mcp/src/dashboard/{handlers,events,mod}.rs`
- **UI:** `apps/dashboard/src/routes/(app)/{blackbox,memory-prs}/+page.svelte`,
`lib/components/{ReceiptCard.svelte,blackbox-helpers.ts}`
---
## Suggested review checklist
- [ ] **Spine integrity:** read `test_full_spine_one_runid_crosses_every_hop`
(crates/vestige-mcp/src/server.rs) — does it actually assert the runId is
byte-identical at all five hops?
- [ ] **Privacy:** confirm `mcp.call` stores only a hash of args
(`trace_recorder::hash_args`), never raw args.
- [ ] **Risk taxonomy:** review `classify_write` + `WriteContext`
(crates/vestige-core/src/trace/review.rs) — is the sensitive-topic /
contradiction / supersede gating correct and not over-broad?
- [ ] **Quarantine semantics:** confirm risky writes are committed-then-suppressed
(audit preserved), not silently dropped, and the copy says so.
- [ ] **Migration safety:** V18 is additive; `MIGRATIONS.last().version` is used
by the migration tests (no hard-coded version literals left).
- [ ] **Local-first defaults:** Risk-Gated is default; Sanhedrin/dream producers
stay optional and off by default; nothing forces heavy models.
- [ ] **No protected code touched:** `MemoryCinema.svelte` and `graph/cinema/*`
are unchanged; the graph page only gained an additive `?center=` param.
---
## Review findings addressed (2026-06-22)
A full read-only review (multiple parallel agents, both Rust and dashboard)
found 7 real issues — 4 blockers. All fixed and tested:
| # | Severity | Finding | Fix | Proof |
|---|----------|---------|-----|-------|
| B1 | blocker | Promoting a Memory PR didn't unsuppress the quarantined memory — UI said "promoted" while the memory stayed out of retrieval | `act_on_memory_pr` now calls `reverse_suppression(subject_id)` on accept actions (promote/merge/supersede); `MemoryPrAction::releases_memory()` encodes the rule | live: PR response `subjectReleased: true`, SQLite `suppression_count: 0`; tests `promote_releases_a_quarantined_memory_end_to_end`, `only_accept_actions_release_the_memory` |
| B2 | blocker | memory promote/demote (returns `action`, not `decision`) and `codebase` writes bypassed the write-trace + gate | `extract_writes` reads `action` too, filtered by `is_write_decision`; `is_write_tool` includes `codebase` | tests `extract_writes_recognizes_action_shape_b2`, `extract_writes_ignores_read_actions_b2`, `write_tool_set_includes_codebase_b2` |
| B3 | blocker | Receipt ids collided within a run (`r_<date>_<runId>` + `INSERT OR REPLACE`) — later receipt overwrote earlier | id is now `r_<date>_<runId8>_<unique6>` | live: two receipts in `run_proof` have distinct ids; test `receipt_ids_unique_within_a_run_b3` |
| B4 | blocker | Proof bundle mis-assembled: `trace.json`=`run_proof` but `websocket-events.jsonl`=`run_proof2` | re-captured the whole bundle from one run | all artifacts now carry `run_proof` (verified) |
| B5 | P2 | Black Box receipts panel showed global latest, not the selected run's | `list_receipts_for_run` + `/api/receipts?run=` + page uses `listForRun(runId)` | live: `?run=run_proof` returns only that run; test `receipts_are_listable_per_run_b5` |
| B6 | P2 | `SENSITIVE_TOPICS` substring match false-fired ("tokenizer"→token, "author"→auth) | word-boundary matching | tests `sensitive_topic_word_boundary_no_false_positives_b6`, `..._still_catches_real_b6` |
| B7 | P3 | `set_review_mode` non-atomic write; export filename used raw `run_id` | `write_atomic` (temp+rename); filename sanitized; static routes declared before dynamic | covered by build + the atomic-write helper's existing use |
| C1 | blocker | B1's release used `reverse_suppression`, which **refuses past the 24h labile window** — a PR promoted late stayed suppressed | new `release_quarantine(id)`: unconditional release (no time limit), used by the PR handler instead | test `release_quarantine_works_past_the_labile_window_c1` (proves reverse_suppression refuses but release_quarantine succeeds at +100h) |
| C2 | blocker | `memory` `purge`/`delete` (destructive removal) bypassed the write-trace + gate | added purge/purged/delete/deleted/forget/forgotten to `is_write_decision` | test `extract_writes_recognizes_destructive_actions_c2` |
| C2-deep | blocker | C2 made purge *trace*, but `gate_writes` did `get_node→skip` on the (already-deleted) row, so a destructive write still **never opened a PR** | gate now treats a missing node as gateable for destructive decisions (builds the context from the decision, marks `forgets`); the PR records the removal with `deleted:true` | test `gate_opens_pr_for_destructive_write_after_node_deleted_c2`; **live:** purging a node opened a PR (`kind: node_decayed`, `deleted: true`) |
| PRIV | blocker | `gate_writes` copied **full `node.content`** into the PR `diff` + `title` — a real secret would leak into the `memory_prs` table and any exported proof bundle | PR now stores a truncated **preview** + a **content hash**; sensitive-topic-gated writes are fully **redacted** (`[redacted — sensitive content…]`); the committed `memory_pr.json` was re-captured and contains no secret | tests `gate_redacts_sensitive_content_in_pr_priv`, `content_preview_redacts_sensitive_and_truncates`; **live + bundle scan:** no secret string anywhere |
One earlier (self-)review claim was **withdrawn**: the `/api/memory-prs/mode`
vs `/{id}` route order is *not* a functional bug — axum 0.8 / matchit gives
static segments priority. Reordered for clarity only.
Net after fixes (B1B7 + C1/C2 + C2-deep + PRIV): **1007 lib tests pass, clippy `-D warnings` clean, dashboard
check + build clean.**
## Reproduce (any reviewer, locally)
```sh
# 1. run a real server with the dashboard on
VESTIGE_DATA_DIR=$(mktemp -d) VESTIGE_DASHBOARD_ENABLED=true vestige-mcp # stdio JSON-RPC
# 2. initialize, then drive tools/call with a runId arg (smart_ingest, deep_reference)
# 3. a sensitive-topic write opens a Memory PR; promote it via the dashboard
# 4. export the trace:
curl -s http://127.0.0.1:3927/api/traces/<runId>/export
# 5. for dream.patch: seed >=5 memories, then call the `dream` tool with the same runId
# 6. run the regression: cargo test -p vestige-mcp test_full_spine_one_runid_crosses_every_hop
```

View file

@ -0,0 +1 @@
{"events":[{"argsHash":"ec88652d34b9d5b6","at":1782168489775,"runId":"run_dream_proof","tool":"smart_ingest","type":"mcp.call"},{"at":1782168489889,"diff":{"decision":"create"},"id":"b029fe53-5f78-4c49-8100-62e6243a19ae","runId":"run_dream_proof","source":"agent","type":"memory.write"},{"argsHash":"10134f9d053e1139","at":1782168490987,"runId":"run_dream_proof","tool":"smart_ingest","type":"mcp.call"},{"at":1782168491091,"diff":{"decision":"create"},"id":"6167f2c3-c567-4ec4-b63d-d0b6660173fc","runId":"run_dream_proof","source":"agent","type":"memory.write"},{"argsHash":"a687fb99ed887e10","at":1782168492200,"runId":"run_dream_proof","tool":"smart_ingest","type":"mcp.call"},{"at":1782168492297,"diff":{"decision":"create"},"id":"e0782442-0ce0-4a54-94db-4814ae392bbd","runId":"run_dream_proof","source":"agent","type":"memory.write"},{"argsHash":"a672de9e54d138f9","at":1782168493413,"runId":"run_dream_proof","tool":"smart_ingest","type":"mcp.call"},{"at":1782168493496,"diff":{"decision":"create"},"id":"a41aca72-7758-4f07-8da0-2e16469efc81","runId":"run_dream_proof","source":"agent","type":"memory.write"},{"argsHash":"d5878d7adad2cfe1","at":1782168494628,"runId":"run_dream_proof","tool":"smart_ingest","type":"mcp.call"},{"at":1782168494724,"diff":{"decision":"create"},"id":"5d941c7f-c75c-4c89-acc1-af1b95d3a4de","runId":"run_dream_proof","source":"agent","type":"memory.write"},{"argsHash":"14c334181d37393d","at":1782168495841,"runId":"run_dream_proof","tool":"smart_ingest","type":"mcp.call"},{"at":1782168495922,"diff":{"decision":"create"},"id":"1117dd4e-11f7-434d-b0b7-2b5bcbd841d4","runId":"run_dream_proof","source":"agent","type":"memory.write"},{"argsHash":"1071da7bf3583db3","at":1782168511374,"runId":"run_dream_proof","tool":"dream","type":"mcp.call"},{"at":1782168511375,"proposalIds":["dream:RecurringPattern:5d941c7f+a41aca72+b029fe53+6167f2c3+1117dd4e+e0782442"],"runId":"run_dream_proof","type":"dream.patch"}],"exportedAt":"2026-06-22T22:50:09.588334+00:00","format":"vestige-trace","runId":"run_dream_proof","summary":{"eventCount":14,"firstTool":"smart_ingest","lastAt":1782168511375,"retrievedCount":0,"startedAt":1782168489775,"suppressedCount":0,"vetoCount":0,"writeCount":6},"version":1}

View file

@ -0,0 +1,5 @@
{"data": {"timestamp": "2026-06-22T22:48:29.454773+00:00", "version": "2.1.27"}, "type": "Connected"}
{"type": "TraceEvent", "data": {"run_id": "run_dream_proof", "seq": 12, "event": {"type": "mcp.call", "runId": "run_dream_proof", "tool": "dream", "argsHash": "1071da7bf3583db3", "at": 1782168511374}, "timestamp": "2026-06-22T22:48:31.374680Z"}}
{"type": "DreamStarted", "data": {"memory_count": 6, "timestamp": "2026-06-22T22:48:31.374852Z"}}
{"type": "DreamCompleted", "data": {"memories_replayed": 6, "connections_found": 0, "insights_generated": 1, "duration_ms": 0, "timestamp": "2026-06-22T22:48:31.375855Z"}}
{"type": "TraceEvent", "data": {"run_id": "run_dream_proof", "seq": 13, "event": {"type": "dream.patch", "runId": "run_dream_proof", "proposalIds": ["dream:RecurringPattern:5d941c7f+a41aca72+b029fe53+6167f2c3+1117dd4e+e0782442"], "at": 1782168511375}, "timestamp": "2026-06-22T22:48:31.375973Z"}}

View file

View file

View file

@ -0,0 +1,16 @@
# Proof Pack Screenshots
Captured with Playwright (`@playwright/test`, headless Chromium, 1440×1700 @2x)
from the **live** Vestige dashboard at `http://localhost:5173/dashboard`,
proxying to a real `vestige-mcp` server with real trace data.
| File | Tab | Shows |
|------|-----|-------|
| `black-box.png` | Black Box | spine header (WebSocket Connected), run picker (`proof`/`proof2`), timeline scrubber + colored ticks, current event detail, memory pulse, **event producers** (with honest `dream.patch`/`sanhedrin.veto` off-by-default states), receipts panel, full event log |
| `receipts.png` | Black Box → Receipts | a real `ReceiptCard`: receipt id, retrieved/suppressed/trust-floor, activation path, retrieved ids, "Open receipt in Cinema" |
| `memory-prs.png` | Memory PRs | killer line + quarantine-review note, Fast/Risk-Gated/Paranoid modes, status filters, PR rows, cognition diff, "Why this opened" signal (`sensitive_topic`), `Decided: promote` |
| `graph.png` | Graph | the live WebGL memory constellation + Memory Cinema button (unchanged) |
Re-capture: start the dev server (`pnpm --filter @vestige/dashboard dev`),
point its `/api` proxy at a running `vestige-mcp` with trace data, then run the
capture script (see PROOF.md "Reproduce").

Binary file not shown.

After

Width:  |  Height:  |  Size: 556 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 679 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 579 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

View file

View file

View file

@ -0,0 +1,8 @@
{"data": {"timestamp": "2026-06-23T00:43:28.238141+00:00", "version": "2.1.27"}, "type": "Connected"}
{"type": "TraceEvent", "data": {"run_id": "run_proof", "seq": 14, "event": {"type": "mcp.call", "runId": "run_proof", "tool": "deep_reference", "argsHash": "13a31297fe007a2e", "at": 1782175410153}, "timestamp": "2026-06-23T00:43:30.154710Z"}}
{"type": "TraceEvent", "data": {"run_id": "run_proof", "seq": 15, "event": {"type": "memory.retrieve", "runId": "run_proof", "ids": ["591c638e-1fc7-4b6d-bcb3-b7fcb6c0c7b3", "6aa12b99-270e-4fb6-b523-9f01b0bee16b", "26a3c976-043b-4915-accf-ae098c8dc66b", "76c13cba-7b88-4ce7-b7de-0a906d372806"], "activation": {"26a3c976-043b-4915-accf-ae098c8dc66b": 0.62, "591c638e-1fc7-4b6d-bcb3-b7fcb6c0c7b3": 0.62, "6aa12b99-270e-4fb6-b523-9f01b0bee16b": 0.53, "76c13cba-7b88-4ce7-b7de-0a906d372806": 0.62}, "at": 1782175410209}, "timestamp": "2026-06-23T00:43:30.209554Z"}}
{"type": "TraceEvent", "data": {"run_id": "run_proof", "seq": 16, "event": {"type": "mcp.call", "runId": "run_proof", "tool": "search", "argsHash": "ac19c646baf0673d", "at": 1782175411167}, "timestamp": "2026-06-23T00:43:31.167561Z"}}
{"type": "SearchPerformed", "data": {"query": "dashboard", "result_count": 2, "result_ids": ["26a3c976-043b-4915-accf-ae098c8dc66b", "76c13cba-7b88-4ce7-b7de-0a906d372806"], "duration_ms": 0, "timestamp": "2026-06-23T00:43:31.182829Z"}}
{"type": "TraceEvent", "data": {"run_id": "run_proof", "seq": 17, "event": {"type": "memory.retrieve", "runId": "run_proof", "ids": ["26a3c976-043b-4915-accf-ae098c8dc66b", "76c13cba-7b88-4ce7-b7de-0a906d372806"], "activation": {}, "at": 1782175411182}, "timestamp": "2026-06-23T00:43:31.182933Z"}}
{"type": "MemoryUnsuppressed", "data": {"id": "6aa12b99-270e-4fb6-b523-9f01b0bee16b", "remaining_count": 0, "timestamp": "2026-06-23T00:46:18.338387Z"}}
{"type": "MemoryPrDecided", "data": {"id": "pr_31ab4c15f1694504bf33be82715bee03", "decision": "promote", "status": "promoted", "timestamp": "2026-06-23T00:46:18.338407Z"}}

View file

@ -90,6 +90,10 @@ pub mod fts;
pub mod memory;
pub mod storage;
/// Agent Black Box, Memory Receipts & Memory PRs — the cognitive flight
/// recorder, immune system, and reviewable-diff model for agent memory.
pub mod trace;
#[cfg(feature = "embeddings")]
#[cfg_attr(docsrs, doc(cfg(feature = "embeddings")))]
pub mod embeddings;
@ -160,6 +164,13 @@ pub use fsrs::{
// Configuration (vestige.toml output profiles / defaults)
pub use config::{CONFIG_FILE, OutputConfig, OutputDefaults, OutputProfile, VestigeConfig};
// Agent Black Box / Receipts / Memory PRs (the cognitive flight recorder)
pub use trace::{
classify_write, DecayRisk, MemoryPr, MemoryPrAction, MemoryPrKind, MemoryPrStatus,
MemoryTraceEvent, Receipt, ReceiptMutation, ReviewMode, RiskClass, RiskSignal, SuppressReason,
SuppressedReceiptEntry, WriteContext, WriteSource, HIGH_TRUST_FLOOR, LOW_CONFIDENCE_FLOOR,
};
// Storage layer
pub use storage::{
ClassificationResult,
@ -191,6 +202,7 @@ pub use storage::{
Result,
SchedulingState,
SearchQuery,
AgentRunSummary,
SmartIngestResult,
SourceUpsertOutcome,
SourceUpsertResult,

View file

@ -89,6 +89,11 @@ pub const MIGRATIONS: &[Migration] = &[
description: "#57 Source envelope: provenance columns + connector cursor checkpoints for idempotent external-source sync",
up: MIGRATION_V17_UP,
},
Migration {
version: 18,
description: "Agent Black Box + Memory Receipts + Memory PRs: replayable run traces, retrieval receipts, risk-gated brain-change review queue",
up: MIGRATION_V18_UP,
},
];
/// A database migration
@ -1029,6 +1034,105 @@ pub const MIGRATION_V17_ALTER_COLUMNS: &[&str] = &[
"ALTER TABLE knowledge_nodes ADD COLUMN source_author TEXT",
];
/// V18: Agent Black Box + Memory Receipts + Memory PRs.
///
/// Three append-only / review tables that turn Vestige into the *black box,
/// immune system, and cinematic debugger for agent memory*:
///
/// - `agent_traces` — one row per [`crate::trace::MemoryTraceEvent`], ordered by
/// `(run_id, seq)`. Append-only so a run replays exactly as the agent
/// experienced it. `payload` is the full serialized event; `event_type` and
/// `run_id` are denormalized for fast filtering and the `vestige://trace/{id}`
/// resource. `args_hash` (for `mcp.call`) is stored, never the raw args, so
/// traces can't leak prompt contents or secrets.
///
/// - `memory_receipts` — one row per retrieval receipt. `payload` holds the full
/// [`crate::trace::Receipt`]; the scalar columns (`trust_floor`, `decay_risk`)
/// are denormalized for list/sort without parsing JSON.
///
/// - `memory_prs` — the risk-gated review queue. A risky write (contradiction
/// vs high-trust, supersede/forget/merge/protect, sensitive topic, dream
/// consolidation, decay resurrection, low-confidence batch, weak-provenance
/// connector) lands here as `pending` instead of auto-committing. `diff` is the
/// structured before/after, `signals` is the self-explaining risk evidence,
/// `run_id` links the PR back to the black-box trace that produced it.
///
/// `memory_id` / `run_id` references are intentionally *not* foreign keys to
/// `knowledge_nodes`: forgetting or superseding a memory must never erase the
/// audit trail of the trace, receipt, or PR that touched it (same
/// audit-preserving stance as V15's composition tables).
const MIGRATION_V18_UP: &str = r#"
-- Black-box trace events: append-only, ordered by (run_id, seq).
CREATE TABLE IF NOT EXISTS agent_traces (
id TEXT PRIMARY KEY,
run_id TEXT NOT NULL,
seq INTEGER NOT NULL,
event_type TEXT NOT NULL, -- mcp.call | memory.retrieve | ...
tool TEXT, -- denormalized for mcp.call rows
payload TEXT NOT NULL, -- full serialized MemoryTraceEvent (JSON)
at INTEGER NOT NULL, -- wall-clock millis
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_agent_traces_run ON agent_traces(run_id, seq);
CREATE INDEX IF NOT EXISTS idx_agent_traces_type ON agent_traces(event_type);
CREATE INDEX IF NOT EXISTS idx_agent_traces_at ON agent_traces(at);
-- One row per agent run, for the Black Box run list (denormalized roll-up).
CREATE TABLE IF NOT EXISTS agent_runs (
run_id TEXT PRIMARY KEY,
first_tool TEXT,
event_count INTEGER NOT NULL DEFAULT 0,
retrieved_count INTEGER NOT NULL DEFAULT 0,
suppressed_count INTEGER NOT NULL DEFAULT 0,
write_count INTEGER NOT NULL DEFAULT 0,
veto_count INTEGER NOT NULL DEFAULT 0,
started_at INTEGER NOT NULL, -- millis of first event
last_at INTEGER NOT NULL, -- millis of latest event
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_agent_runs_last_at ON agent_runs(last_at DESC);
-- Retrieval receipts (the "nutrition label" for a piece of agent memory).
CREATE TABLE IF NOT EXISTS memory_receipts (
receipt_id TEXT PRIMARY KEY,
run_id TEXT, -- links to the trace, if any
tool TEXT,
query TEXT,
retrieved_count INTEGER NOT NULL DEFAULT 0,
suppressed_count INTEGER NOT NULL DEFAULT 0,
trust_floor REAL NOT NULL DEFAULT 0,
decay_risk TEXT NOT NULL DEFAULT 'low',
payload TEXT NOT NULL, -- full serialized Receipt (JSON)
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_memory_receipts_run ON memory_receipts(run_id);
CREATE INDEX IF NOT EXISTS idx_memory_receipts_created_at ON memory_receipts(created_at DESC);
-- Memory PRs: the risk-gated review queue for brain changes.
CREATE TABLE IF NOT EXISTS memory_prs (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL, -- new_fact | contradiction_detected | ...
status TEXT NOT NULL DEFAULT 'pending',
title TEXT NOT NULL,
subject_id TEXT, -- the memory this PR concerns, if any
run_id TEXT, -- the run that produced it
diff TEXT NOT NULL DEFAULT '{}', -- structured before/after (JSON)
signals TEXT NOT NULL DEFAULT '[]', -- self-explaining RiskSignal[] (JSON)
decision TEXT, -- promote | merge | supersede | ...
created_at TEXT NOT NULL,
decided_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_memory_prs_status ON memory_prs(status);
CREATE INDEX IF NOT EXISTS idx_memory_prs_kind ON memory_prs(kind);
CREATE INDEX IF NOT EXISTS idx_memory_prs_created_at ON memory_prs(created_at DESC);
UPDATE schema_version SET version = 18, applied_at = datetime('now');
"#;
/// Apply pending migrations
pub fn apply_migrations(conn: &rusqlite::Connection) -> rusqlite::Result<u32> {
let current_version = get_current_version(conn)?;
@ -1109,9 +1213,10 @@ mod tests {
// 1. schema_version advanced to the latest migration
let version = get_current_version(&conn).expect("read schema_version");
let latest = MIGRATIONS.last().unwrap().version;
assert_eq!(
version, 17,
"schema_version must be 17 after all migrations"
version, latest,
"schema_version must be the latest migration after all migrations"
);
// 2. knowledge_edges is gone (V11 drops it)
@ -1236,7 +1341,11 @@ mod tests {
// After replaying from V10, the schema advances to the latest version.
let version = get_current_version(&conn).expect("read schema_version");
assert_eq!(version, 17, "schema_version back at latest after replay");
assert_eq!(
version,
MIGRATIONS.last().unwrap().version,
"schema_version back at latest after replay"
);
}
#[test]
@ -1310,7 +1419,11 @@ mod tests {
// V16 uses CREATE TABLE IF NOT EXISTS and idempotent ALTER handling.
apply_migrations(&conn).expect("V16 replay must be idempotent");
let version = get_current_version(&conn).expect("read version");
assert_eq!(version, 17, "schema_version must be latest after replay");
assert_eq!(
version,
MIGRATIONS.last().unwrap().version,
"schema_version must be latest after replay"
);
}
#[test]
@ -1400,7 +1513,11 @@ mod tests {
.expect("rewind to 16");
apply_migrations(&conn).expect("V17 replay must be idempotent");
let version = get_current_version(&conn).expect("read version");
assert_eq!(version, 17, "schema_version must be 17 after replay");
assert_eq!(
version,
MIGRATIONS.last().unwrap().version,
"schema_version must be latest after replay"
);
}
#[test]

View file

@ -6,6 +6,7 @@ mod memory_store;
mod migrations;
mod portable;
mod sqlite;
mod trace_store;
pub use memory_store::{
ClassificationResult, Domain, HealthStatus, LocalMemoryStore, MemoryEdge, MemoryRecord,
@ -25,6 +26,7 @@ pub use sqlite::{
SmartIngestResult, SourceUpsertOutcome, SourceUpsertResult, SqliteMemoryStore,
StateTransitionRecord, StorageError,
};
pub use trace_store::AgentRunSummary;
/// Backwards-compatibility alias. Retained until Phase 4 completes so every
/// existing `Arc<Storage>` call site keeps compiling. Scheduled for removal

View file

@ -302,8 +302,11 @@ const VESTIGE_DISABLE_VECTOR_SEARCH: &str = "VESTIGE_DISABLE_VECTOR_SEARCH";
/// so the MCP layer can use `Arc<Storage>` instead of `Arc<Mutex<Storage>>`.
pub struct SqliteMemoryStore {
db_path: PathBuf,
writer: Mutex<Connection>,
reader: Mutex<Connection>,
// `pub(crate)` so the sibling `trace_store` module (Black Box / Receipts /
// Memory PRs CRUD) can lock the same writer/reader connections and follow
// the established store idiom without duplicating connection management.
pub(crate) writer: Mutex<Connection>,
pub(crate) reader: Mutex<Connection>,
scheduler: Mutex<FSRSScheduler>,
#[cfg(feature = "embeddings")]
embedding_service: EmbeddingService,
@ -1782,6 +1785,61 @@ impl SqliteMemoryStore {
.ok_or_else(|| StorageError::NotFound(id.to_string()))
}
/// Release a memory from quarantine **unconditionally** (no labile-window
/// limit), used when a Memory PR is approved.
///
/// Unlike [`Self::reverse_suppression`] (which models a time-bounded "undo"
/// of an active-forgetting decision and refuses after the labile window),
/// approving a quarantined risky write is an explicit reviewer decision that
/// must always restore the memory's retrieval influence — even days later.
/// Fully clears the suppression (count → 0, `suppressed_at` → NULL) and
/// restores strengths. A no-op (returns the node) if it isn't suppressed.
pub fn release_quarantine(&self, id: &str) -> Result<KnowledgeNode> {
let node = self
.get_node(id)?
.ok_or_else(|| StorageError::NotFound(id.to_string()))?;
if node.suppression_count == 0 && node.suppressed_at.is_none() {
// Nothing to release — idempotent.
return Ok(node);
}
{
let writer = self
.writer
.lock()
.map_err(|_| StorageError::Init("Writer lock poisoned".into()))?;
writer.execute(
"UPDATE knowledge_nodes SET
suppression_count = 0,
suppressed_at = NULL,
retrieval_strength = MIN(1.0, retrieval_strength + 0.15),
retention_strength = MIN(1.0, retention_strength + 0.10),
stability = stability * 1.25
WHERE id = ?1",
params![id],
)?;
}
let _ = self.log_access(id, "release_quarantine");
self.get_node(id)?
.ok_or_else(|| StorageError::NotFound(id.to_string()))
}
/// Test-only: backdate a node's `suppressed_at` to simulate a suppression
/// that happened long ago (e.g. to verify release works past the labile
/// window). `pub(crate)` so sibling test modules can reach it.
#[cfg(test)]
pub(crate) fn set_suppressed_at_for_test(&self, id: &str, when: DateTime<Utc>) {
if let Ok(writer) = self.writer.lock() {
let _ = writer.execute(
"UPDATE knowledge_nodes SET suppressed_at = ?1 WHERE id = ?2",
params![when.to_rfc3339(), id],
);
}
}
/// Count memories currently in a suppressed state (suppression_count > 0).
pub fn count_suppressed(&self) -> Result<usize> {
let reader = self

View file

@ -0,0 +1,732 @@
//! # Black Box / Receipts / Memory PRs — persistence
//!
//! CRUD for the three V18 tables (`agent_traces` + `agent_runs`,
//! `memory_receipts`, `memory_prs`) on [`SqliteMemoryStore`]. The pure data
//! model lives in [`crate::trace`]; this file is the storage half of the
//! Black Box, immune system, and cinematic debugger for agent memory.
//!
//! Every method follows the established store idiom: lock the writer/reader
//! `Mutex<Connection>`, `params![]`-bind, store timestamps as RFC3339 (and
//! event millis as INTEGER), serialize structured fields with `serde_json`, and
//! map rows back through a small closure.
use chrono::Utc;
use rusqlite::{params, OptionalExtension};
use uuid::Uuid;
use super::sqlite::SqliteMemoryStore;
use super::{Result, StorageError};
use crate::trace::{MemoryPr, MemoryPrAction, MemoryPrStatus, MemoryTraceEvent, Receipt};
/// A roll-up summary of one agent run, for the Black Box run list.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
pub struct AgentRunSummary {
/// The run id.
pub run_id: String,
/// The first tool invoked in the run (the run's "entry point").
pub first_tool: Option<String>,
/// Total events recorded.
pub event_count: i64,
/// Memories retrieved across the run.
pub retrieved_count: i64,
/// Memories suppressed across the run.
pub suppressed_count: i64,
/// Memory writes across the run.
pub write_count: i64,
/// Sanhedrin vetoes across the run.
pub veto_count: i64,
/// Millis of the first event.
pub started_at: i64,
/// Millis of the most recent event.
pub last_at: i64,
}
impl SqliteMemoryStore {
// ========================================================================
// BLACK BOX — trace events + run roll-up
// ========================================================================
/// Append one trace event to a run (append-only) and update the run
/// roll-up. Returns the assigned sequence number within the run.
///
/// `seq` is `MAX(seq)+1` for the run, computed under the writer lock so a
/// run's events stay totally ordered even under concurrent tool calls.
pub fn append_trace_event(&self, event: &MemoryTraceEvent) -> Result<i64> {
let now = Utc::now();
let run_id = event.run_id().to_string();
let event_type = event.kind();
let at = event.at();
let payload = serde_json::to_string(event)
.map_err(|e| StorageError::Init(format!("trace event serialize: {e}")))?;
let tool = match event {
MemoryTraceEvent::McpCall { tool, .. } => Some(tool.clone()),
_ => None,
};
// Roll-up deltas this event contributes.
let (d_retrieved, d_suppressed, d_write, d_veto) = match event {
MemoryTraceEvent::MemoryRetrieve { ids, .. } => (ids.len() as i64, 0, 0, 0),
MemoryTraceEvent::MemorySuppress { .. } => (0, 1, 0, 0),
MemoryTraceEvent::MemoryWrite { .. } => (0, 0, 1, 0),
MemoryTraceEvent::SanhedrinVeto { .. } => (0, 0, 0, 1),
_ => (0, 0, 0, 0),
};
let writer = self
.writer
.lock()
.map_err(|_| StorageError::Init("Writer lock poisoned".into()))?;
let seq: i64 = writer
.query_row(
"SELECT COALESCE(MAX(seq), -1) + 1 FROM agent_traces WHERE run_id = ?1",
params![run_id],
|r| r.get(0),
)
.unwrap_or(0);
writer.execute(
"INSERT INTO agent_traces (id, run_id, seq, event_type, tool, payload, at, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
Uuid::new_v4().to_string(),
run_id,
seq,
event_type,
tool,
payload,
at,
now.to_rfc3339(),
],
)?;
// Upsert the run roll-up. On first event the row is created with the
// event's tool as the entry point; subsequent events accumulate counts
// and advance `last_at`.
writer.execute(
"INSERT INTO agent_runs (run_id, first_tool, event_count, retrieved_count,
suppressed_count, write_count, veto_count, started_at, last_at, created_at)
VALUES (?1, ?2, 1, ?3, ?4, ?5, ?6, ?7, ?7, ?8)
ON CONFLICT(run_id) DO UPDATE SET
first_tool = COALESCE(agent_runs.first_tool, excluded.first_tool),
event_count = agent_runs.event_count + 1,
retrieved_count = agent_runs.retrieved_count + ?3,
suppressed_count = agent_runs.suppressed_count + ?4,
write_count = agent_runs.write_count + ?5,
veto_count = agent_runs.veto_count + ?6,
last_at = MAX(agent_runs.last_at, ?7)",
params![
run_id,
tool,
d_retrieved,
d_suppressed,
d_write,
d_veto,
at,
now.to_rfc3339(),
],
)?;
Ok(seq)
}
/// Fetch every event of a run, in sequence order. The black-box replay.
pub fn get_trace(&self, run_id: &str) -> Result<Vec<MemoryTraceEvent>> {
let reader = self
.reader
.lock()
.map_err(|_| StorageError::Init("Reader lock poisoned".into()))?;
let mut stmt = reader.prepare(
"SELECT payload FROM agent_traces WHERE run_id = ?1 ORDER BY seq ASC",
)?;
let rows = stmt.query_map(params![run_id], |row| {
let payload: String = row.get(0)?;
Ok(payload)
})?;
let mut out = Vec::new();
for r in rows {
let payload = r?;
if let Ok(ev) = serde_json::from_str::<MemoryTraceEvent>(&payload) {
out.push(ev);
}
}
Ok(out)
}
/// List recent runs, newest activity first.
pub fn list_agent_runs(&self, limit: usize) -> Result<Vec<AgentRunSummary>> {
let reader = self
.reader
.lock()
.map_err(|_| StorageError::Init("Reader lock poisoned".into()))?;
let mut stmt = reader.prepare(
"SELECT run_id, first_tool, event_count, retrieved_count, suppressed_count,
write_count, veto_count, started_at, last_at
FROM agent_runs ORDER BY last_at DESC LIMIT ?1",
)?;
let rows = stmt.query_map(params![limit as i64], Self::row_to_run_summary)?;
let mut out = Vec::new();
for r in rows {
out.push(r?);
}
Ok(out)
}
/// Fetch one run summary.
pub fn get_agent_run(&self, run_id: &str) -> Result<Option<AgentRunSummary>> {
let reader = self
.reader
.lock()
.map_err(|_| StorageError::Init("Reader lock poisoned".into()))?;
reader
.query_row(
"SELECT run_id, first_tool, event_count, retrieved_count, suppressed_count,
write_count, veto_count, started_at, last_at
FROM agent_runs WHERE run_id = ?1",
params![run_id],
Self::row_to_run_summary,
)
.optional()
.map_err(StorageError::from)
}
fn row_to_run_summary(row: &rusqlite::Row) -> rusqlite::Result<AgentRunSummary> {
Ok(AgentRunSummary {
run_id: row.get("run_id")?,
first_tool: row.get("first_tool").ok().flatten(),
event_count: row.get("event_count")?,
retrieved_count: row.get("retrieved_count")?,
suppressed_count: row.get("suppressed_count")?,
write_count: row.get("write_count")?,
veto_count: row.get("veto_count")?,
started_at: row.get("started_at")?,
last_at: row.get("last_at")?,
})
}
// ========================================================================
// MEMORY RECEIPTS
// ========================================================================
/// Persist a retrieval receipt. `run_id`/`tool`/`query` are denormalized
/// context for the dashboard; the full [`Receipt`] is stored as JSON.
pub fn save_receipt(
&self,
receipt: &Receipt,
run_id: Option<&str>,
tool: Option<&str>,
query: Option<&str>,
) -> Result<()> {
let payload = serde_json::to_string(receipt)
.map_err(|e| StorageError::Init(format!("receipt serialize: {e}")))?;
let writer = self
.writer
.lock()
.map_err(|_| StorageError::Init("Writer lock poisoned".into()))?;
writer.execute(
"INSERT OR REPLACE INTO memory_receipts
(receipt_id, run_id, tool, query, retrieved_count, suppressed_count,
trust_floor, decay_risk, payload, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
params![
receipt.receipt_id,
run_id,
tool,
query,
receipt.retrieved.len() as i64,
receipt.suppressed.len() as i64,
receipt.trust_floor,
receipt.decay_risk.as_str(),
payload,
Utc::now().to_rfc3339(),
],
)?;
Ok(())
}
/// Fetch one receipt by id.
pub fn get_receipt(&self, receipt_id: &str) -> Result<Option<Receipt>> {
let reader = self
.reader
.lock()
.map_err(|_| StorageError::Init("Reader lock poisoned".into()))?;
let payload: Option<String> = reader
.query_row(
"SELECT payload FROM memory_receipts WHERE receipt_id = ?1",
params![receipt_id],
|row| row.get(0),
)
.optional()?;
Ok(payload.and_then(|p| serde_json::from_str(&p).ok()))
}
/// List recent receipts, newest first.
pub fn list_receipts(&self, limit: usize) -> Result<Vec<Receipt>> {
let reader = self
.reader
.lock()
.map_err(|_| StorageError::Init("Reader lock poisoned".into()))?;
let mut stmt = reader.prepare(
"SELECT payload FROM memory_receipts ORDER BY created_at DESC LIMIT ?1",
)?;
let rows = stmt.query_map(params![limit as i64], |row| {
let p: String = row.get(0)?;
Ok(p)
})?;
let mut out = Vec::new();
for r in rows {
if let Ok(rc) = serde_json::from_str::<Receipt>(&r?) {
out.push(rc);
}
}
Ok(out)
}
/// List the receipts belonging to one run, newest first (B5). The Black Box
/// receipts panel uses this so the receipts it shows actually belong to the
/// selected run, not the global latest.
pub fn list_receipts_for_run(&self, run_id: &str, limit: usize) -> Result<Vec<Receipt>> {
let reader = self
.reader
.lock()
.map_err(|_| StorageError::Init("Reader lock poisoned".into()))?;
let mut stmt = reader.prepare(
"SELECT payload FROM memory_receipts WHERE run_id = ?1
ORDER BY created_at DESC LIMIT ?2",
)?;
let rows = stmt.query_map(params![run_id, limit as i64], |row| {
let p: String = row.get(0)?;
Ok(p)
})?;
let mut out = Vec::new();
for r in rows {
if let Ok(rc) = serde_json::from_str::<Receipt>(&r?) {
out.push(rc);
}
}
Ok(out)
}
// ========================================================================
// MEMORY PRs — the risk-gated review queue
// ========================================================================
/// Open (insert) a Memory PR.
pub fn save_memory_pr(&self, pr: &MemoryPr) -> Result<()> {
let diff = serde_json::to_string(&pr.diff).unwrap_or_else(|_| "{}".to_string());
let signals = serde_json::to_string(&pr.signals).unwrap_or_else(|_| "[]".to_string());
let writer = self
.writer
.lock()
.map_err(|_| StorageError::Init("Writer lock poisoned".into()))?;
writer.execute(
"INSERT OR REPLACE INTO memory_prs
(id, kind, status, title, subject_id, run_id, diff, signals,
decision, created_at, decided_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
params![
pr.id,
pr.kind.as_str(),
pr.status.as_str(),
pr.title,
pr.subject_id,
pr.run_id,
diff,
signals,
pr.decision
.and_then(|d| serde_json::to_value(d).ok())
.and_then(|v| v.as_str().map(|s| s.to_string())),
pr.created_at,
pr.decided_at,
],
)?;
Ok(())
}
/// Fetch one Memory PR by id.
pub fn get_memory_pr(&self, id: &str) -> Result<Option<MemoryPr>> {
let reader = self
.reader
.lock()
.map_err(|_| StorageError::Init("Reader lock poisoned".into()))?;
reader
.query_row(
"SELECT id, kind, status, title, subject_id, run_id, diff, signals,
decision, created_at, decided_at
FROM memory_prs WHERE id = ?1",
params![id],
Self::row_to_memory_pr,
)
.optional()
.map_err(StorageError::from)
}
/// List Memory PRs, optionally filtered by status, newest first.
pub fn list_memory_prs(
&self,
status: Option<MemoryPrStatus>,
limit: usize,
) -> Result<Vec<MemoryPr>> {
let reader = self
.reader
.lock()
.map_err(|_| StorageError::Init("Reader lock poisoned".into()))?;
let (sql, with_filter) = match status {
Some(_) => (
"SELECT id, kind, status, title, subject_id, run_id, diff, signals,
decision, created_at, decided_at
FROM memory_prs WHERE status = ?1 ORDER BY created_at DESC LIMIT ?2",
true,
),
None => (
"SELECT id, kind, status, title, subject_id, run_id, diff, signals,
decision, created_at, decided_at
FROM memory_prs ORDER BY created_at DESC LIMIT ?1",
false,
),
};
let mut stmt = reader.prepare(sql)?;
let mut out = Vec::new();
if with_filter {
let st = status.unwrap();
let rows =
stmt.query_map(params![st.as_str(), limit as i64], Self::row_to_memory_pr)?;
for r in rows {
out.push(r?);
}
} else {
let rows = stmt.query_map(params![limit as i64], Self::row_to_memory_pr)?;
for r in rows {
out.push(r?);
}
}
Ok(out)
}
/// Count pending Memory PRs (for the nav badge).
pub fn count_pending_memory_prs(&self) -> Result<i64> {
let reader = self
.reader
.lock()
.map_err(|_| StorageError::Init("Reader lock poisoned".into()))?;
let n: i64 = reader
.query_row(
"SELECT COUNT(*) FROM memory_prs WHERE status = 'pending'",
[],
|r| r.get(0),
)
.unwrap_or(0);
Ok(n)
}
/// Record a decision on a Memory PR, moving it out of `pending`. Returns the
/// updated PR. `AskAgentWhy` is read-only and never reaches here.
pub fn decide_memory_pr(&self, id: &str, action: MemoryPrAction) -> Result<MemoryPr> {
let new_status = action.resulting_status().ok_or_else(|| {
StorageError::Init("ask_agent_why is read-only and decides nothing".into())
})?;
let decision = serde_json::to_value(action)
.ok()
.and_then(|v| v.as_str().map(|s| s.to_string()))
.unwrap_or_default();
let now = Utc::now().to_rfc3339();
{
let writer = self
.writer
.lock()
.map_err(|_| StorageError::Init("Writer lock poisoned".into()))?;
let changed = writer.execute(
"UPDATE memory_prs SET status = ?1, decision = ?2, decided_at = ?3 WHERE id = ?4",
params![new_status.as_str(), decision, now, id],
)?;
if changed == 0 {
return Err(StorageError::NotFound(id.to_string()));
}
}
self.get_memory_pr(id)?
.ok_or_else(|| StorageError::NotFound(id.to_string()))
}
fn row_to_memory_pr(row: &rusqlite::Row) -> rusqlite::Result<MemoryPr> {
let kind_s: String = row.get("kind")?;
let status_s: String = row.get("status")?;
let diff_s: String = row.get("diff")?;
let signals_s: String = row.get("signals")?;
let decision_s: Option<String> = row.get("decision").ok().flatten();
let kind = crate::trace::MemoryPrKind::from_label(&kind_s)
.unwrap_or(crate::trace::MemoryPrKind::NewFact);
let status = serde_json::from_value(serde_json::Value::String(status_s))
.unwrap_or(MemoryPrStatus::Pending);
let diff: serde_json::Value = serde_json::from_str(&diff_s).unwrap_or(serde_json::json!({}));
let signals = serde_json::from_str(&signals_s).unwrap_or_default();
let decision = decision_s
.and_then(|s| serde_json::from_value(serde_json::Value::String(s)).ok());
Ok(MemoryPr {
id: row.get("id")?,
kind,
status,
title: row.get("title")?,
diff,
signals,
subject_id: row.get("subject_id").ok().flatten(),
run_id: row.get("run_id").ok().flatten(),
created_at: row.get("created_at")?,
decided_at: row.get("decided_at").ok().flatten(),
decision,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::trace::{
DecayRisk, MemoryPrKind, MemoryTraceEvent, Receipt, RiskSignal, SuppressReason,
SuppressedReceiptEntry,
};
fn store() -> SqliteMemoryStore {
// Temp-file store for isolated, fast tests (mirrors the existing
// sqlite.rs test helpers; there is no in-memory constructor).
let dir = tempfile::tempdir().unwrap();
SqliteMemoryStore::new(Some(dir.path().join("trace_test.db"))).expect("test store")
}
#[test]
fn trace_append_orders_and_rolls_up() {
let s = store();
let run = "run_abc";
s.append_trace_event(&MemoryTraceEvent::McpCall {
run_id: run.into(),
tool: "deep_reference".into(),
args_hash: "h".into(),
at: 100,
})
.unwrap();
let mut activation = std::collections::BTreeMap::new();
activation.insert("m1".to_string(), 0.9);
s.append_trace_event(&MemoryTraceEvent::MemoryRetrieve {
run_id: run.into(),
ids: vec!["m1".into(), "m2".into()],
activation,
at: 110,
})
.unwrap();
s.append_trace_event(&MemoryTraceEvent::MemorySuppress {
run_id: run.into(),
id: "m3".into(),
reason: SuppressReason::Contradicted,
at: 120,
})
.unwrap();
let events = s.get_trace(run).unwrap();
assert_eq!(events.len(), 3);
assert_eq!(events[0].kind(), "mcp.call");
assert_eq!(events[2].kind(), "memory.suppress");
let summary = s.get_agent_run(run).unwrap().unwrap();
assert_eq!(summary.first_tool.as_deref(), Some("deep_reference"));
assert_eq!(summary.event_count, 3);
assert_eq!(summary.retrieved_count, 2);
assert_eq!(summary.suppressed_count, 1);
assert_eq!(summary.started_at, 100);
assert_eq!(summary.last_at, 120);
let runs = s.list_agent_runs(10).unwrap();
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].run_id, run);
}
#[test]
fn receipt_roundtrips() {
let s = store();
let receipt = Receipt {
receipt_id: "r_2026_06_22_abc".into(),
retrieved: vec!["m1".into(), "m2".into()],
suppressed: vec![SuppressedReceiptEntry::new("m3", SuppressReason::LowTrust)],
activation_path: vec!["a -> b".into()],
trust_floor: 0.62,
decay_risk: DecayRisk::Medium,
mutations: vec![],
};
s.save_receipt(&receipt, Some("run_abc"), Some("search"), Some("q"))
.unwrap();
let got = s.get_receipt("r_2026_06_22_abc").unwrap().unwrap();
assert_eq!(got, receipt);
assert_eq!(s.list_receipts(10).unwrap().len(), 1);
}
#[test]
fn receipts_are_listable_per_run_b5() {
let s = store();
let mk = |id: &str| Receipt {
receipt_id: id.into(),
retrieved: vec!["m1".into()],
suppressed: vec![],
activation_path: vec![],
trust_floor: 0.9,
decay_risk: DecayRisk::Low,
mutations: vec![],
};
s.save_receipt(&mk("r_a1"), Some("run_a"), Some("search"), None)
.unwrap();
s.save_receipt(&mk("r_a2"), Some("run_a"), Some("search"), None)
.unwrap();
s.save_receipt(&mk("r_b1"), Some("run_b"), Some("search"), None)
.unwrap();
let run_a = s.list_receipts_for_run("run_a", 10).unwrap();
assert_eq!(run_a.len(), 2, "run_a has exactly its 2 receipts");
assert!(run_a.iter().all(|r| r.receipt_id.starts_with("r_a")));
let run_b = s.list_receipts_for_run("run_b", 10).unwrap();
assert_eq!(run_b.len(), 1, "run_b has only its own receipt");
assert_eq!(run_b[0].receipt_id, "r_b1");
// Global list still sees all three.
assert_eq!(s.list_receipts(10).unwrap().len(), 3);
}
#[test]
fn memory_pr_lifecycle() {
let s = store();
let pr = MemoryPr {
id: "pr_1".into(),
kind: MemoryPrKind::ContradictionDetected,
status: MemoryPrStatus::Pending,
title: "Agent wants to overwrite a high-trust fact".into(),
diff: serde_json::json!({"before": "x", "after": "y"}),
signals: vec![RiskSignal {
code: "contradicts_high_trust".into(),
detail: "Contradicts trust 0.9.".into(),
}],
subject_id: Some("m_old".into()),
run_id: Some("run_abc".into()),
created_at: Utc::now().to_rfc3339(),
decided_at: None,
decision: None,
};
s.save_memory_pr(&pr).unwrap();
assert_eq!(s.count_pending_memory_prs().unwrap(), 1);
let pending = s
.list_memory_prs(Some(MemoryPrStatus::Pending), 10)
.unwrap();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].signals[0].code, "contradicts_high_trust");
let decided = s.decide_memory_pr("pr_1", MemoryPrAction::Promote).unwrap();
assert_eq!(decided.status, MemoryPrStatus::Promoted);
assert_eq!(decided.decision, Some(MemoryPrAction::Promote));
assert!(decided.decided_at.is_some());
assert_eq!(s.count_pending_memory_prs().unwrap(), 0);
}
#[test]
fn promote_releases_a_quarantined_memory_end_to_end() {
// B1 regression: the full quarantine→release cycle at the storage layer.
// gate_writes suppresses a risky write; an accept action must reverse it.
let s = store();
let node = s
.ingest(crate::IngestInput {
content: "Risky write that got quarantined.".to_string(),
node_type: "fact".to_string(),
..Default::default()
})
.expect("ingest");
assert_eq!(node.suppression_count, 0, "fresh node not suppressed");
// Quarantine it (what gate_writes does for a risky write).
let suppressed = s.suppress_memory(&node.id).expect("suppress");
assert_eq!(
suppressed.suppression_count, 1,
"quarantined write is suppressed (held out of retrieval)"
);
// Promote = release. (The action releases_memory() == true; the handler
// calls release_quarantine on the subject.)
assert!(crate::MemoryPrAction::Promote.releases_memory());
let released = s
.release_quarantine(&node.id)
.expect("release quarantine");
assert_eq!(
released.suppression_count, 0,
"promoting the PR must release the memory — not leave it suppressed"
);
assert!(
released.suppressed_at.is_none(),
"release must clear suppressed_at"
);
}
#[test]
fn release_quarantine_works_past_the_labile_window_c1() {
// C1: a PR reviewed LATE (past the 24h active-forgetting labile window)
// must still release the memory. reverse_suppression refuses after the
// window; release_quarantine must not.
let s = store();
let node = s
.ingest(crate::IngestInput {
content: "Risky write quarantined and reviewed days later.".to_string(),
node_type: "fact".to_string(),
..Default::default()
})
.expect("ingest");
s.suppress_memory(&node.id).expect("suppress");
// Backdate suppressed_at to 100h ago — well past any labile window.
s.set_suppressed_at_for_test(&node.id, chrono::Utc::now() - chrono::Duration::hours(100));
// reverse_suppression refuses (window expired)...
assert!(
s.reverse_suppression(&node.id, 24).is_err(),
"reverse_suppression must refuse past the labile window"
);
// ...but release_quarantine still releases it.
let released = s.release_quarantine(&node.id).expect("release past window");
assert_eq!(released.suppression_count, 0);
assert!(released.suppressed_at.is_none());
}
#[test]
fn release_quarantine_is_idempotent_on_unsuppressed() {
let s = store();
let node = s
.ingest(crate::IngestInput {
content: "Never suppressed.".to_string(),
node_type: "fact".to_string(),
..Default::default()
})
.expect("ingest");
// No-op when not suppressed — must not error.
let same = s.release_quarantine(&node.id).expect("idempotent release");
assert_eq!(same.suppression_count, 0);
}
#[test]
fn ask_agent_why_is_not_a_decision() {
let s = store();
let pr = MemoryPr {
id: "pr_2".into(),
kind: MemoryPrKind::NewFact,
status: MemoryPrStatus::Pending,
title: "t".into(),
diff: serde_json::json!({}),
signals: vec![],
subject_id: None,
run_id: None,
created_at: Utc::now().to_rfc3339(),
decided_at: None,
decision: None,
};
s.save_memory_pr(&pr).unwrap();
assert!(s
.decide_memory_pr("pr_2", MemoryPrAction::AskAgentWhy)
.is_err());
// Still pending.
assert_eq!(s.count_pending_memory_prs().unwrap(), 1);
}
}

View file

@ -0,0 +1,356 @@
//! # Agent Black Box, Receipts & Memory PRs — the cognitive flight recorder
//!
//! This module holds the **pure** data model and classification logic for three
//! tightly-related capabilities that together make Vestige *the black box,
//! immune system, and cinematic debugger for agent memory*:
//!
//! 1. **Agent Black Box** — a replayable trace of everything an agent run did to
//! memory: prompt → retrieved → suppressed → activated edges → tool calls →
//! writes → contradictions → vetoes → dream consolidation → final answer.
//! The event model is [`MemoryTraceEvent`].
//!
//! 2. **Memory Receipts** — every important retrieval returns a structured
//! [`Receipt`]: what was retrieved, what was suppressed and why, the
//! activation path that surfaced it, the trust floor, the decay risk, and any
//! mutations. A receipt is the "nutrition label" for a piece of agent memory.
//!
//! 3. **Memory PRs** — changes to an agent's *brain* are reviewed like changes
//! to code. Ordinary context auto-commits (and always leaves a receipt), but
//! risky writes — contradictions against high-trust memory, supersede / forget
//! / merge / protect, identity / preference / workflow / positioning facts,
//! permission / auth / security / money / legal facts, dream consolidation
//! proposals, decay-below-threshold resurrection, low-confidence batch
//! imports, and weak-provenance connector writes — open a reviewable
//! [`MemoryPr`]. The gating decision is [`classify_write`].
//!
//! ## Design north star (shared with [`crate::advanced::merge_supersede`])
//!
//! - **append-only** — trace events are never mutated, only appended, so a run
//! replays exactly as the agent experienced it.
//! - **self-explaining** — every gated write carries the [`RiskSignal`]s that
//! explain *why* it needs review, in plain language.
//! - **opt-in friction** — the default [`ReviewMode::RiskGated`] keeps ordinary
//! memory frictionless and only opens a PR when the agent tries to rewrite its
//! own brain. [`ReviewMode::Fast`] never gates; [`ReviewMode::Paranoid`] gates
//! every write.
//! - **DB-free** — this module is pure logic so it is unit-testable without a
//! database. Persistence (the `agent_traces`, `memory_receipts`, and
//! `memory_prs` tables) lives in [`crate::storage`].
//!
//! The killer line, made literal by [`classify_write`]:
//!
//! > Vestige auto-remembers ordinary context, but opens a Memory PR when the
//! > agent tries to rewrite its own brain.
use serde::{Deserialize, Serialize};
mod receipt;
mod review;
pub use receipt::{DecayRisk, Receipt, ReceiptMutation, SuppressedReceiptEntry};
pub use review::{
classify_write, MemoryPr, MemoryPrAction, MemoryPrKind, MemoryPrStatus, ReviewMode, RiskClass,
RiskSignal, WriteContext, HIGH_TRUST_FLOOR, LOW_CONFIDENCE_FLOOR,
};
// ============================================================================
// TRACE EVENTS — the black-box flight recorder
// ============================================================================
/// One append-only event in an agent run's black-box trace.
///
/// Mirrors the TypeScript `MemoryTraceEvent` union exactly (tagged on `type`,
/// camelCase fields) so the dashboard, the `vestige://trace/{runId}` MCP
/// resource, and the exported `.vestige-trace.json` all speak one schema.
///
/// ```ts
/// type MemoryTraceEvent =
/// | { type: "mcp.call"; runId; tool; argsHash; at }
/// | { type: "memory.retrieve"; runId; ids; activation; at }
/// | { type: "memory.suppress"; runId; id; reason }
/// | { type: "memory.write"; runId; id; diff; source }
/// | { type: "sanhedrin.veto"; runId; claim; evidenceIds; confidence }
/// | { type: "dream.patch"; runId; proposalIds; at };
/// ```
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type")]
pub enum MemoryTraceEvent {
/// An MCP tool was invoked. The args are stored as a hash (not the raw
/// payload) so traces never leak prompt contents or secrets.
#[serde(rename = "mcp.call")]
McpCall {
#[serde(rename = "runId")]
run_id: String,
tool: String,
#[serde(rename = "argsHash")]
args_hash: String,
at: i64,
},
/// Memories were retrieved, with per-id spreading-activation strength so the
/// graph replay can pulse exactly the nodes the agent saw, at their weight.
#[serde(rename = "memory.retrieve")]
MemoryRetrieve {
#[serde(rename = "runId")]
run_id: String,
ids: Vec<String>,
activation: std::collections::BTreeMap<String, f64>,
at: i64,
},
/// A memory that *would* have surfaced was suppressed, with the reason —
/// this is the "what the agent chose NOT to use" channel.
#[serde(rename = "memory.suppress")]
MemorySuppress {
#[serde(rename = "runId")]
run_id: String,
id: String,
reason: SuppressReason,
#[serde(default)]
at: i64,
},
/// A memory was written / strengthened. `diff` is an opaque JSON description
/// of the change; `source` records who caused it.
#[serde(rename = "memory.write")]
MemoryWrite {
#[serde(rename = "runId")]
run_id: String,
id: String,
diff: serde_json::Value,
source: WriteSource,
#[serde(default)]
at: i64,
},
/// A contradiction was detected between memories during a run — its own
/// first-class event (not folded into `memory.suppress`), so the Black Box
/// can show the exact contradiction decision the agent faced.
#[serde(rename = "contradiction.detected")]
ContradictionDetected {
#[serde(rename = "runId")]
run_id: String,
/// The two (or more) memory ids in tension.
ids: Vec<String>,
/// The id the agent trusted (kept), if it resolved the tension.
#[serde(rename = "winnerId", skip_serializing_if = "Option::is_none")]
winner_id: Option<String>,
/// Plain-language description of the contradiction.
detail: String,
#[serde(default)]
at: i64,
},
/// The Sanhedrin verifier vetoed a claim the agent was about to assert,
/// citing the evidence it weighed and its confidence.
#[serde(rename = "sanhedrin.veto")]
SanhedrinVeto {
#[serde(rename = "runId")]
run_id: String,
claim: String,
#[serde(rename = "evidenceIds")]
evidence_ids: Vec<String>,
confidence: f64,
#[serde(default)]
at: i64,
},
/// Dream consolidation proposed a patch to memory (merge / insight / prune).
#[serde(rename = "dream.patch")]
DreamPatch {
#[serde(rename = "runId")]
run_id: String,
#[serde(rename = "proposalIds")]
proposal_ids: Vec<String>,
at: i64,
},
}
impl MemoryTraceEvent {
/// The run this event belongs to.
pub fn run_id(&self) -> &str {
match self {
MemoryTraceEvent::McpCall { run_id, .. }
| MemoryTraceEvent::MemoryRetrieve { run_id, .. }
| MemoryTraceEvent::MemorySuppress { run_id, .. }
| MemoryTraceEvent::MemoryWrite { run_id, .. }
| MemoryTraceEvent::ContradictionDetected { run_id, .. }
| MemoryTraceEvent::SanhedrinVeto { run_id, .. }
| MemoryTraceEvent::DreamPatch { run_id, .. } => run_id,
}
}
/// The wall-clock millisecond timestamp the event was recorded at.
pub fn at(&self) -> i64 {
match self {
MemoryTraceEvent::McpCall { at, .. }
| MemoryTraceEvent::MemoryRetrieve { at, .. }
| MemoryTraceEvent::MemorySuppress { at, .. }
| MemoryTraceEvent::MemoryWrite { at, .. }
| MemoryTraceEvent::ContradictionDetected { at, .. }
| MemoryTraceEvent::SanhedrinVeto { at, .. }
| MemoryTraceEvent::DreamPatch { at, .. } => *at,
}
}
/// Short stable kind label used for filtering / the `event_type` column.
pub fn kind(&self) -> &'static str {
match self {
MemoryTraceEvent::McpCall { .. } => "mcp.call",
MemoryTraceEvent::MemoryRetrieve { .. } => "memory.retrieve",
MemoryTraceEvent::MemorySuppress { .. } => "memory.suppress",
MemoryTraceEvent::MemoryWrite { .. } => "memory.write",
MemoryTraceEvent::ContradictionDetected { .. } => "contradiction.detected",
MemoryTraceEvent::SanhedrinVeto { .. } => "sanhedrin.veto",
MemoryTraceEvent::DreamPatch { .. } => "dream.patch",
}
}
/// Stamp `at` on events that left it defaulted (the recorder fills this so
/// callers don't have to thread a clock through every emit site).
pub fn with_at(mut self, now_ms: i64) -> Self {
match &mut self {
MemoryTraceEvent::McpCall { at, .. }
| MemoryTraceEvent::MemoryRetrieve { at, .. }
| MemoryTraceEvent::MemorySuppress { at, .. }
| MemoryTraceEvent::MemoryWrite { at, .. }
| MemoryTraceEvent::ContradictionDetected { at, .. }
| MemoryTraceEvent::SanhedrinVeto { at, .. }
| MemoryTraceEvent::DreamPatch { at, .. } => {
if *at == 0 {
*at = now_ms;
}
}
}
self
}
}
/// Why a memory was suppressed during a run. Mirrors the TS union member
/// `"low_trust" | "decayed" | "contradicted" | "privacy"`, plus `competition`
/// for the existing spreading-activation competition suppression.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SuppressReason {
/// Below the trust floor for this retrieval.
LowTrust,
/// FSRS retrievability decayed below the usable threshold.
Decayed,
/// Contradicted by a higher-trust memory.
Contradicted,
/// Withheld for privacy / sensitivity reasons.
Privacy,
/// Lost spreading-activation competition to a stronger memory.
Competition,
}
impl SuppressReason {
/// Stable string label.
pub fn as_str(&self) -> &'static str {
match self {
SuppressReason::LowTrust => "low_trust",
SuppressReason::Decayed => "decayed",
SuppressReason::Contradicted => "contradicted",
SuppressReason::Privacy => "privacy",
SuppressReason::Competition => "competition",
}
}
}
/// Who caused a `memory.write`. Mirrors the TS `"agent" | "user" | "dream"`,
/// plus `connector` for external-source sync writes.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum WriteSource {
/// The agent wrote it autonomously.
Agent,
/// The user explicitly asked for it.
User,
/// Produced by dream consolidation.
Dream,
/// Ingested by an external connector (GitHub, Redmine, …).
Connector,
}
impl WriteSource {
/// Stable string label.
pub fn as_str(&self) -> &'static str {
match self {
WriteSource::Agent => "agent",
WriteSource::User => "user",
WriteSource::Dream => "dream",
WriteSource::Connector => "connector",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn trace_event_roundtrips_with_ts_shape() {
let ev = MemoryTraceEvent::McpCall {
run_id: "run_123".into(),
tool: "deep_reference".into(),
args_hash: "abc".into(),
at: 42,
};
let json = serde_json::to_value(&ev).unwrap();
// Tagged on `type`, camelCase runId/argsHash — exactly the TS contract.
assert_eq!(json["type"], "mcp.call");
assert_eq!(json["runId"], "run_123");
assert_eq!(json["argsHash"], "abc");
assert_eq!(json["at"], 42);
let back: MemoryTraceEvent = serde_json::from_value(json).unwrap();
assert_eq!(back, ev);
}
#[test]
fn retrieve_event_carries_activation_map() {
let mut activation = std::collections::BTreeMap::new();
activation.insert("mem_1".to_string(), 0.91);
activation.insert("mem_7".to_string(), 0.42);
let ev = MemoryTraceEvent::MemoryRetrieve {
run_id: "r".into(),
ids: vec!["mem_1".into(), "mem_7".into()],
activation,
at: 1,
};
let json = serde_json::to_value(&ev).unwrap();
assert_eq!(json["type"], "memory.retrieve");
assert_eq!(json["activation"]["mem_1"], 0.91);
}
#[test]
fn with_at_fills_only_when_unset() {
let ev = MemoryTraceEvent::MemorySuppress {
run_id: "r".into(),
id: "m".into(),
reason: SuppressReason::Contradicted,
at: 0,
}
.with_at(999);
assert_eq!(ev.at(), 999);
let ev2 = MemoryTraceEvent::DreamPatch {
run_id: "r".into(),
proposal_ids: vec!["p".into()],
at: 7,
}
.with_at(999);
assert_eq!(ev2.at(), 7, "explicit timestamp must not be overwritten");
}
#[test]
fn suppress_reason_labels_match_ts() {
assert_eq!(SuppressReason::LowTrust.as_str(), "low_trust");
assert_eq!(SuppressReason::Contradicted.as_str(), "contradicted");
// Serde uses the same snake_case form on the wire.
assert_eq!(
serde_json::to_value(SuppressReason::Privacy).unwrap(),
serde_json::json!("privacy")
);
}
}

View file

@ -0,0 +1,302 @@
//! # Memory Receipts
//!
//! Every important retrieval returns a [`Receipt`] — a structured record of what
//! the agent's memory actually did to answer a query. It is built entirely from
//! data the retrieval pipeline *already computes* (scored memories, suppression
//! decisions, spreading-activation path, FSRS trust), so attaching one is nearly
//! free and never changes the answer.
//!
//! The canonical shape (matching the product spec):
//!
//! ```json
//! {
//! "receipt_id": "r_2026_06_22_abc",
//! "retrieved": ["mem_1", "mem_7", "mem_9"],
//! "suppressed": [{"id": "mem_4", "reason": "contradicted"}],
//! "activation_path": ["project_goal -> design_decision -> current_file"],
//! "trust_floor": 0.62,
//! "decay_risk": "medium",
//! "mutations": []
//! }
//! ```
use serde::{Deserialize, Serialize};
use super::SuppressReason;
/// A structured receipt attached to a retrieval's output.
///
/// Field names are snake_case to match the published product spec and the
/// dashboard receipt card exactly.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Receipt {
/// Stable, human-legible id: `r_<yyyy>_<mm>_<dd>_<short>`.
pub receipt_id: String,
/// Ids of the memories that actually informed the answer, best-first.
pub retrieved: Vec<String>,
/// Memories that were withheld, each with the reason — the "what the agent
/// chose NOT to use" channel that makes retrieval auditable.
pub suppressed: Vec<SuppressedReceiptEntry>,
/// Human-readable spreading-activation path(s) that surfaced the result,
/// e.g. `"project_goal -> design_decision -> current_file"`.
pub activation_path: Vec<String>,
/// The minimum trust score among the retrieved memories — the weakest link
/// the answer rests on.
pub trust_floor: f64,
/// Coarse decay risk for the retrieved set (how stale the evidence is).
pub decay_risk: DecayRisk,
/// Any memory mutations this retrieval triggered (testing-effect
/// strengthening, reconsolidation, supersession). Empty for a pure read.
pub mutations: Vec<ReceiptMutation>,
}
impl Receipt {
/// Build a receipt from already-computed retrieval signals.
///
/// `receipt_id` is `r_<date>_<discriminator8>_<unique6>` — human-legible
/// and dated, with a short random suffix so that **multiple retrievals in
/// the same run never collide** (B3). The discriminator (usually the runId)
/// keeps receipts from one run visually grouped; the suffix guarantees
/// uniqueness so `INSERT OR REPLACE` can't overwrite an earlier receipt.
/// `trust_scores` is the per-id FSRS retrievability/trust the pipeline
/// already produced.
pub fn build(
now: chrono::DateTime<chrono::Utc>,
discriminator: &str,
retrieved: Vec<String>,
suppressed: Vec<SuppressedReceiptEntry>,
activation_path: Vec<String>,
trust_scores: &[f64],
mutations: Vec<ReceiptMutation>,
) -> Self {
Self::build_with_unique(
now,
discriminator,
&uuid::Uuid::new_v4().simple().to_string()[..6],
retrieved,
suppressed,
activation_path,
trust_scores,
mutations,
)
}
/// Like [`Receipt::build`] but with a caller-supplied uniqueness token,
/// so the id is fully deterministic for tests. Production uses
/// [`Receipt::build`] which mints a random token.
#[allow(clippy::too_many_arguments)]
pub fn build_with_unique(
now: chrono::DateTime<chrono::Utc>,
discriminator: &str,
unique: &str,
retrieved: Vec<String>,
suppressed: Vec<SuppressedReceiptEntry>,
activation_path: Vec<String>,
trust_scores: &[f64],
mutations: Vec<ReceiptMutation>,
) -> Self {
let trust_floor = trust_scores
.iter()
.copied()
.fold(f64::INFINITY, f64::min);
let trust_floor = if trust_floor.is_finite() {
(trust_floor * 100.0).round() / 100.0
} else {
0.0
};
let decay_risk = DecayRisk::from_trust_floor(trust_floor);
let short: String = discriminator
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.take(8)
.collect();
let unique_clean: String = unique
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.take(6)
.collect();
let receipt_id = format!(
"r_{}_{}_{}",
now.format("%Y_%m_%d"),
short,
unique_clean
);
Self {
receipt_id,
retrieved,
suppressed,
activation_path,
trust_floor,
decay_risk,
mutations,
}
}
}
/// One suppressed-memory entry in a [`Receipt`].
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SuppressedReceiptEntry {
/// The id of the suppressed memory.
pub id: String,
/// Why it was withheld.
pub reason: SuppressReason,
}
impl SuppressedReceiptEntry {
/// Convenience constructor.
pub fn new(id: impl Into<String>, reason: SuppressReason) -> Self {
Self {
id: id.into(),
reason,
}
}
}
/// Coarse staleness signal for a retrieved set, derived from the trust floor.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum DecayRisk {
/// Trust floor is healthy; the evidence is fresh.
Low,
/// Some of the evidence is weakening.
Medium,
/// The answer rests on memory that is decaying out.
High,
}
impl DecayRisk {
/// Map the weakest retrieved-trust score to a decay-risk band.
///
/// Thresholds align with the FSRS "due for review" intuition: above 0.7 the
/// memory is comfortably retrievable, 0.40.7 is getting weak, below 0.4 is
/// at risk of being forgotten.
pub fn from_trust_floor(trust_floor: f64) -> Self {
if trust_floor >= 0.7 {
DecayRisk::Low
} else if trust_floor >= 0.4 {
DecayRisk::Medium
} else {
DecayRisk::High
}
}
/// Stable string label.
pub fn as_str(&self) -> &'static str {
match self {
DecayRisk::Low => "low",
DecayRisk::Medium => "medium",
DecayRisk::High => "high",
}
}
}
/// A memory mutation that a retrieval triggered, recorded on the receipt so the
/// side effects of "just reading" are never invisible.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ReceiptMutation {
/// The mutated memory id.
pub id: String,
/// What changed: `"strengthened"`, `"reconsolidated"`, `"superseded"`, …
pub kind: String,
/// Optional human note about the change.
#[serde(skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn fixed_now() -> chrono::DateTime<chrono::Utc> {
chrono::Utc.with_ymd_and_hms(2026, 6, 22, 15, 0, 0).unwrap()
}
#[test]
fn receipt_id_is_human_legible_and_dated() {
let r = Receipt::build_with_unique(
fixed_now(),
"abc123!!",
"u1u2u3",
vec!["mem_1".into()],
vec![],
vec![],
&[0.9],
vec![],
);
assert_eq!(r.receipt_id, "r_2026_06_22_abc123_u1u2u3");
}
#[test]
fn receipt_ids_unique_within_a_run_b3() {
// B3: two retrievals in the SAME run (same date + discriminator) must
// get DISTINCT ids so INSERT OR REPLACE can't overwrite the first.
let a = Receipt::build(fixed_now(), "run_x", vec![], vec![], vec![], &[], vec![]);
let b = Receipt::build(fixed_now(), "run_x", vec![], vec![], vec![], &[], vec![]);
assert_ne!(
a.receipt_id, b.receipt_id,
"same-run receipts must not collide"
);
assert!(a.receipt_id.starts_with("r_2026_06_22_runx_"));
assert!(b.receipt_id.starts_with("r_2026_06_22_runx_"));
}
#[test]
fn trust_floor_is_the_weakest_link() {
let r = Receipt::build(
fixed_now(),
"x",
vec!["a".into(), "b".into(), "c".into()],
vec![],
vec![],
&[0.91, 0.62, 0.78],
vec![],
);
assert_eq!(r.trust_floor, 0.62);
assert_eq!(r.decay_risk, DecayRisk::Medium);
}
#[test]
fn empty_trust_scores_floor_to_zero_high_risk() {
let r = Receipt::build(fixed_now(), "x", vec![], vec![], vec![], &[], vec![]);
assert_eq!(r.trust_floor, 0.0);
assert_eq!(r.decay_risk, DecayRisk::High);
}
#[test]
fn decay_bands() {
assert_eq!(DecayRisk::from_trust_floor(0.95), DecayRisk::Low);
assert_eq!(DecayRisk::from_trust_floor(0.55), DecayRisk::Medium);
assert_eq!(DecayRisk::from_trust_floor(0.20), DecayRisk::High);
}
#[test]
fn matches_published_spec_shape() {
let r = Receipt {
receipt_id: "r_2026_06_22_abc".into(),
retrieved: vec!["mem_1".into(), "mem_7".into(), "mem_9".into()],
suppressed: vec![SuppressedReceiptEntry::new(
"mem_4",
SuppressReason::Contradicted,
)],
activation_path: vec!["project_goal -> design_decision -> current_file".into()],
trust_floor: 0.62,
decay_risk: DecayRisk::Medium,
mutations: vec![],
};
let json = serde_json::to_value(&r).unwrap();
assert_eq!(json["receipt_id"], "r_2026_06_22_abc");
assert_eq!(json["suppressed"][0]["reason"], "contradicted");
assert_eq!(json["decay_risk"], "medium");
assert_eq!(json["trust_floor"], 0.62);
assert!(json["mutations"].as_array().unwrap().is_empty());
}
}

View file

@ -0,0 +1,802 @@
//! # Memory PRs — review changes to an agent's brain like code
//!
//! Ordinary context auto-commits and always leaves a receipt. But a *risky*
//! write — one where the agent is rewriting its own brain — opens a reviewable
//! [`MemoryPr`] instead. [`classify_write`] is the immune system: given a
//! [`WriteContext`] and a [`ReviewMode`], it returns the [`RiskClass`] and the
//! [`RiskSignal`]s that explain, in plain language, *why* a write needs review.
//!
//! ## The three modes (one-click in the dashboard)
//!
//! | Mode | Behaviour |
//! |------|-----------|
//! | [`ReviewMode::Fast`] | Never gate. Every write auto-commits. (Demos, trusted solo flows.) |
//! | [`ReviewMode::RiskGated`] | **Default.** Auto-commit ordinary writes; open a PR for risky ones. |
//! | [`ReviewMode::Paranoid`] | Gate *every* write. Nothing enters the brain without approval. |
//!
//! ## What counts as "risky" (the taxonomy)
//!
//! A write is risky when any of these hold:
//! - it **contradicts a high-trust memory**,
//! - it **supersedes / forgets / merges / protects** existing memory,
//! - it touches **identity, user preference, workflow, or project positioning**,
//! - it asserts a **permission / auth / security / money / bounty / legal-ish** fact,
//! - it is a **dream consolidation** proposal,
//! - it **resurrects a decayed** memory (below the retention threshold),
//! - it is part of a **low-confidence batch import**,
//! - it is an **external connector write without strong provenance**.
//!
//! Each rule maps to a [`RiskSignal`] so the resulting Memory PR is fully
//! self-explaining.
use serde::{Deserialize, Serialize};
use super::WriteSource;
/// A memory is "high trust" at or above this FSRS retrievability/trust score.
/// Contradicting something this trusted is always worth a review.
pub const HIGH_TRUST_FLOOR: f64 = 0.7;
/// Writes below this confidence are treated as low-confidence (e.g. a bulk
/// import where the model wasn't sure).
pub const LOW_CONFIDENCE_FLOOR: f64 = 0.5;
// ============================================================================
// REVIEW MODE
// ============================================================================
/// How aggressively the agent's brain gates incoming writes.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum ReviewMode {
/// Never gate — every write auto-commits.
Fast,
/// Default: auto-commit ordinary writes, open a PR for risky ones.
#[default]
RiskGated,
/// Gate every write — nothing enters the brain without approval.
Paranoid,
}
impl ReviewMode {
/// Stable string label, also the wire form.
pub fn as_str(&self) -> &'static str {
match self {
ReviewMode::Fast => "fast",
ReviewMode::RiskGated => "risk_gated",
ReviewMode::Paranoid => "paranoid",
}
}
/// Parse from a label (case-insensitive, tolerant of `-`/`_`). Falls back to
/// the default [`ReviewMode::RiskGated`] on anything unrecognised.
pub fn from_label(s: &str) -> Self {
match s.trim().to_ascii_lowercase().replace('-', "_").as_str() {
"fast" => ReviewMode::Fast,
"paranoid" => ReviewMode::Paranoid,
_ => ReviewMode::RiskGated,
}
}
}
// ============================================================================
// RISK CLASSIFICATION
// ============================================================================
/// The outcome of [`classify_write`]: does this write auto-commit or open a PR?
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RiskClass {
/// Ordinary context — auto-commit (a receipt is still generated).
AutoCommit,
/// Risky — open a [`MemoryPr`] for review.
Review,
}
impl RiskClass {
/// Stable string label.
pub fn as_str(&self) -> &'static str {
match self {
RiskClass::AutoCommit => "auto_commit",
RiskClass::Review => "review",
}
}
/// Whether this write should be held for review.
pub fn needs_review(&self) -> bool {
matches!(self, RiskClass::Review)
}
}
/// A single, self-explaining reason a write was flagged for review. The
/// `code` is stable for filtering/telemetry; the `detail` is human prose for
/// the PR card.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RiskSignal {
/// Stable machine code, e.g. `"contradicts_high_trust"`.
pub code: String,
/// Plain-language explanation shown on the Memory PR.
pub detail: String,
}
impl RiskSignal {
fn new(code: &str, detail: impl Into<String>) -> Self {
Self {
code: code.into(),
detail: detail.into(),
}
}
}
/// Everything [`classify_write`] needs to decide whether a write is risky.
///
/// All fields default to the "ordinary, safe" interpretation so callers only
/// set the signals that actually apply to their write.
#[derive(Debug, Clone, Default)]
pub struct WriteContext {
/// Who is performing the write.
pub source: Option<WriteSource>,
/// The node type being written, e.g. `"fact"`, `"preference"`, `"identity"`.
pub node_type: String,
/// The content (or a representative slice) — scanned for sensitive topics.
pub content: String,
/// Tags attached to the write — also scanned for sensitive topics.
pub tags: Vec<String>,
/// The write contradicts an existing memory whose trust is this high.
/// `None` if there is no contradiction.
pub contradicts_trust: Option<f64>,
/// This write supersedes / replaces an existing memory.
pub supersedes: bool,
/// This write forgets / suppresses an existing memory.
pub forgets: bool,
/// This write merges existing memories.
pub merges: bool,
/// This write protects / pins a memory.
pub protects: bool,
/// This write resurrects a memory that had decayed below retention.
pub resurrects_decayed: bool,
/// Confidence of the write (0..1). `None` means "not a batch / unknown".
pub confidence: Option<f64>,
/// This write is one of many in a bulk import.
pub batch_import: bool,
/// For connector writes: whether the source envelope carries strong
/// provenance (a verified `source_system` + `source_id` + URL).
pub strong_provenance: bool,
}
/// Sensitive topic substrings. A write whose content/tags/type mention any of
/// these is treated as touching identity / preference / security / money /
/// legal / workflow / positioning and is routed to review.
const SENSITIVE_TOPICS: &[(&str, &str)] = &[
// identity & preference
("identity", "identity fact"),
("preference", "user preference"),
("workflow", "workflow rule"),
("positioning", "project positioning"),
("persona", "agent persona"),
// permission / auth / security
("permission", "tool permission"),
("auth", "authentication / authorization"),
("token", "credential / token"),
("secret", "secret material"),
("password", "credential"),
("api key", "credential / API key"),
("security", "security-relevant fact"),
("vuln", "security vulnerability"),
("vulnerability", "security vulnerability"),
("credential", "credential material"),
("credentials", "credential material"),
("api key", "credential / API key"),
("apikey", "credential / API key"),
// money / bounty / legal
("money", "financial fact"),
("payment", "financial fact"),
("invoice", "financial fact"),
("bounty", "bounty / payout"),
("salary", "financial fact"),
("license", "legal / license fact"),
("legal", "legal-relevant fact"),
("contract", "legal / contract fact"),
];
/// Node types that are intrinsically sensitive regardless of content.
const SENSITIVE_NODE_TYPES: &[&str] = &[
"identity",
"preference",
"user_preference",
"credential",
"permission",
"security",
"constitution",
];
/// Classify a write into auto-commit vs. review, with the signals explaining the
/// decision.
///
/// This is the immune system. It is pure and deterministic, so the dashboard's
/// "explain this PR" view and the agent's `Ask Agent Why` action see exactly the
/// same reasoning the gate used.
pub fn classify_write(ctx: &WriteContext, mode: ReviewMode) -> (RiskClass, Vec<RiskSignal>) {
// Mode shortcuts.
match mode {
// Fast never gates — but we still collect signals so the receipt/PR
// record can note what *would* have been flagged.
ReviewMode::Fast => return (RiskClass::AutoCommit, Vec::new()),
ReviewMode::Paranoid => {
let mut signals = collect_signals(ctx);
if signals.is_empty() {
signals.push(RiskSignal::new(
"paranoid_mode",
"Paranoid mode: every write is reviewed before entering memory.",
));
}
return (RiskClass::Review, signals);
}
ReviewMode::RiskGated => {}
}
let signals = collect_signals(ctx);
if signals.is_empty() {
(RiskClass::AutoCommit, signals)
} else {
(RiskClass::Review, signals)
}
}
/// Gather every risk signal that applies to a write, independent of mode.
fn collect_signals(ctx: &WriteContext) -> Vec<RiskSignal> {
let mut signals = Vec::new();
// 1. Contradiction against a high-trust memory.
if let Some(trust) = ctx.contradicts_trust
&& trust >= HIGH_TRUST_FLOOR
{
signals.push(RiskSignal::new(
"contradicts_high_trust",
format!(
"Contradicts an existing high-trust memory (trust {:.2} ≥ {:.2}).",
trust, HIGH_TRUST_FLOOR
),
));
}
// 2. Structural rewrites of existing memory.
if ctx.supersedes {
signals.push(RiskSignal::new(
"supersedes_memory",
"Supersedes / replaces an existing memory.",
));
}
if ctx.forgets {
signals.push(RiskSignal::new(
"forgets_memory",
"Forgets / suppresses an existing memory.",
));
}
if ctx.merges {
signals.push(RiskSignal::new(
"merges_memory",
"Merges existing memories into one.",
));
}
if ctx.protects {
signals.push(RiskSignal::new(
"protects_memory",
"Protects / pins a memory against decay and forgetting.",
));
}
// 3. Sensitive node types & topics (identity / preference / workflow /
// positioning / permission / auth / security / money / legal).
let node_type_lc = ctx.node_type.to_ascii_lowercase();
if SENSITIVE_NODE_TYPES.contains(&node_type_lc.as_str()) {
signals.push(RiskSignal::new(
"sensitive_node_type",
format!("Writes a sensitive node type: `{}`.", node_type_lc),
));
}
if let Some(topic) = first_sensitive_topic(&ctx.content, &ctx.tags) {
signals.push(RiskSignal::new(
"sensitive_topic",
format!("Touches a sensitive topic: {topic}."),
));
}
// 4. Dream consolidation proposals.
if matches!(ctx.source, Some(WriteSource::Dream)) {
signals.push(RiskSignal::new(
"dream_consolidation",
"Proposed by dream consolidation — a machine-generated change to memory.",
));
}
// 5. Decay-below-threshold resurrection.
if ctx.resurrects_decayed {
signals.push(RiskSignal::new(
"resurrects_decayed",
"Resurrects a memory that had decayed below the retention threshold.",
));
}
// 6. Low-confidence batch imports.
if ctx.batch_import {
if let Some(conf) = ctx.confidence {
if conf < LOW_CONFIDENCE_FLOOR {
signals.push(RiskSignal::new(
"low_confidence_batch",
format!(
"Low-confidence batch import (confidence {:.2} < {:.2}).",
conf, LOW_CONFIDENCE_FLOOR
),
));
}
} else {
signals.push(RiskSignal::new(
"unscored_batch",
"Batch import with no confidence score.",
));
}
}
// 7. External connector writes without strong provenance.
if matches!(ctx.source, Some(WriteSource::Connector)) && !ctx.strong_provenance {
signals.push(RiskSignal::new(
"weak_provenance_connector",
"External connector write without strong provenance (unverified source envelope).",
));
}
signals
}
/// Return the human label of the first sensitive topic found in content/tags.
///
/// B6: matches on WORD BOUNDARIES, not substrings — so "tokenizer" no longer
/// trips "token", "author" no longer trips "auth", "secretary" no longer trips
/// "secret". Multi-word needles (e.g. "api key") match a consecutive run of
/// words. The text is lowercased and split on any non-alphanumeric char.
fn first_sensitive_topic(content: &str, tags: &[String]) -> Option<&'static str> {
// Tokenize content + tags into lowercased alphanumeric words.
let mut words: Vec<String> = Vec::new();
let mut push_words = |s: &str| {
for w in s
.to_ascii_lowercase()
.split(|c: char| !c.is_ascii_alphanumeric())
{
if !w.is_empty() {
words.push(w.to_string());
}
}
};
push_words(content);
for t in tags {
push_words(t);
}
SENSITIVE_TOPICS
.iter()
.find(|(needle, _)| matches_word_sequence(&words, needle))
.map(|(_, label)| *label)
}
/// Whether `needle` (one or more space-separated words) appears as a consecutive
/// whole-word run in `words`.
fn matches_word_sequence(words: &[String], needle: &str) -> bool {
let needle_words: Vec<&str> = needle.split_whitespace().collect();
if needle_words.is_empty() {
return false;
}
if needle_words.len() == 1 {
return words.iter().any(|w| w == needle_words[0]);
}
words
.windows(needle_words.len())
.any(|win| win.iter().zip(&needle_words).all(|(w, n)| w == n))
}
// ============================================================================
// MEMORY PR DATA MODEL
// ============================================================================
/// What kind of change a Memory PR represents.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum MemoryPrKind {
/// A brand-new fact entering the brain.
NewFact,
/// An existing fact being strengthened / reinforced.
StrengthenedFact,
/// A contradiction was detected against existing memory.
ContradictionDetected,
/// A memory being superseded by a newer one.
MemorySuperseded,
/// A new edge added to the knowledge graph.
EdgeAdded,
/// A node decayed below the retention threshold.
NodeDecayed,
/// Dream consolidation proposed a merge / insight.
DreamConsolidation,
}
impl MemoryPrKind {
/// Stable string label.
pub fn as_str(&self) -> &'static str {
match self {
MemoryPrKind::NewFact => "new_fact",
MemoryPrKind::StrengthenedFact => "strengthened_fact",
MemoryPrKind::ContradictionDetected => "contradiction_detected",
MemoryPrKind::MemorySuperseded => "memory_superseded",
MemoryPrKind::EdgeAdded => "edge_added",
MemoryPrKind::NodeDecayed => "node_decayed",
MemoryPrKind::DreamConsolidation => "dream_consolidation",
}
}
/// Parse from a label; `None` if unrecognised.
pub fn from_label(s: &str) -> Option<Self> {
Some(match s {
"new_fact" => MemoryPrKind::NewFact,
"strengthened_fact" => MemoryPrKind::StrengthenedFact,
"contradiction_detected" => MemoryPrKind::ContradictionDetected,
"memory_superseded" => MemoryPrKind::MemorySuperseded,
"edge_added" => MemoryPrKind::EdgeAdded,
"node_decayed" => MemoryPrKind::NodeDecayed,
"dream_consolidation" => MemoryPrKind::DreamConsolidation,
_ => return None,
})
}
}
/// The review status of a Memory PR.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum MemoryPrStatus {
/// Awaiting a decision.
#[default]
Pending,
/// Promoted into long-term memory as-is.
Promoted,
/// Merged into an existing memory.
Merged,
/// Superseded an existing memory.
Superseded,
/// Quarantined — held in the firewall, not used for retrieval.
Quarantined,
/// Forgotten — rejected and suppressed.
Forgotten,
}
impl MemoryPrStatus {
/// Stable string label.
pub fn as_str(&self) -> &'static str {
match self {
MemoryPrStatus::Pending => "pending",
MemoryPrStatus::Promoted => "promoted",
MemoryPrStatus::Merged => "merged",
MemoryPrStatus::Superseded => "superseded",
MemoryPrStatus::Quarantined => "quarantined",
MemoryPrStatus::Forgotten => "forgotten",
}
}
}
/// The actions a reviewer can take on a Memory PR (the buttons in the diff UI).
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum MemoryPrAction {
/// Accept the change as-is.
Promote,
/// Fold it into an existing memory.
Merge,
/// Use it to supersede an existing memory.
Supersede,
/// Hold it in the firewall.
Quarantine,
/// Reject and suppress it.
Forget,
/// Ask the agent to explain the change (returns the risk signals).
AskAgentWhy,
}
impl MemoryPrAction {
/// Parse from a URL/path label; `None` if unrecognised.
pub fn from_label(s: &str) -> Option<Self> {
Some(match s {
"promote" => MemoryPrAction::Promote,
"merge" => MemoryPrAction::Merge,
"supersede" => MemoryPrAction::Supersede,
"quarantine" => MemoryPrAction::Quarantine,
"forget" => MemoryPrAction::Forget,
"ask_agent_why" | "ask-agent-why" | "why" => MemoryPrAction::AskAgentWhy,
_ => return None,
})
}
/// The status this action moves the PR into (`None` for `AskAgentWhy`, which
/// is read-only).
pub fn resulting_status(&self) -> Option<MemoryPrStatus> {
Some(match self {
MemoryPrAction::Promote => MemoryPrStatus::Promoted,
MemoryPrAction::Merge => MemoryPrStatus::Merged,
MemoryPrAction::Supersede => MemoryPrStatus::Superseded,
MemoryPrAction::Quarantine => MemoryPrStatus::Quarantined,
MemoryPrAction::Forget => MemoryPrStatus::Forgotten,
MemoryPrAction::AskAgentWhy => return None,
})
}
/// Whether deciding the PR with this action should **release** the subject
/// memory from quarantine (reverse the suppression that gate_writes applied).
///
/// A risky write is committed-then-suppressed; approving it must restore its
/// retrieval influence, otherwise the UI says "promoted" while the memory
/// stays held out — the bug this guards against. Accept actions release;
/// `Quarantine` keeps it held; `Forget` rejects it (stays suppressed);
/// `AskAgentWhy` is read-only.
pub fn releases_memory(&self) -> bool {
matches!(
self,
MemoryPrAction::Promote | MemoryPrAction::Merge | MemoryPrAction::Supersede
)
}
}
/// A reviewable change to the agent's brain — the persisted Memory PR record.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MemoryPr {
/// UUID.
pub id: String,
/// What kind of change this is.
pub kind: MemoryPrKind,
/// Current review status.
pub status: MemoryPrStatus,
/// Short human title for the PR list.
pub title: String,
/// The proposed change as a structured diff (before/after, ids, payload).
pub diff: serde_json::Value,
/// The self-explaining risk signals that opened this PR.
pub signals: Vec<RiskSignal>,
/// The memory id this PR concerns, if any.
#[serde(skip_serializing_if = "Option::is_none")]
pub subject_id: Option<String>,
/// The run that produced this change, linking the PR back to the black box.
#[serde(skip_serializing_if = "Option::is_none")]
pub run_id: Option<String>,
/// RFC3339 creation time.
pub created_at: String,
/// RFC3339 decision time, once decided.
#[serde(skip_serializing_if = "Option::is_none")]
pub decided_at: Option<String>,
/// The action that resolved this PR, once decided.
#[serde(skip_serializing_if = "Option::is_none")]
pub decision: Option<MemoryPrAction>,
}
#[cfg(test)]
mod tests {
use super::*;
fn ordinary() -> WriteContext {
WriteContext {
source: Some(WriteSource::Agent),
node_type: "fact".into(),
content: "The build uses cargo and pnpm.".into(),
tags: vec!["build".into()],
..Default::default()
}
}
#[test]
fn ordinary_write_auto_commits_in_risk_gated() {
let (class, signals) = classify_write(&ordinary(), ReviewMode::RiskGated);
assert_eq!(class, RiskClass::AutoCommit);
assert!(signals.is_empty());
}
#[test]
fn fast_mode_never_gates_even_risky_writes() {
let mut ctx = ordinary();
ctx.supersedes = true;
ctx.contradicts_trust = Some(0.95);
let (class, _) = classify_write(&ctx, ReviewMode::Fast);
assert_eq!(class, RiskClass::AutoCommit);
}
#[test]
fn paranoid_mode_gates_even_ordinary_writes() {
let (class, signals) = classify_write(&ordinary(), ReviewMode::Paranoid);
assert_eq!(class, RiskClass::Review);
assert_eq!(signals[0].code, "paranoid_mode");
}
#[test]
fn contradiction_against_high_trust_is_risky() {
let mut ctx = ordinary();
ctx.contradicts_trust = Some(0.82);
let (class, signals) = classify_write(&ctx, ReviewMode::RiskGated);
assert_eq!(class, RiskClass::Review);
assert!(signals.iter().any(|s| s.code == "contradicts_high_trust"));
}
#[test]
fn contradiction_against_low_trust_is_fine() {
let mut ctx = ordinary();
ctx.contradicts_trust = Some(0.3);
let (class, _) = classify_write(&ctx, ReviewMode::RiskGated);
assert_eq!(class, RiskClass::AutoCommit);
}
#[test]
fn supersede_forget_merge_protect_all_gate() {
for set in [
|c: &mut WriteContext| c.supersedes = true,
|c: &mut WriteContext| c.forgets = true,
|c: &mut WriteContext| c.merges = true,
|c: &mut WriteContext| c.protects = true,
] {
let mut ctx = ordinary();
set(&mut ctx);
let (class, _) = classify_write(&ctx, ReviewMode::RiskGated);
assert_eq!(class, RiskClass::Review);
}
}
#[test]
fn sensitive_topics_gate() {
for topic in [
"remember my auth token is xyz",
"Sam's salary is confidential",
"the bounty payout terms",
"user preference: dark mode",
"this is a security vulnerability",
] {
let mut ctx = ordinary();
ctx.content = topic.into();
let (class, signals) = classify_write(&ctx, ReviewMode::RiskGated);
assert_eq!(class, RiskClass::Review, "should gate: {topic}");
assert!(signals.iter().any(|s| s.code == "sensitive_topic"));
}
}
#[test]
fn sensitive_topic_word_boundary_no_false_positives_b6() {
// B6: these ordinary technical writes must NOT gate — they only CONTAIN
// a sensitive substring, they don't USE the sensitive word.
// These each only CONTAIN a sensitive substring; the word-boundary fix
// means they no longer gate. (Note: bare "license"/"contract"/"legal"
// ARE kept as gating words — a license/contract fact is legitimately
// legal-relevant — so they're intentionally not in this benign set.)
for benign in [
"The tokenizer converts input strings to embeddings.",
"The author of this module is documented in the header.",
"The secretary pattern coordinates the worker pool.",
"Contraction of the array happens during compaction.",
"The authority record links to the canonical node.",
"The authentication-free endpoint is for health checks.", // "authentication" != "auth"
] {
let mut ctx = ordinary();
ctx.content = benign.into();
ctx.node_type = "fact".into();
ctx.tags = vec![];
let (class, _) = classify_write(&ctx, ReviewMode::RiskGated);
assert_eq!(
class,
RiskClass::AutoCommit,
"must NOT gate ordinary write: {benign}"
);
}
}
#[test]
fn sensitive_topic_word_boundary_still_catches_real_b6() {
// The real sensitive phrasings must still gate.
for risky in [
"store the auth token for the deploy",
"this is a security vulnerability in the parser",
"the api key for the service",
"remember the user preference for dark mode",
"the bounty payout is configured",
] {
let mut ctx = ordinary();
ctx.content = risky.into();
ctx.node_type = "fact".into();
ctx.tags = vec![];
let (class, signals) = classify_write(&ctx, ReviewMode::RiskGated);
assert_eq!(class, RiskClass::Review, "must gate: {risky}");
assert!(signals.iter().any(|s| s.code == "sensitive_topic"));
}
}
#[test]
fn sensitive_node_type_gates() {
let mut ctx = ordinary();
ctx.node_type = "identity".into();
let (class, signals) = classify_write(&ctx, ReviewMode::RiskGated);
assert_eq!(class, RiskClass::Review);
assert!(signals.iter().any(|s| s.code == "sensitive_node_type"));
}
#[test]
fn dream_consolidation_gates() {
let mut ctx = ordinary();
ctx.source = Some(WriteSource::Dream);
let (class, signals) = classify_write(&ctx, ReviewMode::RiskGated);
assert_eq!(class, RiskClass::Review);
assert!(signals.iter().any(|s| s.code == "dream_consolidation"));
}
#[test]
fn decayed_resurrection_gates() {
let mut ctx = ordinary();
ctx.resurrects_decayed = true;
let (class, _) = classify_write(&ctx, ReviewMode::RiskGated);
assert_eq!(class, RiskClass::Review);
}
#[test]
fn low_confidence_batch_gates_but_confident_batch_does_not() {
let mut low = ordinary();
low.batch_import = true;
low.confidence = Some(0.3);
assert_eq!(
classify_write(&low, ReviewMode::RiskGated).0,
RiskClass::Review
);
let mut high = ordinary();
high.batch_import = true;
high.confidence = Some(0.9);
assert_eq!(
classify_write(&high, ReviewMode::RiskGated).0,
RiskClass::AutoCommit
);
}
#[test]
fn weak_provenance_connector_gates_strong_does_not() {
let mut weak = ordinary();
weak.source = Some(WriteSource::Connector);
weak.strong_provenance = false;
assert_eq!(
classify_write(&weak, ReviewMode::RiskGated).0,
RiskClass::Review
);
let mut strong = ordinary();
strong.source = Some(WriteSource::Connector);
strong.strong_provenance = true;
assert_eq!(
classify_write(&strong, ReviewMode::RiskGated).0,
RiskClass::AutoCommit
);
}
#[test]
fn mode_label_roundtrip() {
assert_eq!(ReviewMode::from_label("FAST"), ReviewMode::Fast);
assert_eq!(ReviewMode::from_label("risk-gated"), ReviewMode::RiskGated);
assert_eq!(ReviewMode::from_label("paranoid"), ReviewMode::Paranoid);
assert_eq!(ReviewMode::from_label("garbage"), ReviewMode::RiskGated);
}
#[test]
fn action_resulting_status() {
assert_eq!(
MemoryPrAction::Promote.resulting_status(),
Some(MemoryPrStatus::Promoted)
);
assert_eq!(MemoryPrAction::AskAgentWhy.resulting_status(), None);
}
#[test]
fn only_accept_actions_release_the_memory() {
// B1: accepting a risky write must release it from quarantine.
assert!(MemoryPrAction::Promote.releases_memory());
assert!(MemoryPrAction::Merge.releases_memory());
assert!(MemoryPrAction::Supersede.releases_memory());
// Rejecting / holding keeps it suppressed.
assert!(!MemoryPrAction::Forget.releases_memory());
assert!(!MemoryPrAction::Quarantine.releases_memory());
assert!(!MemoryPrAction::AskAgentWhy.releases_memory());
}
}

View file

@ -443,7 +443,10 @@ async fn handle_event(
| VestigeEvent::ConsolidationCompleted { .. }
| VestigeEvent::RetentionDecayed { .. }
| VestigeEvent::ConnectionDiscovered { .. }
| VestigeEvent::ActivationSpread { .. } => {}
| VestigeEvent::ActivationSpread { .. }
| VestigeEvent::TraceEvent { .. }
| VestigeEvent::MemoryPrOpened { .. }
| VestigeEvent::MemoryPrDecided { .. } => {}
}
}

View file

@ -167,6 +167,39 @@ pub enum VestigeEvent {
timestamp: DateTime<Utc>,
},
// -- Agent Black Box (v2.2) --
// One replayable trace event from an agent run. The dashboard Black Box tab
// appends these to the live timeline and pulses the graph exactly as the
// agent experienced it. The inner event is the canonical
// `vestige_core::MemoryTraceEvent`, serialized with its own `type` tag, so
// the wire shape is `{ "type": "TraceEvent", "data": { "runId": ..., "event": { "type": "mcp.call", ... } } }`.
TraceEvent {
run_id: String,
seq: i64,
event: vestige_core::MemoryTraceEvent,
timestamp: DateTime<Utc>,
},
// -- Memory PRs (v2.2) — the cognitive immune system --
// A risky write opened a Memory PR. The dashboard raises the PR-queue badge
// and can surface a toast: "Vestige opened a Memory PR — the agent tried to
// rewrite its own brain."
MemoryPrOpened {
id: String,
kind: String,
title: String,
signal_count: usize,
run_id: Option<String>,
timestamp: DateTime<Utc>,
},
// A Memory PR was decided (promote / merge / supersede / quarantine / forget).
MemoryPrDecided {
id: String,
decision: String,
status: String,
timestamp: DateTime<Utc>,
},
// -- System --
Heartbeat {
uptime_secs: u64,

View file

@ -1983,6 +1983,358 @@ pub async fn deep_reference_query(
Ok(Json(response))
}
// ============================================================================
// AGENT BLACK BOX (v2.2) — replayable agent-run traces
// ============================================================================
#[derive(Debug, Deserialize)]
pub struct TraceListParams {
pub limit: Option<usize>,
/// Optional run filter — receipts/traces scoped to one run (B5).
pub run: Option<String>,
}
/// List recent agent runs (newest activity first) for the Black Box run picker.
pub async fn list_traces(
State(state): State<AppState>,
Query(params): Query<TraceListParams>,
) -> Result<Json<Value>, StatusCode> {
let limit = params.limit.unwrap_or(50).clamp(1, 500);
let runs = state
.storage
.list_agent_runs(limit)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let runs_json: Vec<Value> = runs
.into_iter()
.map(|r| {
serde_json::json!({
"runId": r.run_id,
"firstTool": r.first_tool,
"eventCount": r.event_count,
"retrievedCount": r.retrieved_count,
"suppressedCount": r.suppressed_count,
"writeCount": r.write_count,
"vetoCount": r.veto_count,
"startedAt": r.started_at,
"lastAt": r.last_at,
})
})
.collect();
Ok(Json(serde_json::json!({
"total": runs_json.len(),
"runs": runs_json,
})))
}
/// Fetch the full event timeline for one run — the black-box replay payload.
pub async fn get_trace(
State(state): State<AppState>,
Path(run_id): Path<String>,
) -> Result<Json<Value>, StatusCode> {
let events = state
.storage
.get_trace(&run_id)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if events.is_empty() {
return Err(StatusCode::NOT_FOUND);
}
let summary = state.storage.get_agent_run(&run_id).ok().flatten();
Ok(Json(serde_json::json!({
"runId": run_id,
"summary": summary.map(|s| serde_json::json!({
"firstTool": s.first_tool,
"eventCount": s.event_count,
"retrievedCount": s.retrieved_count,
"suppressedCount": s.suppressed_count,
"writeCount": s.write_count,
"vetoCount": s.veto_count,
"startedAt": s.started_at,
"lastAt": s.last_at,
})),
"events": events,
})))
}
/// Export a run as a downloadable `.vestige-trace.json` artifact.
pub async fn export_trace(
State(state): State<AppState>,
Path(run_id): Path<String>,
) -> Result<([(axum::http::HeaderName, String); 2], Json<Value>), StatusCode> {
let events = state
.storage
.get_trace(&run_id)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if events.is_empty() {
return Err(StatusCode::NOT_FOUND);
}
let summary = state.storage.get_agent_run(&run_id).ok().flatten();
let body = serde_json::json!({
"format": "vestige-trace",
"version": 1,
"runId": run_id,
"exportedAt": Utc::now().to_rfc3339(),
"summary": summary.map(|s| serde_json::json!({
"firstTool": s.first_tool,
"eventCount": s.event_count,
"retrievedCount": s.retrieved_count,
"suppressedCount": s.suppressed_count,
"writeCount": s.write_count,
"vetoCount": s.veto_count,
"startedAt": s.started_at,
"lastAt": s.last_at,
})),
"events": events,
});
// B7: sanitize the run_id before putting it in the download filename so a
// crafted run_id (quotes, path separators, control chars) can't break the
// Content-Disposition header or the filename. Falls back to "trace".
let safe: String = run_id
.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '_' })
.collect();
let safe = if safe.trim_matches('_').is_empty() {
"trace".to_string()
} else {
safe
};
let headers = [
(
axum::http::header::CONTENT_TYPE,
"application/json".to_string(),
),
(
axum::http::header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{safe}.vestige-trace.json\""),
),
];
Ok((headers, Json(body)))
}
// ============================================================================
// MEMORY RECEIPTS (v2.2)
// ============================================================================
/// List recent retrieval receipts.
pub async fn list_receipts(
State(state): State<AppState>,
Query(params): Query<TraceListParams>,
) -> Result<Json<Value>, StatusCode> {
let limit = params.limit.unwrap_or(50).clamp(1, 500);
// B5: when a run is given, scope to that run's receipts so the Black Box
// panel shows only receipts that actually belong to the selected run.
let receipts = match params.run.as_deref().filter(|r| !r.is_empty()) {
Some(run_id) => state.storage.list_receipts_for_run(run_id, limit),
None => state.storage.list_receipts(limit),
}
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(serde_json::json!({
"total": receipts.len(),
"receipts": receipts,
})))
}
/// Fetch one receipt by id — the payload behind "Open receipt in Cinema".
pub async fn get_receipt(
State(state): State<AppState>,
Path(receipt_id): Path<String>,
) -> Result<Json<Value>, StatusCode> {
let receipt = state
.storage
.get_receipt(&receipt_id)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
Ok(Json(serde_json::to_value(receipt).unwrap_or_default()))
}
// ============================================================================
// MEMORY PRs (v2.2) — risk-gated brain-change review queue
// ============================================================================
#[derive(Debug, Deserialize)]
pub struct MemoryPrListParams {
pub status: Option<String>,
pub limit: Option<usize>,
}
/// List Memory PRs, optionally filtered by status.
pub async fn list_memory_prs(
State(state): State<AppState>,
Query(params): Query<MemoryPrListParams>,
) -> Result<Json<Value>, StatusCode> {
let limit = params.limit.unwrap_or(100).clamp(1, 500);
let status = params.status.as_deref().and_then(|s| {
serde_json::from_value::<vestige_core::MemoryPrStatus>(serde_json::Value::String(
s.to_string(),
))
.ok()
});
let prs = state
.storage
.list_memory_prs(status, limit)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let pending = state.storage.count_pending_memory_prs().unwrap_or(0);
Ok(Json(serde_json::json!({
"total": prs.len(),
"pendingCount": pending,
"mode": read_review_mode(&state).as_str(),
"prs": prs,
})))
}
/// Fetch one Memory PR by id.
pub async fn get_memory_pr(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Value>, StatusCode> {
let pr = state
.storage
.get_memory_pr(&id)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
Ok(Json(serde_json::to_value(pr).unwrap_or_default()))
}
/// Act on a Memory PR: promote / merge / supersede / quarantine / forget /
/// ask_agent_why. `ask_agent_why` is read-only and returns the risk signals.
pub async fn act_on_memory_pr(
State(state): State<AppState>,
Path((id, action)): Path<(String, String)>,
) -> Result<Json<Value>, StatusCode> {
let action = vestige_core::MemoryPrAction::from_label(&action)
.ok_or(StatusCode::BAD_REQUEST)?;
// Ask Agent Why is read-only — return the self-explaining signals.
if matches!(action, vestige_core::MemoryPrAction::AskAgentWhy) {
let pr = state
.storage
.get_memory_pr(&id)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
return Ok(Json(serde_json::json!({
"id": pr.id,
"kind": pr.kind.as_str(),
"title": pr.title,
"why": pr.signals,
"explanation": "These are the risk signals that opened this Memory PR.",
})));
}
let decided = state
.storage
.decide_memory_pr(&id, action)
.map_err(|_| StatusCode::NOT_FOUND)?;
// B1: an accept action (promote/merge/supersede) must RELEASE the subject
// memory from quarantine — gate_writes suppressed it, so deciding the PR
// without un-suppressing would leave it "promoted" yet still held out of
// retrieval. Forget/Quarantine intentionally keep it suppressed.
let mut released = false;
if action.releases_memory()
&& let Some(subject_id) = decided.subject_id.as_deref()
{
// Use the UNCONDITIONAL quarantine release, not reverse_suppression:
// approving a PR must restore the memory even if reviewed days later,
// past the active-forgetting labile window (the C1 fix).
match state.storage.release_quarantine(subject_id) {
Ok(node) => {
released = true;
state.emit(VestigeEvent::MemoryUnsuppressed {
id: node.id.clone(),
remaining_count: node.suppression_count,
timestamp: Utc::now(),
});
}
Err(e) => {
// Best-effort: the PR is decided regardless, but surface the
// failure so a stuck-suppressed memory isn't silent.
tracing::warn!(
"memory PR {} {}d but failed to release subject {}: {}",
id,
action_label(action),
subject_id,
e
);
}
}
}
state.emit(VestigeEvent::MemoryPrDecided {
id: decided.id.clone(),
decision: decided
.decision
.and_then(|d| serde_json::to_value(d).ok())
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_default(),
status: decided.status.as_str().to_string(),
timestamp: Utc::now(),
});
let mut out = serde_json::to_value(&decided).unwrap_or_default();
if let Some(obj) = out.as_object_mut() {
obj.insert("subjectReleased".to_string(), serde_json::json!(released));
}
Ok(Json(out))
}
/// Short label for a Memory PR action, for log lines.
fn action_label(action: vestige_core::MemoryPrAction) -> &'static str {
use vestige_core::MemoryPrAction::*;
match action {
Promote => "promote",
Merge => "merge",
Supersede => "supersede",
Quarantine => "quarantine",
Forget => "forget",
AskAgentWhy => "ask_agent_why",
}
}
#[derive(Debug, Deserialize)]
pub struct ReviewModeBody {
pub mode: String,
}
/// Get the current review mode (fast / risk_gated / paranoid).
pub async fn get_review_mode(State(state): State<AppState>) -> Json<Value> {
let mode = read_review_mode(&state);
Json(serde_json::json!({
"mode": mode.as_str(),
"pendingCount": state.storage.count_pending_memory_prs().unwrap_or(0),
}))
}
/// Set the review mode. Persisted to a small JSON file in the data dir so it
/// survives restarts (local-first, no extra config service).
pub async fn set_review_mode(
State(state): State<AppState>,
Json(body): Json<ReviewModeBody>,
) -> Result<Json<Value>, StatusCode> {
let mode = vestige_core::ReviewMode::from_label(&body.mode);
let path = review_mode_path(&state);
let payload = serde_json::json!({ "mode": mode.as_str() });
// B7: atomic write (temp + rename) so a concurrent read can never see a
// partially-written / corrupt review_mode.json, reusing the same helper the
// Sanhedrin receipt path uses.
write_atomic(&path, &serde_json::to_vec_pretty(&payload).unwrap_or_default())?;
Ok(Json(serde_json::json!({ "mode": mode.as_str() })))
}
/// Path to the persisted review-mode file.
fn review_mode_path(state: &AppState) -> PathBuf {
state.storage.data_dir().join("review_mode.json")
}
/// Read the persisted review mode, defaulting to RiskGated.
pub fn read_review_mode(state: &AppState) -> vestige_core::ReviewMode {
let path = review_mode_path(state);
fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str::<Value>(&s).ok())
.and_then(|v| v.get("mode").and_then(|m| m.as_str()).map(String::from))
.map(|s| vestige_core::ReviewMode::from_label(&s))
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -183,6 +183,32 @@ fn build_router_inner(state: AppState, port: u16) -> (Router, AppState) {
get(handlers::get_sanhedrin_telemetry),
)
.route("/api/sanhedrin/appeal", post(handlers::appeal_sanhedrin))
// ============================================================
// AGENT BLACK BOX (v2.2) — replayable agent-run traces
// ============================================================
.route("/api/traces", get(handlers::list_traces))
.route("/api/traces/{run_id}", get(handlers::get_trace))
.route("/api/traces/{run_id}/export", get(handlers::export_trace))
// ============================================================
// MEMORY RECEIPTS (v2.2) — the nutrition label for a retrieval
// ============================================================
.route("/api/receipts", get(handlers::list_receipts))
.route("/api/receipts/{receipt_id}", get(handlers::get_receipt))
// ============================================================
// MEMORY PRs (v2.2) — risk-gated brain-change review queue
// ============================================================
.route("/api/memory-prs", get(handlers::list_memory_prs))
// Static `/mode` routes declared BEFORE the dynamic `/{id}` route (B7
// hygiene). axum 0.8/matchit already prioritizes static segments, but
// declaring them first makes the intent unambiguous and guards against
// a future router that doesn't.
.route("/api/memory-prs/mode", get(handlers::get_review_mode))
.route("/api/memory-prs/mode", post(handlers::set_review_mode))
.route("/api/memory-prs/{id}", get(handlers::get_memory_pr))
.route(
"/api/memory-prs/{id}/{action}",
post(handlers::act_on_memory_pr),
)
.layer(
ServiceBuilder::new()
.concurrency_limit(50)

View file

@ -9,3 +9,4 @@ pub mod protocol;
pub mod resources;
pub mod server;
pub mod tools;
pub mod trace_recorder;

View file

@ -4,3 +4,4 @@
pub mod codebase;
pub mod memory;
pub mod trace;

View file

@ -0,0 +1,103 @@
//! Agent Black Box Resources
//!
//! `trace://` URI scheme — exposes replayable agent-run traces as MCP resources
//! so a coding agent can read its *own* black box back. This closes the trace
//! correlation spine on the MCP side: the same `runId` an agent received in a
//! tool result's `traceUri` resolves here to the full event timeline.
//!
//! - `trace://{runId}` — the full ordered event log for a run.
//! - `trace://{runId}/summary` — just the roll-up counts.
//! - `trace://runs` — recent runs (the run picker).
//! - `trace://latest` — the most recently active run's full trace.
use std::sync::Arc;
use vestige_core::Storage;
/// Read a `trace://` resource.
pub async fn read(storage: &Arc<Storage>, uri: &str) -> Result<String, String> {
let path = uri.strip_prefix("trace://").unwrap_or("");
let (path, _query) = match path.split_once('?') {
Some((p, q)) => (p, Some(q)),
None => (path, None),
};
match path {
"" | "runs" => read_runs(storage).await,
"latest" => read_latest(storage).await,
other => {
if let Some(run_id) = other.strip_suffix("/summary") {
read_summary(storage, run_id).await
} else {
read_run(storage, other).await
}
}
}
}
async fn read_runs(storage: &Arc<Storage>) -> Result<String, String> {
let runs = storage.list_agent_runs(50).map_err(|e| e.to_string())?;
let json: Vec<_> = runs
.into_iter()
.map(|r| {
serde_json::json!({
"runId": r.run_id,
"firstTool": r.first_tool,
"eventCount": r.event_count,
"retrievedCount": r.retrieved_count,
"suppressedCount": r.suppressed_count,
"writeCount": r.write_count,
"vetoCount": r.veto_count,
"startedAt": r.started_at,
"lastAt": r.last_at,
})
})
.collect();
serde_json::to_string_pretty(&serde_json::json!({ "runs": json }))
.map_err(|e| e.to_string())
}
async fn read_latest(storage: &Arc<Storage>) -> Result<String, String> {
let runs = storage.list_agent_runs(1).map_err(|e| e.to_string())?;
let run = runs
.into_iter()
.next()
.ok_or_else(|| "No agent runs recorded yet".to_string())?;
read_run(storage, &run.run_id).await
}
async fn read_run(storage: &Arc<Storage>, run_id: &str) -> Result<String, String> {
let events = storage.get_trace(run_id).map_err(|e| e.to_string())?;
if events.is_empty() {
return Err(format!("No trace found for run: {run_id}"));
}
let summary = storage.get_agent_run(run_id).ok().flatten();
let body = serde_json::json!({
"runId": run_id,
"summary": summary.map(summary_json),
"events": events,
});
serde_json::to_string_pretty(&body).map_err(|e| e.to_string())
}
async fn read_summary(storage: &Arc<Storage>, run_id: &str) -> Result<String, String> {
let summary = storage
.get_agent_run(run_id)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("No run: {run_id}"))?;
serde_json::to_string_pretty(&summary_json(summary)).map_err(|e| e.to_string())
}
fn summary_json(s: vestige_core::AgentRunSummary) -> serde_json::Value {
serde_json::json!({
"runId": s.run_id,
"firstTool": s.first_tool,
"eventCount": s.event_count,
"retrievedCount": s.retrieved_count,
"suppressedCount": s.suppressed_count,
"writeCount": s.write_count,
"vetoCount": s.veto_count,
"startedAt": s.started_at,
"lastAt": s.last_at,
})
}

View file

@ -129,6 +129,23 @@ impl McpServer {
}
}
/// Read the active Memory PR review mode from `<data_dir>/review_mode.json`,
/// defaulting to `RiskGated`. Shared shape with the dashboard handler so the
/// MCP write path and the UI agree on the mode.
fn review_mode(&self) -> vestige_core::ReviewMode {
let path = self.storage.data_dir().join("review_mode.json");
std::fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.and_then(|v| {
v.get("mode")
.and_then(|m| m.as_str())
.map(|s| s.to_string())
})
.map(|s| vestige_core::ReviewMode::from_label(&s))
.unwrap_or_default()
}
/// Handle an incoming JSON-RPC request
pub async fn handle_request(&mut self, request: JsonRpcRequest) -> Option<JsonRpcResponse> {
debug!("Handling request: {}", request.method);
@ -240,8 +257,8 @@ impl McpServer {
/// Handle tools/list request
async fn handle_tools_list(&self) -> Result<serde_json::Value, JsonRpcError> {
// v2.1.21: 25 tools (verified by the `tools.len() == 25` assertion in the
// handle_tools_list test below — the `suppress` tool landed in v2.0.5).
// v2.1.27: 34 tools (verified by the `tools.len() == 34` assertion in the
// handle_tools_list test below).
// Deprecated tools still work via redirects in handle_tools_call.
let mut tools = vec![
// ================================================================
@ -503,7 +520,7 @@ impl McpServer {
// Per-tool caps below are sized at ~2× observed peak with growth
// headroom; max permitted by Anthropic is 500_000. Only the four
// empirically-measured high-payload tools carry the annotation today;
// the remaining 21 tools deliberately do NOT (cargo-cult prevention —
// the remaining 30 tools deliberately do NOT (cargo-cult prevention —
// annotating a small-payload tool dilutes the signal).
//
// Other tools that COULD plausibly grow into the annotated set with
@ -563,6 +580,21 @@ impl McpServer {
None
};
// ================================================================
// AGENT BLACK BOX (v2.2)
// Open/continue a run for this call and record the opening `mcp.call`
// event (args are hashed, never stored raw). Downstream memory events
// are recorded from the result after dispatch.
// ================================================================
let run_id = crate::trace_recorder::run_id_for(&request.arguments);
crate::trace_recorder::record_call(
&self.storage,
self.event_tx.as_ref(),
&run_id,
&request.name,
&request.arguments,
);
let result = match request.name.as_str() {
// ================================================================
// UNIFIED TOOLS (v1.1+) - Preferred API
@ -1083,10 +1115,81 @@ impl McpServer {
// ================================================================
if let Ok(ref content) = result {
self.emit_tool_event(&request.name, &saved_args, content);
// Black Box: record the downstream memory events (retrieve /
// suppress / write / veto / dream) the agent experienced.
crate::trace_recorder::record_result(
&self.storage,
self.event_tx.as_ref(),
&run_id,
&request.name,
content,
);
}
// ================================================================
// RISK-GATED MEMORY PRs (v2.2) — quarantine review, the cognitive
// immune system. Normal writes auto-land. Risky writes (contradiction
// vs high-trust, supersede/forget/merge, sensitive topics, …) are
// *committed then quarantined*: the row is recorded (audit history
// preserved) but suppressed out of retrieval until a Memory PR is
// decided. This is quarantine review, NOT pre-write blocking — the
// write happens inside the tool before the gate sees it; we hold its
// influence, not its existence. Centralized here so tools stay
// untouched.
// ================================================================
let opened_prs = if let Ok(ref content) = result {
crate::trace_recorder::gate_writes(
&self.storage,
self.event_tx.as_ref(),
&run_id,
&request.name,
content,
self.review_mode(),
)
} else {
Vec::new()
};
let response = match result {
Ok(content) => {
Ok(mut content) => {
// ============================================================
// TRACE SPINE (Phase 0)
// Stamp the runId + a pointer to the full trace onto the tool
// output itself. This is the first hop of the correlation
// chain: the same runId now appears in the tool result, the
// SQLite trace rows, the WebSocket events, /api/traces/{runId},
// and vestige://trace/{runId}. One id, end to end.
// ============================================================
// Memory Receipt: for retrieval tools, build + persist a
// receipt from what the tool already computed and attach it.
// Done before the runId stamp so the receipt's own suppressed
// list is part of the same payload the agent reads.
let receipt =
crate::trace_recorder::build_and_save_receipt(&self.storage, &run_id, &request.name, &content);
if let Some(obj) = content.as_object_mut() {
obj.insert("runId".to_string(), serde_json::json!(run_id));
obj.insert(
"traceUri".to_string(),
serde_json::json!(format!("vestige://trace/{run_id}")),
);
if let Some(r) = receipt {
obj.insert("receipt".to_string(), r);
}
// Surface opened Memory PRs so the agent learns its risky
// write is held for review, not silently committed.
if !opened_prs.is_empty() {
obj.insert(
"memoryPrsOpened".to_string(),
serde_json::json!(opened_prs),
);
obj.insert(
"memoryPrNotice".to_string(),
serde_json::json!(
"Vestige opened a Memory PR (quarantine review): this write was recorded but is held out of retrieval until reviewed — its audit history is preserved while its influence is suspended. See the Memory PRs queue."
),
);
}
}
let call_result = CallToolResult {
content: vec![crate::protocol::messages::ToolResultContent {
content_type: "text".to_string(),
@ -1228,6 +1331,27 @@ impl McpServer {
description: Some("Intentions that have been triggered or are overdue".to_string()),
mime_type: Some("application/json".to_string()),
},
// Agent Black Box (v2.2) — replayable agent-run traces. Individual
// runs are read via the templated `vestige://trace/{runId}` (or
// `trace://{runId}`) URI; these concrete entries list the runs and
// the latest trace so a client can discover them.
ResourceDescription {
uri: "trace://runs".to_string(),
name: "Agent Runs (Black Box)".to_string(),
description: Some(
"Recent agent runs. Read vestige://trace/{runId} for a full replayable trace."
.to_string(),
),
mime_type: Some("application/json".to_string()),
},
ResourceDescription {
uri: "trace://latest".to_string(),
name: "Latest Agent Trace".to_string(),
description: Some(
"The most recently active agent run's full black-box trace.".to_string(),
),
mime_type: Some("application/json".to_string()),
},
];
let result = ListResourcesResult { resources };
@ -1250,7 +1374,17 @@ impl McpServer {
// OpenCode and other MCP clients may send "vestige/memory://recent"
// but we register resources as "memory://recent"
let normalized_uri = uri.strip_prefix("vestige/").unwrap_or(uri);
let content = if normalized_uri.starts_with("memory://") {
// The trace resource is specced as `vestige://trace/{runId}`. Accept
// both that form and the bare `trace://{runId}` scheme, normalizing the
// former to the latter so the resource module sees one shape.
let trace_uri = normalized_uri
.strip_prefix("vestige://trace/")
.map(|rest| format!("trace://{rest}"));
let content = if let Some(ref tu) = trace_uri {
resources::trace::read(&self.storage, tu).await
} else if normalized_uri.starts_with("trace://") {
resources::trace::read(&self.storage, normalized_uri).await
} else if normalized_uri.starts_with("memory://") {
resources::memory::read(&self.storage, normalized_uri).await
} else if normalized_uri.starts_with("codebase://") {
resources::codebase::read(&self.storage, normalized_uri).await
@ -1820,9 +1954,9 @@ mod tests {
let result = response.result.unwrap();
let tools = result["tools"].as_array().unwrap();
// 34 tools: 25 from v2.1.21 + 7 Phase 3 merge/supersede tools
// (merge_candidates, plan_merge, plan_supersede, apply_plan, merge_undo,
// protect, merge_policy, composed_graph) + 1 connector tool (source_sync, #57).
// 34 tools in v2.1.27: the unified memory surface, Phase 3
// merge/supersede controls, ComposedGraph, and the #57 source_sync
// connector tool.
assert_eq!(tools.len(), 34, "Expected exactly 34 tools");
let tool_names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
@ -2248,4 +2382,245 @@ mod tests {
"search tool has un-renamed `meta` key (regression — serde rename broke)"
);
}
// ========================================================================
// TRACE SPINE (Phase 0) — one runId, end to end
// ========================================================================
/// Every tools/call must stamp a runId + a trace pointer onto its output,
/// persist an `mcp.call` trace row under that same runId, and that runId
/// must resolve through the `vestige://trace/{runId}` resource. This is the
/// load-bearing correlation guarantee.
#[tokio::test]
async fn test_trace_spine_runid_end_to_end() {
let (mut server, _dir) = test_server().await;
server
.handle_request(make_request("initialize", Some(init_params())))
.await;
// A client-supplied runId must be honoured so a whole session
// correlates under one id.
let call = make_request(
"tools/call",
Some(serde_json::json!({
"name": "memory_health",
"arguments": { "runId": "run_spine_test" }
})),
);
let response = server.handle_request(call).await.unwrap();
let result = response.result.expect("tools/call ok");
// 1. The tool output itself carries the runId + trace pointer.
let structured = &result["structuredContent"];
assert_eq!(
structured["runId"].as_str(),
Some("run_spine_test"),
"tool output must echo the runId (spine hop 1)"
);
assert_eq!(
structured["traceUri"].as_str(),
Some("vestige://trace/run_spine_test"),
"tool output must carry the trace resource pointer"
);
// 2. The same runId persisted a trace row (the mcp.call event).
let events = server.storage.get_trace("run_spine_test").unwrap();
assert!(
events.iter().any(|e| e.kind() == "mcp.call"),
"an mcp.call event must be persisted under the runId (spine hop 2)"
);
// 3. The run roll-up exists with the right entry tool.
let run = server
.storage
.get_agent_run("run_spine_test")
.unwrap()
.expect("run summary persisted");
assert_eq!(run.first_tool.as_deref(), Some("memory_health"));
// 4. The MCP resource resolves the same runId (spine hop 3).
let read = make_request(
"resources/read",
Some(serde_json::json!({ "uri": "vestige://trace/run_spine_test" })),
);
let read_resp = server.handle_request(read).await.unwrap();
let read_result = read_resp.result.expect("resource read ok");
let text = read_result["contents"][0]["text"]
.as_str()
.expect("resource text");
assert!(
text.contains("run_spine_test") && text.contains("mcp.call"),
"vestige://trace/{{runId}} must return the run's events"
);
}
/// Trace events must be broadcast to a live WebSocket subscriber, not just
/// persisted. This guards the spine hop from SQLite → WebSocket → pulse.
#[tokio::test]
async fn test_trace_event_is_broadcast_to_subscriber() {
let (storage, _dir) = test_storage().await;
let cognitive = Arc::new(Mutex::new(CognitiveEngine::new()));
let (event_tx, mut event_rx) = broadcast::channel(64);
let mut server = McpServer::new_with_events(storage, cognitive, event_tx);
server
.handle_request(make_request("initialize", Some(init_params())))
.await;
let call = make_request(
"tools/call",
Some(serde_json::json!({
"name": "memory_health",
"arguments": { "runId": "run_ws" }
})),
);
server.handle_request(call).await.unwrap();
// Drain the broadcast: at least one TraceEvent for run_ws must arrive.
let mut saw_trace = false;
while let Ok(ev) = event_rx.try_recv() {
if let VestigeEvent::TraceEvent { run_id, .. } = ev {
if run_id == "run_ws" {
saw_trace = true;
}
}
}
assert!(
saw_trace,
"a TraceEvent for the run must be broadcast to subscribers (spine hop: WebSocket)"
);
}
/// Risk-gated Memory PRs default: an ordinary tool call opens no PR.
#[tokio::test]
async fn test_no_memory_pr_for_non_write_tool() {
let (mut server, _dir) = test_server().await;
server
.handle_request(make_request("initialize", Some(init_params())))
.await;
let call = make_request(
"tools/call",
Some(serde_json::json!({
"name": "memory_health",
"arguments": { "runId": "run_no_pr" }
})),
);
server.handle_request(call).await.unwrap();
assert_eq!(
server.storage.count_pending_memory_prs().unwrap(),
0,
"a read-only tool must never open a Memory PR"
);
}
/// PROOF LOCK: the complete spine in one test. A single runId must cross
/// every hop, and the value must be byte-identical at each:
/// MCP output → SQLite trace → WebSocket event → API response shape →
/// MCP resource.
/// If any hop drops or rewrites the runId, this fails. This is the
/// "impossible to doubt" guarantee for the receipt chain.
#[tokio::test]
async fn test_full_spine_one_runid_crosses_every_hop() {
const RUN: &str = "run_full_spine";
let (storage, _dir) = test_storage().await;
let cognitive = Arc::new(Mutex::new(CognitiveEngine::new()));
let (event_tx, mut event_rx) = broadcast::channel(256);
let mut server = McpServer::new_with_events(storage, cognitive, event_tx);
server
.handle_request(make_request("initialize", Some(init_params())))
.await;
// ---- HOP 1: MCP tool output carries the runId + trace pointer ----
let call = make_request(
"tools/call",
Some(serde_json::json!({
"name": "memory_health",
"arguments": { "runId": RUN }
})),
);
let response = server.handle_request(call).await.unwrap();
let structured = response.result.expect("tools/call ok")["structuredContent"].clone();
assert_eq!(structured["runId"].as_str(), Some(RUN), "HOP 1: tool output runId");
assert_eq!(
structured["traceUri"].as_str(),
Some(&format!("vestige://trace/{RUN}")[..]),
"HOP 1: tool output traceUri"
);
// ---- HOP 2: SQLite trace rows persisted under the same runId ----
let events = server.storage.get_trace(RUN).unwrap();
assert!(!events.is_empty(), "HOP 2: trace rows exist");
assert!(
events.iter().all(|e| e.run_id() == RUN),
"HOP 2: every persisted trace row carries the SAME runId"
);
// ---- HOP 3: WebSocket broadcast carries the same runId ----
let mut ws_run: Option<String> = None;
while let Ok(ev) = event_rx.try_recv() {
if let VestigeEvent::TraceEvent { run_id, .. } = ev {
ws_run = Some(run_id);
break;
}
}
assert_eq!(
ws_run.as_deref(),
Some(RUN),
"HOP 3: the broadcast TraceEvent carries the same runId"
);
// ---- HOP 4: API response shape (what the dashboard renders) ----
// Exercise the exact handler the dashboard /api/traces/:runId calls by
// going through storage the same way, and assert the render-critical
// shape: a summary roll-up + an ordered event list, all under runId.
let summary = server
.storage
.get_agent_run(RUN)
.unwrap()
.expect("HOP 4: run summary the list view renders");
assert_eq!(summary.run_id, RUN, "HOP 4: API run summary runId");
assert!(summary.event_count >= 1, "HOP 4: event_count rendered in the list");
// The detail view renders these events in sequence order.
let detail_events = server.storage.get_trace(RUN).unwrap();
assert_eq!(
detail_events.len() as i64,
summary.event_count,
"HOP 4: detail event count matches the roll-up the list shows"
);
// ---- HOP 5: MCP resource resolves the same runId ----
let read = make_request(
"resources/read",
Some(serde_json::json!({ "uri": format!("vestige://trace/{RUN}") })),
);
let read_resp = server.handle_request(read).await.unwrap();
let text = read_resp.result.expect("resource read ok")["contents"][0]["text"]
.as_str()
.expect("resource text")
.to_string();
let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(
parsed["runId"].as_str(),
Some(RUN),
"HOP 5: vestige://trace/{{runId}} resolves the same runId"
);
assert!(
parsed["events"].as_array().map(|a| !a.is_empty()).unwrap_or(false),
"HOP 5: the resource returns the run's events"
);
// ---- INVARIANT: one id, every hop, byte-identical ----
// Collect the runId as seen at each hop and assert they are all equal.
let seen = [
structured["runId"].as_str().unwrap().to_string(), // hop 1
events[0].run_id().to_string(), // hop 2
ws_run.unwrap(), // hop 3
summary.run_id, // hop 4
parsed["runId"].as_str().unwrap().to_string(), // hop 5
];
assert!(
seen.iter().all(|r| r == RUN),
"the SAME runId must appear, unchanged, at every hop: {seen:?}"
);
}
}

File diff suppressed because it is too large Load diff