feat(launch-polish): finish dashboard auth trust boundary + pending-review UX

Completes the launch-polish lane Codex started (rate-limited mid-wiring):

Trust boundary (was: dashboard API/WS unauthenticated):
- Token middleware on all /api/* and /ws, reusing the persisted Vestige auth
  token with constant-time compare (subtle); Sec-Fetch-Site + exact-Origin checks
- Token delivered via URL fragment (never sent to server / logged), installed
  into localStorage on app load — WIRES installDashboardTokenFromLocation() into
  the root +layout onMount BEFORE websocket.connect()/any fetch (the missing
  piece that otherwise 401s the dashboard against its own API)

Fail-closed memory governance:
- Dashboard delete/suppress pre-gate through Memory PRs in Risk-Gated/Paranoid
  (Fast still mutates directly); decision-before-mutation; replay -> 409
- Duplicate pending-PR reuse; PR content redacted to preview+hash at the API layer

Black Box / receipts:
- Live refresh of run list + receipts on real TraceEvents (trace-before-receipt
  race handled via retry); synthetic events excluded from proof counters

Fixes on top of Codex's work:
- websocket.ts: source 'synthetic' -> `as const` (TS error, blocked pnpm check)
- memories: scoped a11y-ignore on the non-interactive review-notice
- collapsed sanitize_memory_pr_json (clippy collapsible_if); rustfmt the 7
  touched Rust files

Gates: pnpm check 0/0 (905 files); vestige-mcp 453 tests; core trace/review/
receipt 67 tests; clippy -D warnings clean. All green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Valladares 2026-06-24 17:28:38 -05:00
parent 19ef586056
commit 0ff9527d2b
14 changed files with 2302 additions and 662 deletions

View file

@ -11,7 +11,8 @@ import type {
RetentionDistribution,
ConsolidationResult,
IntentionItem,
SuppressResult,
DeleteMemoryResult,
SuppressMemoryResult,
UnsuppressResult,
SanhedrinAppealReason,
SanhedrinAppealResponse,
@ -20,14 +21,81 @@ import type {
} from '$types';
const BASE = '/api';
const DASHBOARD_TOKEN_STORAGE_KEY = 'vestige.dashboard.token';
const DASHBOARD_TOKEN_HEADER = 'X-Vestige-Dashboard-Token';
async function fetcher<T>(path: string, options?: RequestInit): Promise<T> {
const headers = withDashboardAuthHeaders(options?.headers);
const res = await fetch(`${BASE}${path}`, {
headers: { 'Content-Type': 'application/json' },
...options
...options,
headers
});
if (!res.ok) {
if (res.status === 401 || res.status === 403) {
throw new Error(
`API ${res.status}: dashboard auth failed. Open Vestige from the CLI or set VESTIGE_AUTH_TOKEN.`
);
}
throw new Error(`API ${res.status}: ${res.statusText}`);
}
return res.json();
}
function withDashboardAuthHeaders(headers?: HeadersInit): Headers {
const next = new Headers(headers);
if (!next.has('Content-Type')) {
next.set('Content-Type', 'application/json');
}
const token = getDashboardAuthToken();
if (token) {
next.set(DASHBOARD_TOKEN_HEADER, token);
next.set('Authorization', `Bearer ${token}`);
}
return next;
}
export function getDashboardAuthToken(): string | null {
if (typeof window === 'undefined') return null;
return window.localStorage.getItem(DASHBOARD_TOKEN_STORAGE_KEY);
}
export function installDashboardTokenFromLocation(): boolean {
if (typeof window === 'undefined') return false;
const hash = window.location.hash.startsWith('#')
? window.location.hash.slice(1)
: window.location.hash;
if (!hash) return false;
const params = new URLSearchParams(hash);
const token = params.get('vestige_token') ?? params.get('token');
if (!token) return false;
window.localStorage.setItem(DASHBOARD_TOKEN_STORAGE_KEY, token);
params.delete('vestige_token');
params.delete('token');
const remainingHash = params.toString();
const nextUrl = `${window.location.pathname}${window.location.search}${
remainingHash ? `#${remainingHash}` : ''
}`;
window.history.replaceState(null, document.title, nextUrl);
return true;
}
async function downloadJson(path: string, filename: string): Promise<void> {
const res = await fetch(`${BASE}${path}`, {
headers: withDashboardAuthHeaders()
});
if (!res.ok) throw new Error(`API ${res.status}: ${res.statusText}`);
return res.json();
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
export const api = {
@ -37,16 +105,16 @@ export const api = {
const qs = params ? '?' + new URLSearchParams(params).toString() : '';
return fetcher<MemoryListResponse>(`/memories${qs}`);
},
get: (id: string) => fetcher<Memory>(`/memories/${id}`),
delete: (id: string) => fetcher<{ deleted: boolean }>(`/memories/${id}`, { method: 'DELETE' }),
promote: (id: string) => fetcher<Memory>(`/memories/${id}/promote`, { method: 'POST' }),
demote: (id: string) => fetcher<Memory>(`/memories/${id}/demote`, { method: 'POST' }),
get: (id: string) => fetcher<Memory>(`/memories/${id}`),
delete: (id: string) => fetcher<DeleteMemoryResult>(`/memories/${id}`, { method: 'DELETE' }),
promote: (id: string) => fetcher<Memory>(`/memories/${id}/promote`, { method: 'POST' }),
demote: (id: string) => fetcher<Memory>(`/memories/${id}/demote`, { method: 'POST' }),
// v2.0.7: suppress + unsuppress. Anderson 2025 top-down inhibitory
// control. Each suppress call compounds; reversible within 24h. The
// backend emits MemorySuppressed / MemoryUnsuppressed so the 3D graph
// plays the violet implosion / rainbow reversal.
suppress: (id: string, reason?: string) =>
fetcher<SuppressResult>(`/memories/${id}/suppress`, {
suppress: (id: string, reason?: string) =>
fetcher<SuppressMemoryResult>(`/memories/${id}/suppress`, {
method: 'POST',
body: reason ? JSON.stringify({ reason }) : undefined
}),
@ -138,9 +206,19 @@ export const api = {
// 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}`),
list: (limit = 50, runId?: string) => {
const qs = new URLSearchParams();
qs.set('limit', String(limit));
if (runId) qs.set('run', runId);
return fetcher<TraceRunListResponse>(`/traces?${qs.toString()}`);
},
get: (runId: string) => fetcher<TraceDetail>(`/traces/${encodeURIComponent(runId)}`),
exportUrl: (runId: string) => `${BASE}/traces/${encodeURIComponent(runId)}/export`
exportUrl: (runId: string) => `${BASE}/traces/${encodeURIComponent(runId)}/export`,
download: (runId: string) =>
downloadJson(
`/traces/${encodeURIComponent(runId)}/export`,
`${runId}.vestige-trace.json`
)
},
// Memory Receipts (v2.2): the nutrition label for a retrieval.

View file

@ -1,5 +1,6 @@
import { writable, derived } from 'svelte/store';
import type { VestigeEvent } from '$types';
import { getDashboardAuthToken } from './api';
const MAX_EVENTS = 200;
@ -23,9 +24,11 @@ function createWebSocketStore() {
let reconnectAttempts = 0;
function connect(url?: string) {
const wsUrl = url || (window.location.port === '5173'
const baseWsUrl = url || (window.location.port === '5173'
? `ws://${window.location.hostname}:3927/ws`
: `ws://${window.location.host}/ws`);
const token = getDashboardAuthToken();
const wsUrl = token ? withToken(baseWsUrl, token) : baseWsUrl;
if (ws?.readyState === WebSocket.OPEN) return;
@ -93,7 +96,7 @@ function createWebSocketStore() {
*/
function injectEvent(event: VestigeEvent) {
update(s => {
const events = [event, ...s.events].slice(0, MAX_EVENTS);
const events = [{ ...event, source: 'synthetic' as const }, ...s.events].slice(0, MAX_EVENTS);
return { ...s, events };
});
}
@ -107,6 +110,12 @@ function createWebSocketStore() {
};
}
function withToken(url: string, token: string): string {
const next = new URL(url, window.location.href);
next.searchParams.set('token', token);
return next.toString();
}
export const websocket = createWebSocketStore();
// Derived stores for specific event types
@ -136,19 +145,19 @@ export const uptimeSeconds = derived(websocket, $ws =>
// 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')
$ws.events.filter((e) => e.type === 'TraceEvent' && e.source !== 'synthetic')
);
// 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');
const latest = $ws.events.find((e) => e.type === 'TraceEvent' && e.source !== 'synthetic');
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
$ws.events.find((e) => e.type === 'TraceEvent' && e.source !== 'synthetic') ?? null
);
// Live Memory PR notifications (opened / decided) for the queue badge + toasts.

View file

@ -176,6 +176,7 @@ export type VestigeEventType =
export interface VestigeEvent {
type: VestigeEventType;
data: Record<string, unknown>;
source?: 'socket' | 'synthetic';
}
// v2.0.7: active-forgetting response shapes. Each suppress call COMPOUNDS;
@ -196,6 +197,27 @@ export interface SuppressResult {
reason: string | null;
}
export interface MemoryPrOpenedSummary {
id: string;
kind: string;
title: string;
signals?: Array<Record<string, unknown>>;
subjectId?: string;
}
export interface PendingReviewResult {
action: string;
success: false;
pendingReview: true;
nodeId: string;
message: string;
memoryPrsOpened: MemoryPrOpenedSummary[];
memoryPrNotice?: string;
}
export type DeleteMemoryResult = { deleted: true; id: string } | PendingReviewResult;
export type SuppressMemoryResult = SuppressResult | PendingReviewResult;
export interface UnsuppressResult {
unsuppressed: true;
id: string;

View file

@ -13,7 +13,7 @@
// Live events are real — they arrive over the WebSocket backed by trace
// rows. No fake demo events.
// ═══════════════════════════════════════════════════════════════════════
import { onMount } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import PageHeader from '$components/PageHeader.svelte';
import Icon from '$components/Icon.svelte';
import AnimatedNumber from '$components/AnimatedNumber.svelte';
@ -46,6 +46,8 @@
let scrubIndex = $state(0); // index into detail.events
let proofMode = $state(false);
let receipts = $state<Receipt[]>([]);
let liveRefreshTimer: ReturnType<typeof setTimeout> | null = null;
let receiptRetryTimer: ReturnType<typeof setTimeout> | null = null;
// The events up to and including the scrubber position — what the agent had
// "experienced" at that moment in the run.
@ -69,11 +71,15 @@
detail?.events.some((e) => e.type === 'contradiction.detected') ?? false
);
async function loadRuns() {
async function loadRuns(preferredRunId?: string | null) {
try {
const res = await api.traces.list(100);
runs = res.runs;
if (!selectedRunId && runs.length) selectRun(runs[0].runId);
if (preferredRunId) {
await selectRun(preferredRunId);
} else if (!selectedRunId && runs.length) {
await selectRun(runs[0].runId);
}
} catch (e) {
error = String(e);
}
@ -84,11 +90,11 @@
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;
const next = await api.traces.get(runId);
if (selectedRunId !== runId) return;
detail = next;
scrubIndex = Math.max(0, (next.events.length || 1) - 1);
await loadReceiptsForRun(runId);
} catch (e) {
error = String(e);
detail = null;
@ -97,30 +103,77 @@
}
}
async function refreshSelectedRun(runId: string, pinToLatest = false) {
const wasPinnedToLatest = !detail || scrubIndex >= detail.events.length - 1;
const next = await api.traces.get(runId);
if (selectedRunId !== runId) return;
detail = next;
if (pinToLatest || wasPinnedToLatest) {
scrubIndex = Math.max(0, next.events.length - 1);
}
}
async function loadReceiptsForRun(runId: string) {
const next = (await api.receipts.listForRun(runId, 8)).receipts;
if (selectedRunId === runId) {
receipts = next;
}
}
function scheduleReceiptRetry(runId: string) {
if (receiptRetryTimer) clearTimeout(receiptRetryTimer);
receiptRetryTimer = setTimeout(() => {
if (selectedRunId === runId) {
void loadReceiptsForRun(runId);
}
}, 300);
}
function scheduleLiveRefresh(runId: string, eventType?: TraceEvent['type']) {
if (liveRefreshTimer) clearTimeout(liveRefreshTimer);
liveRefreshTimer = setTimeout(async () => {
await loadRuns();
if (!selectedRunId) {
await selectRun(runId);
return;
}
if (selectedRunId !== runId) return;
await refreshSelectedRun(runId, true);
await loadReceiptsForRun(runId);
if (eventType === 'memory.retrieve') {
scheduleReceiptRetry(runId);
}
}, 75);
}
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.
// Live: when a real trace event arrives, refresh the run picker and the open
// trace. Receipts can be saved just after the TraceEvent broadcast, so
// retrieval events get one delayed retry to avoid a stale proof panel.
$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);
});
const event = last.data?.event as TraceEvent | undefined;
const evRunId = (last.data?.run_id as string | undefined) ?? event?.runId;
if (evRunId) {
scheduleLiveRefresh(evRunId, event?.type);
}
});
onMount(loadRuns);
onMount(() => {
const preferredRunId = new URLSearchParams(window.location.search).get('run');
loadRuns(preferredRunId);
});
onDestroy(() => {
if (liveRefreshTimer) clearTimeout(liveRefreshTimer);
if (receiptRetryTimer) clearTimeout(receiptRetryTimer);
});
</script>
<div class="mx-auto max-w-6xl px-5 py-6">

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$stores/api';
import type { Memory } from '$types';
import type { Memory, PendingReviewResult } from '$types';
import { NODE_TYPE_COLORS } from '$types';
import MemoryAuditTrail from '$lib/components/MemoryAuditTrail.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
@ -18,6 +18,9 @@
let minRetention = $state(0);
let loading = $state(true);
let selectedMemory: Memory | null = $state(null);
let reviewNotices: Record<string, { action: string; prId?: string; title?: string; message: string }> = $state({});
let actionErrors: Record<string, string> = $state({});
let actionBusy: Record<string, string> = $state({});
// Which inner tab of the expanded card is active. Keyed by memory id so
// switching between cards remembers each one's last view independently.
let expandedTab: Record<string, 'content' | 'audit'> = $state({});
@ -53,6 +56,75 @@
return '#ef4444';
}
function isPendingReview(result: unknown): result is PendingReviewResult {
return Boolean(result && typeof result === 'object' && (result as PendingReviewResult).pendingReview);
}
function setBusy(id: string, action: string | null) {
if (action) {
actionBusy = { ...actionBusy, [id]: action };
return;
}
const { [id]: _removed, ...rest } = actionBusy;
actionBusy = rest;
}
function setError(id: string, message: string | null) {
if (message) {
actionErrors = { ...actionErrors, [id]: message };
return;
}
const { [id]: _removed, ...rest } = actionErrors;
actionErrors = rest;
}
function setReviewNotice(id: string, result: PendingReviewResult) {
const firstPr = result.memoryPrsOpened?.[0];
reviewNotices = {
...reviewNotices,
[id]: {
action: result.action,
prId: firstPr?.id,
title: firstPr?.title,
message: result.message
}
};
}
async function requestSuppress(memory: Memory) {
setBusy(memory.id, 'suppress');
setError(memory.id, null);
try {
const result = await api.memories.suppress(memory.id, 'dashboard trigger');
if (isPendingReview(result)) {
setReviewNotice(memory.id, result);
return;
}
await loadMemories();
} catch (e) {
setError(memory.id, e instanceof Error ? e.message : String(e));
} finally {
setBusy(memory.id, null);
}
}
async function requestDelete(memory: Memory) {
setBusy(memory.id, 'delete');
setError(memory.id, null);
try {
const result = await api.memories.delete(memory.id);
if (isPendingReview(result)) {
setReviewNotice(memory.id, result);
return;
}
await loadMemories();
} catch (e) {
setError(memory.id, e instanceof Error ? e.message : String(e));
} finally {
setBusy(memory.id, null);
}
}
// Clear, labelled dropdown options replace the dead native <select>.
const typeOptions: DropdownOption[] = [
{ value: '', label: 'All types' },
@ -171,9 +243,12 @@
</div>
</div>
{#if selectedMemory?.id === memory.id}
{@const activeTab = expandedTab[memory.id] ?? 'content'}
<div class="relative z-[1] mt-4 pt-4 border-t border-synapse/10 space-y-3">
{#if selectedMemory?.id === memory.id}
{@const activeTab = expandedTab[memory.id] ?? 'content'}
{@const reviewNotice = reviewNotices[memory.id]}
{@const actionError = actionErrors[memory.id]}
{@const busyAction = actionBusy[memory.id]}
<div class="relative z-[1] mt-4 pt-4 border-t border-synapse/10 space-y-3">
<!-- Inner tab switcher: Content (default) vs Audit Trail. -->
<div class="flex gap-1 text-[11px] uppercase tracking-wider">
<span
@ -209,39 +284,124 @@
>
<MemoryAuditTrail memoryId={memory.id} />
</div>
{/if}
{/if}
<div class="flex gap-2">
<span role="button" tabindex="0" onclick={(e) => { e.stopPropagation(); api.memories.promote(memory.id); }}
onkeydown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); api.memories.promote(memory.id); } }}
{#if reviewNotice}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<!-- Handlers only stop propagation so a click on this status
notice doesn't trigger the parent card; not interactive. -->
<div
class="review-notice"
role="status"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<div class="review-copy">
<span class="review-kicker"><Icon name="memorypr" size={14} /> Review pending</span>
<strong>{reviewNotice.title ?? 'Memory mutation held for review'}</strong>
<small>{reviewNotice.message}</small>
</div>
<a class="review-link" href="/memory-prs" onclick={(e) => e.stopPropagation()}>
Open queue
</a>
</div>
{/if}
{#if actionError}
<div class="action-error" role="alert">{actionError}</div>
{/if}
<div class="flex gap-2">
<span role="button" tabindex="0" onclick={(e) => { e.stopPropagation(); api.memories.promote(memory.id); }}
onkeydown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); api.memories.promote(memory.id); } }}
class="px-3 py-1.5 bg-recall/20 text-recall text-xs rounded-lg hover:bg-recall/30 cursor-pointer select-none">Promote</span>
<span role="button" tabindex="0" onclick={(e) => { e.stopPropagation(); api.memories.demote(memory.id); }}
onkeydown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); api.memories.demote(memory.id); } }}
class="px-3 py-1.5 bg-decay/20 text-decay text-xs rounded-lg hover:bg-decay/30 cursor-pointer select-none">Demote</span>
<!-- v2.0.7: suppress (active forgetting). Distinct from delete: the memory
persists but is inhibited from retrieval and actively decays. Each click
compounds. Graph plays the violet implosion via MemorySuppressed event. -->
<span role="button" tabindex="0"
onclick={async (e) => {
e.stopPropagation();
await api.memories.suppress(memory.id, 'dashboard trigger');
}}
onkeydown={async (e) => {
if (e.key === 'Enter') {
<!-- v2.0.7: suppress (active forgetting). Distinct from delete: the memory
persists but is inhibited from retrieval and actively decays. Each click
compounds. Graph plays the violet implosion via MemorySuppressed event. -->
<span role="button" tabindex="0"
onclick={async (e) => {
e.stopPropagation();
await api.memories.suppress(memory.id, 'dashboard trigger');
}
}}
title="Top-down inhibition (Anderson 2025). Compounds. Reversible for 24h."
class="px-3 py-1.5 bg-purple-500/20 text-purple-400 text-xs rounded-lg hover:bg-purple-500/30 cursor-pointer select-none">Suppress</span>
<span role="button" tabindex="0" onclick={async (e) => { e.stopPropagation(); await api.memories.delete(memory.id); loadMemories(); }}
onkeydown={async (e) => { if (e.key === 'Enter') { e.stopPropagation(); await api.memories.delete(memory.id); loadMemories(); } }}
class="px-3 py-1.5 bg-decay/10 text-decay/60 text-xs rounded-lg hover:bg-decay/20 ml-auto cursor-pointer select-none">Delete</span>
await requestSuppress(memory);
}}
onkeydown={async (e) => {
if (e.key === 'Enter') {
e.stopPropagation();
await requestSuppress(memory);
}
}}
title="Top-down inhibition (Anderson 2025). Compounds. Reversible for 24h."
class="px-3 py-1.5 bg-purple-500/20 text-purple-400 text-xs rounded-lg hover:bg-purple-500/30 cursor-pointer select-none {busyAction === 'suppress' ? 'opacity-60 pointer-events-none' : ''}">
{busyAction === 'suppress' ? 'Holding…' : 'Suppress'}
</span>
<span role="button" tabindex="0" onclick={async (e) => { e.stopPropagation(); await requestDelete(memory); }}
onkeydown={async (e) => { if (e.key === 'Enter') { e.stopPropagation(); await requestDelete(memory); } }}
class="px-3 py-1.5 bg-decay/10 text-decay/60 text-xs rounded-lg hover:bg-decay/20 ml-auto cursor-pointer select-none {busyAction === 'delete' ? 'opacity-60 pointer-events-none' : ''}">
{busyAction === 'delete' ? 'Holding…' : 'Delete'}
</span>
</div>
</div>
</div>
{/if}
{/if}
</button>
{/each}
</div>
{/if}
</div>
{/if}
</div>
<style>
.review-notice {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 11px 12px;
border-radius: 10px;
border: 1px solid color-mix(in oklab, var(--color-synapse) 32%, transparent);
background: color-mix(in oklab, var(--color-synapse) 10%, transparent);
}
.review-copy {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.review-kicker {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
color: var(--color-synapse-glow);
}
.review-copy strong {
font-size: 0.82rem;
color: var(--color-text);
overflow-wrap: anywhere;
}
.review-copy small {
font-size: 0.74rem;
color: var(--color-text-dim);
line-height: 1.35;
}
.review-link {
flex-shrink: 0;
padding: 6px 9px;
border-radius: 8px;
font-size: 0.74rem;
font-weight: 700;
color: var(--color-synapse-glow);
background: color-mix(in oklab, var(--color-synapse) 14%, transparent);
border: 1px solid color-mix(in oklab, var(--color-synapse) 30%, transparent);
}
.action-error {
padding: 8px 10px;
border-radius: 9px;
font-size: 0.78rem;
color: #fecaca;
background: color-mix(in oklab, #ef4444 14%, transparent);
border: 1px solid color-mix(in oklab, #ef4444 30%, transparent);
}
</style>

View file

@ -152,8 +152,8 @@
// Diff rendering helpers
function diffContent(pr: MemoryPr): string {
const node = pr.diff?.node as { content?: string } | undefined;
return node?.content ?? '';
const node = pr.diff?.node as { content?: string; contentPreview?: string } | undefined;
return node?.contentPreview ?? '';
}
function diffNodeType(pr: MemoryPr): string {
const node = pr.diff?.node as { nodeType?: string } | undefined;
@ -163,6 +163,10 @@
const node = pr.diff?.node as { tags?: string[] } | undefined;
return node?.tags ?? [];
}
function diffHash(pr: MemoryPr): string {
const node = pr.diff?.node as { contentHash?: string } | undefined;
return node?.contentHash ?? '';
}
</script>
<div class="mx-auto max-w-6xl px-5 py-6">
@ -270,11 +274,15 @@
<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}
{#if selected.run_id}
<a
class="run-link"
href={`/blackbox?run=${encodeURIComponent(selected.run_id)}`}
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>
@ -284,10 +292,13 @@
{#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>
{#each diffTags(selected) as t (t)}
<span class="meta-pill tag">#{t}</span>
{/each}
{#if diffHash(selected)}
<span class="meta-pill">hash: {diffHash(selected)}</span>
{/if}
</div>
{#if diffContent(selected)}
<pre class="diff-add"><span class="gutter">+</span>{diffContent(selected)}</pre>
{/if}

View file

@ -20,6 +20,7 @@
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import Icon, { type IconName } from '$lib/components/Icon.svelte';
import { initTheme } from '$stores/theme';
import { installDashboardTokenFromLocation } from '$stores/api';
let { children } = $props();
let showCommandPalette = $state(false);
@ -31,6 +32,11 @@
let isMarketingRoute = $derived(dashboardPath === '/waitlist' || dashboardPath.startsWith('/waitlist/'));
onMount(() => {
// Vestige opens the dashboard with the auth token in the URL fragment.
// Install it into localStorage (and scrub it from the address bar) BEFORE
// any API call or WebSocket connect, or the now-authenticated /api and /ws
// endpoints reject every request.
installDashboardTokenFromLocation();
if (!isMarketingRoute) {
websocket.connect();
}

View file

@ -11,7 +11,7 @@
//! map rows back through a small closure.
use chrono::Utc;
use rusqlite::{params, OptionalExtension};
use rusqlite::{OptionalExtension, params};
use uuid::Uuid;
use super::sqlite::SqliteMemoryStore;
@ -136,9 +136,8 @@ impl SqliteMemoryStore {
.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 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)
@ -266,9 +265,8 @@ impl SqliteMemoryStore {
.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 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)
@ -436,7 +434,9 @@ impl SqliteMemoryStore {
.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",
"UPDATE memory_prs
SET status = ?1, decision = ?2, decided_at = ?3
WHERE id = ?4 AND status = 'pending'",
params![new_status.as_str(), decision, now, id],
)?;
if changed == 0 {
@ -458,10 +458,11 @@ impl SqliteMemoryStore {
.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 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());
let decision =
decision_s.and_then(|s| serde_json::from_value(serde_json::Value::String(s)).ok());
Ok(MemoryPr {
id: row.get("id")?,
@ -625,6 +626,39 @@ mod tests {
assert_eq!(s.count_pending_memory_prs().unwrap(), 0);
}
#[test]
fn decided_memory_pr_cannot_be_decided_again() {
let s = store();
let pr = MemoryPr {
id: "pr_once".into(),
kind: MemoryPrKind::ContradictionDetected,
status: MemoryPrStatus::Pending,
title: "Decide once".into(),
diff: serde_json::json!({"before": "x", "after": "y"}),
signals: vec![],
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();
let decided = s
.decide_memory_pr("pr_once", MemoryPrAction::Promote)
.unwrap();
assert_eq!(decided.status, MemoryPrStatus::Promoted);
assert!(
s.decide_memory_pr("pr_once", MemoryPrAction::Forget)
.is_err(),
"a decided Memory PR must not accept a second decision"
);
let still_promoted = s.get_memory_pr("pr_once").unwrap().unwrap();
assert_eq!(still_promoted.status, MemoryPrStatus::Promoted);
assert_eq!(still_promoted.decision, Some(MemoryPrAction::Promote));
}
#[test]
fn promote_releases_a_quarantined_memory_end_to_end() {
// B1 regression: the full quarantine→release cycle at the storage layer.
@ -649,9 +683,7 @@ mod tests {
// 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");
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"
@ -723,9 +755,10 @@ mod tests {
decision: None,
};
s.save_memory_pr(&pr).unwrap();
assert!(s
.decide_memory_pr("pr_2", MemoryPrAction::AskAgentWhy)
.is_err());
assert!(
s.decide_memory_pr("pr_2", MemoryPrAction::AskAgentWhy)
.is_err()
);
// Still pending.
assert_eq!(s.count_pending_memory_prs().unwrap(), 1);
}

View file

@ -161,6 +161,19 @@ pub async fn delete_memory(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Value>, StatusCode> {
if let Some(gated) = gate_dashboard_pending_memory_mutation(
&state,
"memory",
serde_json::json!({
"action": "delete",
"id": id.clone(),
"confirm": true,
"reason": "Dashboard delete requested",
}),
)? {
return Ok(Json(gated));
}
let deleted = state
.storage
.delete_node(&id)
@ -250,6 +263,17 @@ pub async fn suppress_memory(
.and_then(|r| r.as_str())
.map(String::from);
if let Some(gated) = gate_dashboard_pending_memory_mutation(
&state,
"suppress",
serde_json::json!({
"id": id.clone(),
"reason": reason.clone(),
}),
)? {
return Ok(Json(gated));
}
let sys = ActiveForgettingSystem::new();
// Pre-count to surface in the response + for the event payload.
@ -310,6 +334,26 @@ pub async fn suppress_memory(
})))
}
fn gate_dashboard_pending_memory_mutation(
state: &AppState,
tool: &str,
args: Value,
) -> Result<Option<Value>, StatusCode> {
let run_id = format!("dashboard_{}", uuid::Uuid::new_v4().simple());
crate::trace_recorder::gate_pending_memory_mutation(
&state.storage,
Some(&state.event_tx),
&run_id,
tool,
&Some(args),
read_review_mode(state),
)
.map_err(|e| {
tracing::warn!("dashboard memory mutation review gate failed closed: {e}");
StatusCode::INTERNAL_SERVER_ERROR
})
}
/// Reverse a prior suppression, if still inside the 24-hour labile
/// window. Emits `MemoryUnsuppressed` so the graph plays the rainbow
/// reversal burst. Returns the current suppression state so the UI
@ -2000,10 +2044,18 @@ pub async fn list_traces(
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 = match params.run.as_deref().filter(|r| !r.is_empty()) {
Some(run_id) => state
.storage
.get_agent_run(run_id)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.into_iter()
.collect(),
None => state
.storage
.list_agent_runs(limit)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
};
let runs_json: Vec<Value> = runs
.into_iter()
.map(|r| {
@ -2090,7 +2142,13 @@ pub async fn export_trace(
// 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 { '_' })
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
c
} else {
'_'
}
})
.collect();
let safe = if safe.trim_matches('_').is_empty() {
"trace".to_string()
@ -2172,6 +2230,10 @@ pub async fn list_memory_prs(
.storage
.list_memory_prs(status, limit)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let prs: Vec<Value> = prs
.into_iter()
.map(|pr| sanitize_memory_pr_json(serde_json::to_value(pr).unwrap_or_default()))
.collect();
let pending = state.storage.count_pending_memory_prs().unwrap_or(0);
Ok(Json(serde_json::json!({
"total": prs.len(),
@ -2191,7 +2253,25 @@ pub async fn get_memory_pr(
.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()))
Ok(Json(sanitize_memory_pr_json(
serde_json::to_value(pr).unwrap_or_default(),
)))
}
fn sanitize_memory_pr_json(mut value: Value) -> Value {
if let Some(node) = value
.get_mut("diff")
.and_then(|diff| diff.get_mut("node"))
.and_then(|node| node.as_object_mut())
&& node.remove("content").is_some()
&& !node.contains_key("contentPreview")
{
node.insert(
"contentPreview".to_string(),
serde_json::json!("[redacted legacy content]"),
);
}
value
}
/// Act on a Memory PR: promote / merge / supersede / quarantine / forget /
@ -2200,25 +2280,141 @@ 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)?;
let action =
vestige_core::MemoryPrAction::from_label(&action).ok_or(StatusCode::BAD_REQUEST)?;
let current_pr = state
.storage
.get_memory_pr(&id)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
// 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,
"id": current_pr.id,
"kind": current_pr.kind.as_str(),
"title": current_pr.title,
"why": current_pr.signals,
"explanation": "These are the risk signals that opened this Memory PR.",
})));
}
if current_pr.status != vestige_core::MemoryPrStatus::Pending {
return Err(StatusCode::CONFLICT);
}
let pending_action = current_pr
.diff
.get("pendingAction")
.and_then(|v| v.as_str())
.map(str::to_string);
let mut mutation_applied = false;
let mut mutation_report = serde_json::Value::Null;
// Pending destructive/suppressive PRs are opened before the mutation runs.
// Deciding them is therefore the place where the requested operation is
// either applied (`forget`/`quarantine`) or kept (`promote`/accept actions).
if let Some(pending) = pending_action.as_deref()
&& let Some(subject_id) = current_pr.subject_id.as_deref()
{
match (pending, action) {
("purge" | "delete", vestige_core::MemoryPrAction::Forget) => {
let reason = current_pr
.diff
.get("reason")
.and_then(|v| v.as_str())
.unwrap_or("approved destructive Memory PR");
let report = state
.storage
.purge_node(subject_id, Some(reason))
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if report.deleted {
state.emit(VestigeEvent::MemoryDeleted {
id: subject_id.to_string(),
timestamp: Utc::now(),
});
}
mutation_applied = report.deleted;
mutation_report = serde_json::json!({
"action": pending,
"deleted": report.deleted,
"deletedAt": report.deleted_at.to_rfc3339(),
"edgesPruned": report.edges_pruned,
"insightsRewritten": report.insights_rewritten,
"insightsDeleted": report.insights_deleted,
"childrenOrphaned": report.children_orphaned,
});
if let Some(run_id) = current_pr.run_id.as_deref() {
crate::trace_recorder::record_result(
&state.storage,
Some(&state.event_tx),
run_id,
"memory",
&serde_json::json!({
"action": pending,
"nodeId": subject_id,
"success": report.deleted
}),
);
}
}
(
"purge" | "delete" | "suppress",
vestige_core::MemoryPrAction::Quarantine | vestige_core::MemoryPrAction::Forget,
) => {
let node = state
.storage
.suppress_memory(subject_id)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let estimated_cascade = state
.storage
.get_connections_for_memory(subject_id)
.map(|edges| edges.len().min(100))
.unwrap_or(0);
let sys =
vestige_core::neuroscience::active_forgetting::ActiveForgettingSystem::new();
let reversible_until = node
.suppressed_at
.map(|t| sys.reversible_until(t))
.unwrap_or_else(Utc::now);
state.emit(VestigeEvent::MemorySuppressed {
id: node.id.clone(),
suppression_count: node.suppression_count,
estimated_cascade,
reversible_until,
timestamp: Utc::now(),
});
mutation_applied = true;
mutation_report = serde_json::json!({
"action": "suppress",
"suppressionCount": node.suppression_count,
"reversibleUntil": reversible_until.to_rfc3339(),
});
if let Some(run_id) = current_pr.run_id.as_deref() {
crate::trace_recorder::record_result(
&state.storage,
Some(&state.event_tx),
run_id,
"suppress",
&serde_json::json!({
"receipt": {
"suppressed": [
{
"id": subject_id,
"reason": "low_trust"
}
]
}
}),
);
}
}
_ => {}
}
}
let decided = state
.storage
.decide_memory_pr(&id, action)
@ -2230,6 +2426,7 @@ pub async fn act_on_memory_pr(
// retrieval. Forget/Quarantine intentionally keep it suppressed.
let mut released = false;
if action.releases_memory()
&& pending_action.is_none()
&& let Some(subject_id) = decided.subject_id.as_deref()
{
// Use the UNCONDITIONAL quarantine release, not reverse_suppression:
@ -2272,6 +2469,13 @@ pub async fn act_on_memory_pr(
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));
obj.insert(
"pendingMutationApplied".to_string(),
serde_json::json!(mutation_applied),
);
if !mutation_report.is_null() {
obj.insert("mutationReport".to_string(), mutation_report);
}
}
Ok(Json(out))
}
@ -2315,7 +2519,10 @@ pub async fn set_review_mode(
// 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())?;
write_atomic(
&path,
&serde_json::to_vec_pretty(&payload).unwrap_or_default(),
)?;
Ok(Json(serde_json::json!({ "mode": mode.as_str() })))
}
@ -2424,6 +2631,349 @@ mod tests {
node.id
}
fn pending_mutation_pr(id: &str, pr_id: &str, pending_action: &str) -> vestige_core::MemoryPr {
vestige_core::MemoryPr {
id: pr_id.to_string(),
kind: vestige_core::MemoryPrKind::NodeDecayed,
status: vestige_core::MemoryPrStatus::Pending,
title: format!("Pending {pending_action}"),
diff: serde_json::json!({
"pendingAction": pending_action,
"reason": format!("test approved {pending_action}"),
"node": {
"id": id,
"deleted": false
}
}),
signals: vec![vestige_core::RiskSignal {
code: "forgets_memory".to_string(),
detail: "Forgets / suppresses an existing memory.".to_string(),
}],
subject_id: Some(id.to_string()),
run_id: Some(format!("run_{pr_id}")),
created_at: Utc::now().to_rfc3339(),
decided_at: None,
decision: None,
}
}
#[tokio::test]
async fn dashboard_delete_is_pre_gated_before_mutation() {
let (_dir, storage) = seed_storage();
let id = ingest(&storage, "dashboard delete target");
let state = AppState::new(storage.clone(), None);
let Json(out) = delete_memory(State(state), Path(id.clone()))
.await
.expect("dashboard delete should open review");
assert_eq!(out["pendingReview"], serde_json::json!(true));
assert_eq!(out["action"], serde_json::json!("delete_pending_review"));
assert!(
storage.get_node(&id).unwrap().is_some(),
"dashboard delete must not mutate before review in risk-gated mode"
);
let prs = storage
.list_memory_prs(Some(vestige_core::MemoryPrStatus::Pending), 10)
.unwrap();
assert_eq!(prs.len(), 1);
assert_eq!(prs[0].diff["pendingAction"], serde_json::json!("delete"));
}
#[tokio::test]
async fn dashboard_delete_fast_mode_preserves_direct_mutation() {
let (_dir, storage) = seed_storage();
let id = ingest(&storage, "dashboard fast delete target");
let state = AppState::new(storage.clone(), None);
write_atomic(&review_mode_path(&state), br#"{"mode":"fast"}"#).unwrap();
let Json(out) = delete_memory(State(state), Path(id.clone()))
.await
.expect("fast mode delete should execute directly");
assert_eq!(out["deleted"], serde_json::json!(true));
assert!(storage.get_node(&id).unwrap().is_none());
assert_eq!(storage.count_pending_memory_prs().unwrap(), 0);
}
#[tokio::test]
async fn dashboard_suppress_is_pre_gated_before_mutation() {
let (_dir, storage) = seed_storage();
let id = ingest(&storage, "dashboard suppress target");
let state = AppState::new(storage.clone(), None);
let Json(out) = suppress_memory(State(state), Path(id.clone()), None)
.await
.expect("dashboard suppress should open review");
assert_eq!(out["pendingReview"], serde_json::json!(true));
assert_eq!(out["action"], serde_json::json!("suppress_pending_review"));
let node = storage.get_node(&id).unwrap().unwrap();
assert_eq!(
node.suppression_count, 0,
"dashboard suppress must not change retrieval influence before review"
);
let prs = storage
.list_memory_prs(Some(vestige_core::MemoryPrStatus::Pending), 10)
.unwrap();
assert_eq!(prs.len(), 1);
assert_eq!(prs[0].diff["pendingAction"], serde_json::json!("suppress"));
}
#[tokio::test]
async fn dashboard_suppress_fast_mode_preserves_direct_mutation() {
let (_dir, storage) = seed_storage();
let id = ingest(&storage, "dashboard fast suppress target");
let state = AppState::new(storage.clone(), None);
write_atomic(&review_mode_path(&state), br#"{"mode":"fast"}"#).unwrap();
let Json(out) = suppress_memory(State(state), Path(id.clone()), None)
.await
.expect("fast mode suppress should execute directly");
assert_eq!(out["suppressed"], serde_json::json!(true));
assert_eq!(storage.get_node(&id).unwrap().unwrap().suppression_count, 1);
assert_eq!(storage.count_pending_memory_prs().unwrap(), 0);
}
#[tokio::test]
async fn list_traces_run_filter_returns_only_requested_run() {
let (_dir, storage) = seed_storage();
storage
.append_trace_event(&vestige_core::MemoryTraceEvent::McpCall {
run_id: "run_a".to_string(),
tool: "search".to_string(),
args_hash: "ha".to_string(),
at: 100,
})
.unwrap();
storage
.append_trace_event(&vestige_core::MemoryTraceEvent::McpCall {
run_id: "run_b".to_string(),
tool: "deep_reference".to_string(),
args_hash: "hb".to_string(),
at: 200,
})
.unwrap();
let state = AppState::new(storage, None);
let Json(out) = list_traces(
State(state),
Query(TraceListParams {
limit: Some(100),
run: Some("run_b".to_string()),
}),
)
.await
.expect("trace list should load");
assert_eq!(out["total"], serde_json::json!(1));
assert_eq!(out["runs"][0]["runId"], serde_json::json!("run_b"));
}
#[tokio::test]
async fn pending_purge_pr_forget_applies_delete() {
let (_dir, storage) = seed_storage();
let id = ingest(&storage, "pending purge target");
let pr = vestige_core::MemoryPr {
id: "pr_pending_purge".to_string(),
kind: vestige_core::MemoryPrKind::NodeDecayed,
status: vestige_core::MemoryPrStatus::Pending,
title: "Pending purge".to_string(),
diff: serde_json::json!({
"pendingAction": "purge",
"reason": "test approved purge",
"node": {
"id": id,
"deleted": false
}
}),
signals: vec![vestige_core::RiskSignal {
code: "forgets_memory".to_string(),
detail: "Forgets / suppresses an existing memory.".to_string(),
}],
subject_id: Some(id.clone()),
run_id: Some("run_pending_purge".to_string()),
created_at: Utc::now().to_rfc3339(),
decided_at: None,
decision: None,
};
storage.save_memory_pr(&pr).unwrap();
let state = AppState::new(storage.clone(), None);
let Json(out) = act_on_memory_pr(
State(state),
Path(("pr_pending_purge".to_string(), "forget".to_string())),
)
.await
.expect("decision should succeed");
assert_eq!(out["pendingMutationApplied"], serde_json::json!(true));
assert_eq!(out["mutationReport"]["deleted"], serde_json::json!(true));
assert!(
storage.get_node(&id).unwrap().is_none(),
"forget on pending purge PR must perform the irreversible purge"
);
}
#[tokio::test]
async fn pending_purge_pr_promote_keeps_memory() {
let (_dir, storage) = seed_storage();
let id = ingest(&storage, "pending purge keep target");
let pr = vestige_core::MemoryPr {
id: "pr_keep_purge".to_string(),
kind: vestige_core::MemoryPrKind::NodeDecayed,
status: vestige_core::MemoryPrStatus::Pending,
title: "Pending purge".to_string(),
diff: serde_json::json!({
"pendingAction": "purge",
"node": {
"id": id,
"deleted": false
}
}),
signals: vec![vestige_core::RiskSignal {
code: "forgets_memory".to_string(),
detail: "Forgets / suppresses an existing memory.".to_string(),
}],
subject_id: Some(id.clone()),
run_id: Some("run_keep_purge".to_string()),
created_at: Utc::now().to_rfc3339(),
decided_at: None,
decision: None,
};
storage.save_memory_pr(&pr).unwrap();
let state = AppState::new(storage.clone(), None);
let Json(out) = act_on_memory_pr(
State(state),
Path(("pr_keep_purge".to_string(), "promote".to_string())),
)
.await
.expect("decision should succeed");
assert_eq!(out["pendingMutationApplied"], serde_json::json!(false));
assert!(
storage.get_node(&id).unwrap().is_some(),
"promote on pending purge PR must keep the memory"
);
}
#[tokio::test]
async fn decided_pending_purge_pr_cannot_be_replayed() {
let (_dir, storage) = seed_storage();
let id = ingest(&storage, "pending purge replay target");
storage
.save_memory_pr(&pending_mutation_pr(&id, "pr_replay_purge", "purge"))
.unwrap();
let state = AppState::new(storage.clone(), None);
let Json(first) = act_on_memory_pr(
State(state.clone()),
Path(("pr_replay_purge".to_string(), "promote".to_string())),
)
.await
.expect("first decision should succeed");
assert_eq!(first["status"], serde_json::json!("promoted"));
let err = match act_on_memory_pr(
State(state),
Path(("pr_replay_purge".to_string(), "forget".to_string())),
)
.await
{
Ok(_) => panic!("decided PR must not accept a second destructive decision"),
Err(status) => status,
};
assert_eq!(err, StatusCode::CONFLICT);
assert!(
storage.get_node(&id).unwrap().is_some(),
"replaying forget after promote must not delete the memory"
);
}
#[tokio::test]
async fn decided_pending_suppress_pr_cannot_be_replayed_or_released() {
let (_dir, storage) = seed_storage();
let id = ingest(&storage, "pending suppress replay target");
storage
.save_memory_pr(&pending_mutation_pr(&id, "pr_replay_suppress", "suppress"))
.unwrap();
let state = AppState::new(storage.clone(), None);
let Json(first) = act_on_memory_pr(
State(state.clone()),
Path(("pr_replay_suppress".to_string(), "forget".to_string())),
)
.await
.expect("first decision should apply suppression");
assert_eq!(first["status"], serde_json::json!("forgotten"));
assert_eq!(storage.get_node(&id).unwrap().unwrap().suppression_count, 1);
let err = match act_on_memory_pr(
State(state),
Path(("pr_replay_suppress".to_string(), "promote".to_string())),
)
.await
{
Ok(_) => panic!("decided PR must not accept a second release decision"),
Err(status) => status,
};
assert_eq!(err, StatusCode::CONFLICT);
assert_eq!(
storage.get_node(&id).unwrap().unwrap().suppression_count,
1,
"replaying promote after forget must not release the approved suppression"
);
}
#[tokio::test]
async fn pending_suppress_pr_forget_applies_suppression() {
let (_dir, storage) = seed_storage();
let id = ingest(&storage, "pending suppress target");
let pr = vestige_core::MemoryPr {
id: "pr_pending_suppress".to_string(),
kind: vestige_core::MemoryPrKind::NodeDecayed,
status: vestige_core::MemoryPrStatus::Pending,
title: "Pending suppress".to_string(),
diff: serde_json::json!({
"pendingAction": "suppress",
"reason": "test approved suppress",
"node": {
"id": id,
"deleted": false
}
}),
signals: vec![vestige_core::RiskSignal {
code: "forgets_memory".to_string(),
detail: "Forgets / suppresses an existing memory.".to_string(),
}],
subject_id: Some(id.clone()),
run_id: Some("run_pending_suppress".to_string()),
created_at: Utc::now().to_rfc3339(),
decided_at: None,
decision: None,
};
storage.save_memory_pr(&pr).unwrap();
let state = AppState::new(storage.clone(), None);
let Json(out) = act_on_memory_pr(
State(state),
Path(("pr_pending_suppress".to_string(), "forget".to_string())),
)
.await
.expect("decision should succeed");
assert_eq!(out["pendingMutationApplied"], serde_json::json!(true));
let node = storage.get_node(&id).unwrap().unwrap();
assert_eq!(
node.suppression_count, 1,
"forget on pending suppress PR must apply suppression"
);
}
#[test]
fn default_center_id_recent_returns_newest_node() {
let (_dir, storage) = seed_storage();

View file

@ -12,6 +12,11 @@ pub mod static_files;
pub mod websocket;
use axum::Router;
use axum::body::Body;
use axum::extract::State;
use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, header};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::{delete, get, post};
use std::net::SocketAddr;
use std::sync::Arc;
@ -25,6 +30,8 @@ use crate::cognitive::CognitiveEngine;
use state::AppState;
use vestige_core::Storage;
const DASHBOARD_TOKEN_HEADER: &str = "x-vestige-dashboard-token";
/// Build the axum router with all dashboard routes
pub fn build_router(
storage: Arc<Storage>,
@ -47,30 +54,12 @@ pub fn build_router_with_event_tx(
}
fn build_router_inner(state: AppState, port: u16) -> (Router, AppState) {
#[allow(unused_mut)]
let mut origins = vec![
format!("http://127.0.0.1:{}", port)
.parse::<axum::http::HeaderValue>()
.expect("valid origin"),
format!("http://localhost:{}", port)
.parse::<axum::http::HeaderValue>()
.expect("valid origin"),
];
// SvelteKit dev server — only in debug builds
#[cfg(debug_assertions)]
{
origins.push(
"http://localhost:5173"
.parse::<axum::http::HeaderValue>()
.expect("valid origin"),
);
origins.push(
"http://127.0.0.1:5173"
.parse::<axum::http::HeaderValue>()
.expect("valid origin"),
);
}
let origin_strings = dashboard_allowed_origins(port);
let origins: Vec<HeaderValue> = origin_strings
.iter()
.map(|origin| origin.parse::<HeaderValue>().expect("valid origin"))
.collect();
let state = state.with_dashboard_allowed_origins(origin_strings);
let cors = CorsLayer::new()
.allow_origin(AllowOrigin::list(origins))
@ -83,6 +72,7 @@ fn build_router_inner(state: AppState, port: u16) -> (Router, AppState) {
.allow_headers([
axum::http::header::CONTENT_TYPE,
axum::http::header::AUTHORIZATION,
axum::http::HeaderName::from_static(DASHBOARD_TOKEN_HEADER),
]);
// Security: restrict WebSocket connections to localhost only (prevents cross-site WS hijacking)
@ -212,6 +202,10 @@ fn build_router_inner(state: AppState, port: u16) -> (Router, AppState) {
.layer(
ServiceBuilder::new()
.concurrency_limit(50)
.layer(middleware::from_fn_with_state(
state.clone(),
require_dashboard_auth,
))
.layer(cors)
.layer(csp)
.layer(x_frame_options)
@ -224,6 +218,92 @@ fn build_router_inner(state: AppState, port: u16) -> (Router, AppState) {
(router, state)
}
fn dashboard_allowed_origins(port: u16) -> Vec<String> {
#[allow(unused_mut)]
let mut origins = vec![
format!("http://127.0.0.1:{}", port),
format!("http://localhost:{}", port),
];
// SvelteKit dev server — only in debug builds.
#[cfg(debug_assertions)]
{
origins.push("http://localhost:5173".to_string());
origins.push("http://127.0.0.1:5173".to_string());
}
origins
}
async fn require_dashboard_auth(
State(state): State<AppState>,
request: Request<Body>,
next: Next,
) -> Response {
let path = request.uri().path();
if !path.starts_with("/api/") || request.method() == Method::OPTIONS {
return next.run(request).await;
}
let headers = request.headers();
if let Err((status, message)) = validate_dashboard_origin(headers, &state) {
return (status, message).into_response();
}
if let Err((status, message)) = validate_dashboard_token(headers, &state) {
return (status, message).into_response();
}
next.run(request).await
}
fn validate_dashboard_origin(
headers: &HeaderMap,
state: &AppState,
) -> Result<(), (StatusCode, &'static str)> {
if let Some(fetch_site) = headers
.get("sec-fetch-site")
.and_then(|value| value.to_str().ok())
&& fetch_site == "cross-site"
{
return Err((
StatusCode::FORBIDDEN,
"Cross-site dashboard request rejected",
));
}
let Some(origin) = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok()) else {
return Ok(());
};
if state.is_allowed_dashboard_origin(origin) {
Ok(())
} else {
Err((StatusCode::FORBIDDEN, "Dashboard origin not allowed"))
}
}
fn validate_dashboard_token(
headers: &HeaderMap,
state: &AppState,
) -> Result<(), (StatusCode, &'static str)> {
let token = headers
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "))
.or_else(|| {
headers
.get(DASHBOARD_TOKEN_HEADER)
.and_then(|value| value.to_str().ok())
})
.ok_or((StatusCode::UNAUTHORIZED, "Missing dashboard auth token"))?;
if state.is_valid_dashboard_token(token) {
Ok(())
} else {
Err((StatusCode::FORBIDDEN, "Invalid dashboard auth token"))
}
}
/// Start the dashboard HTTP server (blocking — use in CLI mode)
pub async fn start_dashboard(
storage: Arc<Storage>,
@ -237,7 +317,11 @@ pub async fn start_dashboard(
info!("Dashboard starting at http://127.0.0.1:{}", port);
if open_browser {
let url = format!("http://127.0.0.1:{}", port);
let url = format!(
"http://127.0.0.1:{}/dashboard#vestige_token={}",
port,
_state.dashboard_token_fragment_value()
);
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
let _ = open::that(&url);

View file

@ -3,10 +3,12 @@
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::{Mutex, broadcast};
use tracing::warn;
use vestige_core::Storage;
use super::events::VestigeEvent;
use crate::cognitive::CognitiveEngine;
use subtle::ConstantTimeEq;
/// Broadcast channel capacity — how many events can buffer before old ones drop.
const EVENT_CHANNEL_CAPACITY: usize = 1024;
@ -18,6 +20,8 @@ pub struct AppState {
pub cognitive: Option<Arc<Mutex<CognitiveEngine>>>,
pub event_tx: broadcast::Sender<VestigeEvent>,
pub start_time: Instant,
dashboard_token: Arc<str>,
dashboard_allowed_origins: Arc<Vec<String>>,
}
impl AppState {
@ -29,6 +33,8 @@ impl AppState {
cognitive,
event_tx,
start_time: Instant::now(),
dashboard_token: load_dashboard_token().into(),
dashboard_allowed_origins: Arc::new(Vec::new()),
}
}
@ -48,12 +54,68 @@ impl AppState {
cognitive,
event_tx,
start_time: Instant::now(),
dashboard_token: load_dashboard_token().into(),
dashboard_allowed_origins: Arc::new(Vec::new()),
}
}
/// Attach the exact origins allowed to drive the dashboard API and WS.
pub fn with_dashboard_allowed_origins(mut self, origins: Vec<String>) -> Self {
self.dashboard_allowed_origins = Arc::new(origins);
self
}
/// Return true when a dashboard token matches the shared Vestige auth token.
pub fn is_valid_dashboard_token(&self, token: &str) -> bool {
let expected = self.dashboard_token.as_bytes();
let candidate = token.as_bytes();
candidate.len() == expected.len() && candidate.ct_eq(expected).unwrap_u8() == 1
}
/// Return true when an Origin header exactly matches the configured dashboard origins.
pub fn is_allowed_dashboard_origin(&self, origin: &str) -> bool {
self.dashboard_allowed_origins
.iter()
.any(|allowed| allowed == origin)
}
/// Percent-encode the dashboard token for use inside a URL fragment.
pub fn dashboard_token_fragment_value(&self) -> String {
percent_encode_fragment_value(self.dashboard_token.as_ref())
}
/// Emit an event to all connected clients.
pub fn emit(&self, event: VestigeEvent) {
// Ignore send errors (no receivers connected)
let _ = self.event_tx.send(event);
}
}
fn load_dashboard_token() -> String {
match crate::protocol::auth::get_or_create_auth_token() {
Ok(token) => token,
Err(err) => {
warn!(
"Could not load persisted auth token for dashboard; using process-local token: {}",
err
);
uuid::Uuid::new_v4().to_string()
}
}
}
fn percent_encode_fragment_value(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
encoded.push(byte as char);
}
_ => {
encoded.push('%');
encoded.push_str(&format!("{:02X}", byte));
}
}
}
encoded
}

View file

@ -3,35 +3,46 @@
//! Clients connect to `/ws` and receive all VestigeEvents as JSON.
//! Also sends heartbeats every 5 seconds with system stats.
use axum::extract::State;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::IntoResponse;
use chrono::Utc;
use futures_util::{SinkExt, StreamExt};
use serde::Deserialize;
use tokio::sync::broadcast;
use tracing::{debug, warn};
use super::events::VestigeEvent;
use super::state::AppState;
#[derive(Debug, Deserialize)]
pub struct WsAuthQuery {
token: Option<String>,
}
/// WebSocket upgrade handler — GET /ws
/// Validates Origin header to prevent cross-site WebSocket hijacking.
/// Validates token + Origin header to prevent cross-site WebSocket hijacking.
pub async fn ws_handler(
headers: HeaderMap,
Query(query): Query<WsAuthQuery>,
ws: WebSocketUpgrade,
State(state): State<AppState>,
) -> impl IntoResponse {
let Some(token) = query.token.as_deref() else {
warn!("Rejected WebSocket connection without dashboard token");
return StatusCode::UNAUTHORIZED.into_response();
};
if !state.is_valid_dashboard_token(token) {
warn!("Rejected WebSocket connection with invalid dashboard token");
return StatusCode::FORBIDDEN.into_response();
}
// Validate Origin header (browsers always send it for WebSocket upgrades).
// Non-browser clients (curl, wscat) won't have Origin — allowed since localhost-only.
// Non-browser clients (curl, wscat) won't have Origin; they still need the token.
match headers.get("origin").and_then(|v| v.to_str().ok()) {
Some(origin) => {
let allowed =
origin.starts_with("http://127.0.0.1:") || origin.starts_with("http://localhost:");
#[cfg(debug_assertions)]
let allowed =
allowed || origin == "http://localhost:5173" || origin == "http://127.0.0.1:5173";
if !allowed {
if !state.is_allowed_dashboard_origin(origin) {
warn!("Rejected WebSocket connection from origin: {}", origin);
return StatusCode::FORBIDDEN.into_response();
}

File diff suppressed because it is too large Load diff

View file

@ -97,7 +97,7 @@ pub fn gate_writes(
mode: vestige_core::ReviewMode,
) -> Vec<serde_json::Value> {
use vestige_core::{
classify_write, MemoryPr, MemoryPrKind, MemoryPrStatus, RiskClass, WriteContext,
MemoryPr, MemoryPrKind, MemoryPrStatus, RiskClass, WriteContext, classify_write,
};
if !is_write_tool(tool) {
@ -139,7 +139,10 @@ pub fn gate_writes(
let ctx = WriteContext {
source: Some(WriteSource::Agent),
node_type: node.as_ref().map(|n| n.node_type.clone()).unwrap_or_default(),
node_type: node
.as_ref()
.map(|n| n.node_type.clone())
.unwrap_or_default(),
content: node.as_ref().map(|n| n.content.clone()).unwrap_or_default(),
tags: node.as_ref().map(|n| n.tags.clone()).unwrap_or_default(),
contradicts_trust,
@ -176,9 +179,9 @@ pub fn gate_writes(
// secret, and the PR row is read by the dashboard and may be exported).
// Store a short, redacted preview + a content hash instead. The preview
// is dropped entirely when the write was gated for a sensitive topic.
let sensitive = signals.iter().any(|s| {
s.code == "sensitive_topic" || s.code == "sensitive_node_type"
});
let sensitive = signals
.iter()
.any(|s| s.code == "sensitive_topic" || s.code == "sensitive_node_type");
let raw_content = node.as_ref().map(|n| n.content.as_str()).unwrap_or("");
let preview = content_preview(raw_content, sensitive);
let content_hash = hash_content(raw_content);
@ -238,6 +241,232 @@ pub fn gate_writes(
opened
}
struct PendingMemoryMutation {
action: String,
id: String,
reason: Option<String>,
}
/// Pre-gate memory mutations that would otherwise be irreversible or directly
/// inhibitory before the reviewer sees them.
///
/// Normal risky writes are still handled post-commit by [`gate_writes`]. Purge,
/// delete, and suppress are different: executing the tool first either removes
/// the row or mutates retrieval influence before review. Under Risk-Gated and
/// Paranoid modes this function opens a pending Memory PR and returns a tool
/// response without performing the mutation. Fast mode keeps the historical
/// direct-execution behavior.
pub fn gate_pending_memory_mutation(
storage: &Arc<Storage>,
event_tx: Option<&broadcast::Sender<VestigeEvent>>,
run_id: &str,
tool: &str,
args: &Option<serde_json::Value>,
mode: vestige_core::ReviewMode,
) -> Result<Option<serde_json::Value>, String> {
use vestige_core::{
MemoryPr, MemoryPrKind, MemoryPrStatus, RiskClass, WriteContext, classify_write,
};
if matches!(mode, vestige_core::ReviewMode::Fast) {
return Ok(None);
}
let Some(pending) = pending_memory_mutation(tool, args) else {
return Ok(None);
};
let node = match storage.get_node(&pending.id) {
Ok(Some(node)) => node,
Ok(None) => return Ok(None),
Err(e) => return Err(format!("failed to inspect memory before review gate: {e}")),
};
let ctx = WriteContext {
source: Some(WriteSource::Agent),
node_type: node.node_type.clone(),
content: node.content.clone(),
tags: node.tags.clone(),
forgets: true,
..Default::default()
};
let (class, signals) = classify_write(&ctx, mode);
if class != RiskClass::Review {
return Ok(None);
}
if let Some(existing) = find_pending_mutation_pr(storage, &pending.id, &pending.action) {
if let Some(tx) = event_tx {
let _ = tx.send(VestigeEvent::MemoryPrOpened {
id: existing.id.clone(),
kind: existing.kind.as_str().to_string(),
title: existing.title.clone(),
signal_count: existing.signals.len(),
run_id: existing.run_id.clone().or_else(|| Some(run_id.to_string())),
timestamp: Utc::now(),
});
}
return Ok(Some(pending_review_response(&pending, &existing)));
}
let sensitive = signals
.iter()
.any(|s| s.code == "sensitive_topic" || s.code == "sensitive_node_type");
let preview = content_preview(&node.content, sensitive);
let content_hash = hash_content(&node.content);
let kind = MemoryPrKind::NodeDecayed;
let title = format!("{}: {}", pr_kind_phrase(kind), preview);
let pr = MemoryPr {
id: format!("pr_{}", uuid::Uuid::new_v4().simple()),
kind,
status: MemoryPrStatus::Pending,
title: title.clone(),
diff: serde_json::json!({
"decision": pending.action,
"pendingAction": pending.action,
"requiresApproval": true,
"reason": pending.reason,
"node": {
"id": pending.id,
"nodeType": node.node_type,
"contentPreview": preview,
"contentHash": content_hash,
"tags": node.tags,
"deleted": false,
},
}),
signals: signals.clone(),
subject_id: Some(pending.id.clone()),
run_id: Some(run_id.to_string()),
created_at: Utc::now().to_rfc3339(),
decided_at: None,
decision: None,
};
if let Err(e) = storage.save_memory_pr(&pr) {
tracing::warn!("pending destructive Memory PR save failed: {e}");
return Err(format!(
"review gate failed closed: could not open Memory PR for pending mutation: {e}"
));
}
if let Some(tx) = event_tx {
let _ = tx.send(VestigeEvent::MemoryPrOpened {
id: pr.id.clone(),
kind: kind.as_str().to_string(),
title: title.clone(),
signal_count: signals.len(),
run_id: Some(run_id.to_string()),
timestamp: Utc::now(),
});
}
Ok(Some(pending_review_response(&pending, &pr)))
}
fn find_pending_mutation_pr(
storage: &Arc<Storage>,
subject_id: &str,
pending_action: &str,
) -> Option<vestige_core::MemoryPr> {
storage
.list_memory_prs(Some(vestige_core::MemoryPrStatus::Pending), 500)
.ok()?
.into_iter()
.find(|pr| {
pr.subject_id.as_deref() == Some(subject_id)
&& pr.diff.get("pendingAction").and_then(|v| v.as_str()) == Some(pending_action)
})
}
fn pending_review_response(
pending: &PendingMemoryMutation,
pr: &vestige_core::MemoryPr,
) -> serde_json::Value {
let opened = serde_json::json!({
"id": pr.id.clone(),
"kind": pr.kind.as_str(),
"title": pr.title.clone(),
"signals": pr.signals.clone(),
"subjectId": pending.id.clone(),
});
serde_json::json!({
"action": format!("{}_pending_review", pending.action),
"success": false,
"pendingReview": true,
"nodeId": pending.id,
"message": "Mutation was not executed. Vestige opened a Memory PR and is waiting for review.",
"memoryPrsOpened": [opened],
"memoryPrNotice": "Vestige opened a Memory PR before applying this destructive or suppressive memory mutation. Approve with `forget`; keep the memory with `promote`; hold it suppressed with `quarantine`.",
})
}
fn pending_memory_mutation(
tool: &str,
args: &Option<serde_json::Value>,
) -> Option<PendingMemoryMutation> {
let args = args.as_ref()?;
match tool {
"memory" => {
let action = args.get("action")?.as_str()?.to_ascii_lowercase();
if !matches!(action.as_str(), "purge" | "delete") {
return None;
}
if !args
.get("confirm")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
return None;
}
Some(PendingMemoryMutation {
action,
id: args.get("id")?.as_str()?.to_string(),
reason: args
.get("reason")
.and_then(|v| v.as_str())
.map(str::to_string),
})
}
"delete_knowledge" => {
if !args
.get("confirm")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
return None;
}
Some(PendingMemoryMutation {
action: "delete".to_string(),
id: args.get("id")?.as_str()?.to_string(),
reason: args
.get("reason")
.and_then(|v| v.as_str())
.map(str::to_string),
})
}
"suppress" => {
if args
.get("reverse")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
return None;
}
Some(PendingMemoryMutation {
action: "suppress".to_string(),
id: args.get("id")?.as_str()?.to_string(),
reason: args
.get("reason")
.or_else(|| args.get("note"))
.and_then(|v| v.as_str())
.map(str::to_string),
})
}
_ => None,
}
}
/// Whether a write decision permanently removes / forgets memory (so the live
/// row may already be gone when the gate runs).
fn is_destructive_decision(label: &str) -> bool {
@ -719,7 +948,10 @@ fn extract_veto(result: &Value) -> Option<(String, Vec<String>, f64)> {
.collect()
})
.unwrap_or_default();
let confidence = veto.get("confidence").and_then(|v| v.as_f64()).unwrap_or(0.0);
let confidence = veto
.get("confidence")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
Some((claim, evidence_ids, confidence))
}
@ -875,7 +1107,10 @@ mod tests {
#[test]
fn extract_writes_single_and_batch() {
let single = serde_json::json!({ "decision": "create", "nodeId": "n1" });
assert_eq!(extract_writes(&single), vec![("n1".into(), "create".into())]);
assert_eq!(
extract_writes(&single),
vec![("n1".into(), "create".into())]
);
let batch = serde_json::json!({
"results": [ { "decision": "update", "id": "n2" } ]
});
@ -886,9 +1121,15 @@ mod tests {
fn extract_writes_recognizes_action_shape_b2() {
// B2: memory promote/demote return `action` + `nodeId`, not `decision`.
let promoted = serde_json::json!({ "action": "promoted", "nodeId": "m1" });
assert_eq!(extract_writes(&promoted), vec![("m1".into(), "promoted".into())]);
assert_eq!(
extract_writes(&promoted),
vec![("m1".into(), "promoted".into())]
);
let demoted = serde_json::json!({ "action": "demoted", "nodeId": "m2" });
assert_eq!(extract_writes(&demoted), vec![("m2".into(), "demoted".into())]);
assert_eq!(
extract_writes(&demoted),
vec![("m2".into(), "demoted".into())]
);
// codebase remember_decision returns action + nodeId.
let decision = serde_json::json!({ "action": "remember_decision", "nodeId": "c1" });
assert_eq!(
@ -908,7 +1149,14 @@ mod tests {
#[test]
fn destructive_decision_classification_c2() {
for d in ["purge", "delete", "forget", "purged", "deleted", "forgotten"] {
for d in [
"purge",
"delete",
"forget",
"purged",
"deleted",
"forgotten",
] {
assert!(is_destructive_decision(d), "{d} is destructive");
}
for d in ["create", "update", "promote", "reinforce"] {
@ -987,8 +1235,14 @@ mod tests {
vestige_core::ReviewMode::RiskGated,
);
assert_eq!(opened.len(), 1, "destructive write must open a PR even with the node gone");
let pr = s.list_memory_prs(Some(vestige_core::MemoryPrStatus::Pending), 10).unwrap();
assert_eq!(
opened.len(),
1,
"destructive write must open a PR even with the node gone"
);
let pr = s
.list_memory_prs(Some(vestige_core::MemoryPrStatus::Pending), 10)
.unwrap();
assert_eq!(pr.len(), 1);
assert_eq!(pr[0].subject_id.as_deref(), Some(node.id.as_str()));
// The diff marks the node as deleted and carries no resurrected content.
@ -1035,6 +1289,157 @@ mod tests {
assert!(pr.diff["node"]["contentHash"].as_str().is_some());
}
#[test]
fn pre_gate_blocks_purge_before_deleting_c2() {
let s = store();
let node = s
.ingest(vestige_core::IngestInput {
content: "A memory awaiting destructive review.".to_string(),
node_type: "fact".to_string(),
..Default::default()
})
.unwrap();
let args = Some(serde_json::json!({
"action": "purge",
"id": node.id,
"confirm": true,
"reason": "test purge"
}));
let response = gate_pending_memory_mutation(
&s,
None,
"run_pre_gate",
"memory",
&args,
vestige_core::ReviewMode::RiskGated,
)
.unwrap()
.expect("purge should be pre-gated");
assert_eq!(response["pendingReview"], serde_json::json!(true));
assert!(
s.get_node(&node.id).unwrap().is_some(),
"pre-gating must not delete before review"
);
let pr = s
.list_memory_prs(Some(vestige_core::MemoryPrStatus::Pending), 10)
.unwrap();
assert_eq!(pr.len(), 1);
assert_eq!(pr[0].diff["pendingAction"], serde_json::json!("purge"));
assert_eq!(pr[0].diff["node"]["deleted"], serde_json::json!(false));
}
#[test]
fn pre_gate_leaves_fast_mode_direct() {
let s = store();
let node = s
.ingest(vestige_core::IngestInput {
content: "Fast mode purge target.".to_string(),
node_type: "fact".to_string(),
..Default::default()
})
.unwrap();
let args = Some(serde_json::json!({
"action": "purge",
"id": node.id,
"confirm": true
}));
assert!(
gate_pending_memory_mutation(
&s,
None,
"run_fast",
"memory",
&args,
vestige_core::ReviewMode::Fast,
)
.unwrap()
.is_none(),
"Fast mode should preserve direct execution"
);
}
#[test]
fn pre_gate_blocks_direct_suppress_before_mutating() {
let s = store();
let node = s
.ingest(vestige_core::IngestInput {
content: "A memory awaiting suppress review.".to_string(),
node_type: "fact".to_string(),
..Default::default()
})
.unwrap();
let args = Some(serde_json::json!({ "id": node.id, "reason": "test suppress" }));
let response = gate_pending_memory_mutation(
&s,
None,
"run_suppress",
"suppress",
&args,
vestige_core::ReviewMode::RiskGated,
)
.unwrap()
.expect("suppress should be pre-gated");
assert_eq!(response["pendingReview"], serde_json::json!(true));
let kept = s.get_node(&node.id).unwrap().unwrap();
assert_eq!(kept.suppression_count, 0, "pre-gate must not suppress yet");
let pr = s
.list_memory_prs(Some(vestige_core::MemoryPrStatus::Pending), 10)
.unwrap();
assert_eq!(pr[0].diff["pendingAction"], serde_json::json!("suppress"));
}
#[test]
fn pre_gate_reuses_existing_pending_mutation_pr() {
let s = store();
let node = s
.ingest(vestige_core::IngestInput {
content: "Do not open duplicate destructive PRs.".to_string(),
node_type: "fact".to_string(),
..Default::default()
})
.unwrap();
let args = Some(serde_json::json!({
"action": "delete",
"id": node.id,
"confirm": true
}));
let first = gate_pending_memory_mutation(
&s,
None,
"run_repeat_1",
"memory",
&args,
vestige_core::ReviewMode::RiskGated,
)
.unwrap()
.expect("first delete should open PR");
let second = gate_pending_memory_mutation(
&s,
None,
"run_repeat_2",
"memory",
&args,
vestige_core::ReviewMode::RiskGated,
)
.unwrap()
.expect("second delete should reuse PR");
assert_eq!(
first["memoryPrsOpened"][0]["id"], second["memoryPrsOpened"][0]["id"],
"repeated pending mutation returns the existing PR"
);
let prs = s
.list_memory_prs(Some(vestige_core::MemoryPrStatus::Pending), 10)
.unwrap();
assert_eq!(prs.len(), 1, "only one pending PR is stored");
}
#[test]
fn write_tool_set_includes_codebase_b2() {
assert!(is_write_tool("codebase"));