feat(workbench): Vault route — fail-closed epistemic memory evidence (Wave R2)

Replaces VaultRoutePlaceholder with the real route over the R2-B
/vault read substrate (#712).

- TS mirrors VaultSummary/VaultEntry; both removed from NOT_YET_MIRRORED.
  Also fixes a latent ErrorCode drift: adds 'evidence_unavailable' to the
  TS union (present in Python schemas, missing in TS) — the route branches
  on it.
- fail-closed is the design: a missing/unpersisted vault (the 501
  evidence_unavailable signal, or persisted=false / entry_count=0) renders
  the honest absence card as the PRIMARY state, naming the opt-in
  RuntimeConfig.persist_session_state pointer — not a generic error, no
  skeleton theater
- with data: summary strip (entry_count, store_count, reproject_interval,
  max_entries, source_path) + VirtualizedList of entries with
  epistemic_status / epistemic_state pills and versor_digest DigestBadge
- exact-recall doctrine: renders only backend fields; never computes or
  displays a similarity/relevance proxy (runtime recall is exact cga_inner)
- selection publishes the vault_entry subject (inspect-param only, via
  setSubject + setInspectorOpen; EvidenceUrlSync writes ?inspect=vault:N);
  a URL-restored identity-only subject is hydrated once entries load
- Vault row added to routeConformance MOUNT_ROUTES — the fail-closed
  absence asserted as the primary contract
- tests: fail-closed card (not error), summary+entries, no-similarity-score
  doctrine, vault_entry subject publication, j/k spine

Token-only styling (hexScan green).
This commit is contained in:
Shay 2026-06-12 21:25:52 -07:00
parent 19f8d5e3bd
commit ebb95a0541
9 changed files with 484 additions and 15 deletions

View file

@ -15,6 +15,8 @@ import type {
RunDetail,
PackSummary,
PackDetail,
VaultSummary,
VaultEntry,
TurnJournalEntry,
TurnJournalSummary,
MathProposalSummary,
@ -224,3 +226,18 @@ export async function fetchPacks(limit?: number, offset?: number): Promise<PackS
export async function fetchPack(packId: string): Promise<PackDetail> {
return apiFetch<PackDetail>(`/packs/${encodeURIComponent(packId)}`);
}
export async function fetchVaultSummary(): Promise<VaultSummary> {
return apiFetch<VaultSummary>("/vault/summary");
}
export async function fetchVaultEntries(limit?: number, offset?: number): Promise<VaultEntry[]> {
const params = new URLSearchParams();
if (limit !== undefined) params.set("limit", String(limit));
if (offset !== undefined) params.set("offset", String(offset));
const query = params.toString();
const envelope = await apiFetch<ItemsEnvelope<VaultEntry>>(
query ? `/vault/entries?${query}` : "/vault/entries",
);
return envelope.items;
}

View file

@ -18,6 +18,8 @@ import {
fetchRun,
fetchPacks,
fetchPack,
fetchVaultSummary,
fetchVaultEntries,
fetchTraceTurn,
fetchTraceTurns,
fetchMathProposals,
@ -39,6 +41,8 @@ import type {
RunDetail,
PackSummary,
PackDetail,
VaultSummary,
VaultEntry,
TurnJournalEntry,
TurnJournalSummary,
EvalLaneSummary,
@ -295,3 +299,24 @@ export function usePack(packId?: string | null) {
});
}
export function useVaultSummary() {
return useQuery<VaultSummary, WorkbenchApiError>({
queryKey: ["api", "vault", "summary"],
queryFn: () => fetchVaultSummary(),
retry: false,
staleTime: 30_000,
refetchOnWindowFocus: false,
});
}
export function useVaultEntries(enabled: boolean, limit?: number, offset?: number) {
return useQuery<VaultEntry[], WorkbenchApiError>({
queryKey: ["api", "vault", "entries", limit ?? null, offset ?? null],
queryFn: () => fetchVaultEntries(limit, offset),
enabled,
retry: false,
staleTime: 30_000,
refetchOnWindowFocus: false,
});
}

View file

@ -11,7 +11,7 @@ import { ReplayRoute } from "./replay/ReplayRoute";
import { EvalsRoute } from "./evals/EvalsRoute";
import { RunsRoute } from "./runs/RunsRoute";
import { PacksRoute } from "./packs/PacksRoute";
import { VaultRoutePlaceholder } from "../routes/VaultRoutePlaceholder";
import { VaultRoute } from "./vault/VaultRoute";
import { SettingsRoutePlaceholder } from "../routes/SettingsRoutePlaceholder";
export function App() {
@ -28,7 +28,7 @@ export function App() {
<Route path="evals/:laneId?" element={<EvalsRoute />} />
<Route path="runs/:sessionId?" element={<RunsRoute />} />
<Route path="packs/:packId?" element={<PacksRoute />} />
<Route path="vault" element={<VaultRoutePlaceholder />} />
<Route path="vault" element={<VaultRoute />} />
<Route path="audit" element={<AuditRoute />} />
<Route path="settings" element={<SettingsRoutePlaceholder />} />
</Route>

View file

@ -14,6 +14,7 @@ import { EvalsRoute } from "./evals/EvalsRoute";
import { ReplayRoute } from "./replay/ReplayRoute";
import { RunsRoute } from "./runs/RunsRoute";
import { PacksRoute } from "./packs/PacksRoute";
import { VaultRoute } from "./vault/VaultRoute";
/**
* ADR-0162 §6 route conformance executable, not aspirational.
@ -171,6 +172,17 @@ const MOUNT_ROUTES: MountRouteSpec[] = [
emptyStatement: "No packs discovered.",
emptyCommand: "core pack validate <path>",
},
{
name: "Vault",
element: <VaultRoute />,
path: "/vault",
initialEntry: "/vault",
loadingLabel: "Loading vault...",
// Fail-closed: absence of a persisted vault is the primary contract.
emptyStatement:
"No persisted vault. Session memory is held in-process and discarded on exit; persistence is opt-in via RuntimeConfig.persist_session_state.",
emptyCommand: "Set RuntimeConfig.persist_session_state = true",
},
];
describe.each(MOUNT_ROUTES)("route conformance: $name", (spec) => {

View file

@ -0,0 +1,185 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createTestQueryClient } from "../../test/createTestQueryClient";
import type { VaultEntry, VaultSummary } from "../../types/api";
import { EvidenceProvider, useEvidenceSubject } from "../evidenceContext";
import { VaultRoute } from "./VaultRoute";
const summary: VaultSummary = {
source_path: "engine_state/session_state.json",
entry_count: 2,
store_count: 1,
reproject_interval: 64,
max_entries: 4096,
persisted: true,
};
const entries: VaultEntry[] = [
{
entry_index: 0,
epistemic_status: "coherent",
epistemic_state: "verified",
metadata: { concept: "truth" },
versor_digest: "sha256:aaaaaaaaaaaaaaaaaaaa",
},
{
entry_index: 1,
epistemic_status: "speculative",
epistemic_state: "inferred",
metadata: { concept: "beauty" },
versor_digest: "sha256:bbbbbbbbbbbbbbbbbbbb",
},
];
function SubjectProbe() {
const { subject } = useEvidenceSubject();
return (
<span data-testid="subject">
{subject.kind === "vault_entry" ? `vault_entry:${subject.entryIndex}` : subject.kind}
</span>
);
}
function renderRoute() {
return render(
<QueryClientProvider client={createTestQueryClient()}>
<MemoryRouter initialEntries={["/vault"]}>
<EvidenceProvider>
<Routes>
<Route
path="/vault"
element={
<>
<VaultRoute />
<SubjectProbe />
</>
}
/>
</Routes>
</EvidenceProvider>
</MemoryRouter>
</QueryClientProvider>,
);
}
function okBody(data: unknown) {
return { ok: true, generated_at: "now", data };
}
function evidenceUnavailable(message: string) {
return { ok: false, generated_at: "now", error: { code: "evidence_unavailable", message } };
}
function stubVault({ persisted = true }: { persisted?: boolean } = {}) {
vi.stubGlobal(
"fetch",
vi.fn((input: unknown) => {
const path = new URL(String(input)).pathname;
if (path === "/vault/summary") {
return persisted
? Promise.resolve({ json: () => Promise.resolve(okBody(summary)) })
: Promise.resolve({
json: () =>
Promise.resolve(evidenceUnavailable("vault evidence unavailable: not persisted")),
});
}
if (path === "/vault/entries") {
return Promise.resolve({ json: () => Promise.resolve(okBody({ items: entries })) });
}
return Promise.resolve({
json: () => Promise.resolve(evidenceUnavailable(`unexpected ${path}`)),
});
}),
);
}
const offsetDescriptors = {
offsetHeight: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight"),
offsetWidth: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetWidth"),
};
describe("VaultRoute", () => {
beforeEach(() => {
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
configurable: true,
get: () => 560,
});
Object.defineProperty(HTMLElement.prototype, "offsetWidth", {
configurable: true,
get: () => 480,
});
});
afterEach(() => {
if (offsetDescriptors.offsetHeight) {
Object.defineProperty(HTMLElement.prototype, "offsetHeight", offsetDescriptors.offsetHeight);
}
if (offsetDescriptors.offsetWidth) {
Object.defineProperty(HTMLElement.prototype, "offsetWidth", offsetDescriptors.offsetWidth);
}
vi.restoreAllMocks();
localStorage.clear();
});
it("fail-closed: an unpersisted vault renders the honest absence card, not an error", async () => {
stubVault({ persisted: false });
renderRoute();
expect(
await screen.findByText(/No persisted vault\./),
).toBeInTheDocument();
// both the statement and the config-pointer action name the opt-in flag
expect(
screen.getAllByText(/RuntimeConfig\.persist_session_state/).length,
).toBeGreaterThan(0);
// it is NOT the generic error contract
expect(screen.queryByText("What failed")).not.toBeInTheDocument();
});
it("renders the summary strip and entries when persisted", async () => {
stubVault();
renderRoute();
expect(await screen.findByText("entry_count")).toBeInTheDocument();
expect(screen.getByText("reproject_interval")).toBeInTheDocument();
expect(screen.getByText("coherent")).toBeInTheDocument();
expect(screen.getByText("verified")).toBeInTheDocument();
expect(screen.getByText("speculative")).toBeInTheDocument();
});
it("never invents a similarity / relevance score (exact-recall doctrine)", async () => {
stubVault();
renderRoute();
await screen.findByText("entry_count");
expect(screen.queryByText(/similarity|relevance|score/i)).not.toBeInTheDocument();
});
it("selecting an entry publishes the vault_entry subject", async () => {
stubVault();
const user = userEvent.setup();
renderRoute();
await user.click(await screen.findByText("speculative"));
await waitFor(() =>
expect(screen.getByTestId("subject")).toHaveTextContent("vault_entry:1"),
);
});
it("moves entry focus with j/k through the VirtualizedList spine", async () => {
stubVault();
const user = userEvent.setup();
renderRoute();
const list = await screen.findByRole("listbox", { name: "Vault entries" });
list.focus();
expect(screen.getAllByRole("option")[0]).toHaveAttribute("aria-selected", "true");
await user.keyboard("j");
expect(screen.getAllByRole("option")[1]).toHaveAttribute("aria-selected", "true");
});
});

View file

@ -0,0 +1,225 @@
import { useEffect, useMemo, useState } from "react";
import { WorkbenchApiError } from "../../api/client";
import { useVaultEntries, useVaultSummary } from "../../api/queries";
import { DigestBadge } from "../../design/components/DigestBadge/DigestBadge";
import { MetadataTable } from "../../design/components/MetadataTable/MetadataTable";
import { Panel } from "../../design/components/Panel/Panel";
import { SearchInput } from "../../design/components/SearchInput/SearchInput";
import { VirtualizedList } from "../../design/components/VirtualizedList/VirtualizedList";
import { EmptyState } from "../../design/components/states/EmptyState";
import { ErrorState } from "../../design/components/states/ErrorState";
import { LoadingState } from "../../design/components/states/LoadingState";
import type { VaultEntry, VaultSummary } from "../../types/api";
import { useEvidenceSubject } from "../evidenceContext";
// Vault persistence is opt-in; absence is the common, honest state. This is
// the fail-closed primary contract, not an error.
const FAIL_CLOSED_STATEMENT =
"No persisted vault. Session memory is held in-process and discarded on exit; persistence is opt-in via RuntimeConfig.persist_session_state.";
const FAIL_CLOSED_ACTION = "Set RuntimeConfig.persist_session_state = true";
function errorMessage(error: unknown) {
return error instanceof WorkbenchApiError ? error.message : "Vault request failed.";
}
function digestPayload(value: string | null | undefined): string | null {
if (!value) return null;
return value.replace(/^sha256:/, "");
}
function StatusPill({ children }: { children: React.ReactNode }) {
return (
<span className="inline-flex h-6 items-center rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface-inset)] px-2 text-xs text-[var(--color-text-secondary)]">
{children}
</span>
);
}
function VaultRow({
entry,
selected,
focused,
onSelect,
}: {
entry: VaultEntry;
selected: boolean;
focused: boolean;
onSelect: () => void;
}) {
const digest = digestPayload(entry.versor_digest);
return (
<div
role="button"
tabIndex={-1}
aria-current={selected ? "true" : undefined}
onClick={onSelect}
className={`grid w-full grid-cols-[minmax(0,1fr)_auto] items-start gap-3 border-b border-[var(--color-border-subtle)] px-3 py-2 text-left transition-colors hover:bg-[var(--color-surface-inset)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-inset focus-visible:outline-[var(--color-focus-ring)] ${
selected ? "bg-[var(--color-selected-bg)]" : ""
} ${
selected
? "border-l-2 border-l-[var(--color-selected-border)] pl-[10px]"
: focused
? "border-l-2 border-l-[var(--color-focus-ring)] pl-[10px]"
: "border-l-2 border-l-transparent pl-[10px]"
}`}
>
<span className="min-w-0">
<span className="block font-mono text-xs text-[var(--color-text-muted)]">
#{entry.entry_index}
</span>
<span className="mt-1 flex flex-wrap items-center gap-2">
<StatusPill>{entry.epistemic_status}</StatusPill>
<StatusPill>{entry.epistemic_state}</StatusPill>
</span>
</span>
<span className="justify-self-end">
{digest ? (
<DigestBadge digest={digest} truncate={12} />
) : (
<span className="font-mono text-xs text-[var(--color-text-muted)]">no versor</span>
)}
</span>
</div>
);
}
function VaultSummaryStrip({ summary }: { summary: VaultSummary }) {
// Render only fields the backend returns. Recall is exact CGA (cga_inner);
// the UI must never invent an approximate similarity/relevance proxy.
return (
<MetadataTable
rows={[
{ key: "entry_count", value: String(summary.entry_count), mono: true },
{ key: "store_count", value: String(summary.store_count), mono: true },
{ key: "reproject_interval", value: String(summary.reproject_interval), mono: true },
{
key: "max_entries",
value: summary.max_entries === null ? "unbounded" : String(summary.max_entries),
mono: true,
},
{ key: "source_path", value: summary.source_path, mono: true },
]}
/>
);
}
function FailClosed() {
return (
<EmptyState statement={FAIL_CLOSED_STATEMENT} nextAction={FAIL_CLOSED_ACTION} />
);
}
export function VaultRoute() {
const { subject, setSubject, setInspectorOpen } = useEvidenceSubject();
const [search, setSearch] = useState("");
const summaryQuery = useVaultSummary();
const summary = summaryQuery.data;
const hasEntries = !!summary && summary.persisted && summary.entry_count > 0;
const entriesQuery = useVaultEntries(hasEntries);
const selectedIndex =
subject.kind === "vault_entry" ? subject.entryIndex : null;
const entries = entriesQuery.data ?? [];
const filteredEntries = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return entries;
return entries.filter(
(entry) =>
entry.epistemic_status.toLowerCase().includes(q) ||
entry.epistemic_state.toLowerCase().includes(q) ||
String(entry.entry_index).includes(q),
);
}, [search, entries]);
// Hydrate a URL-restored (identity-only) vault_entry subject with its data.
useEffect(() => {
if (subject.kind !== "vault_entry" || subject.data) return;
const match = entries.find((entry) => entry.entry_index === subject.entryIndex);
if (match) {
setSubject({ kind: "vault_entry", entryIndex: match.entry_index, data: match });
}
}, [subject, entries, setSubject]);
function selectEntry(entry: VaultEntry) {
setSubject({ kind: "vault_entry", entryIndex: entry.entry_index, data: entry });
setInspectorOpen(true);
}
if (summaryQuery.isLoading) {
return <LoadingState label="Loading vault..." />;
}
// Fail-closed: no persisted vault is the expected primary state, not an error.
if (summaryQuery.isError) {
if (summaryQuery.error.code === "evidence_unavailable") {
return <FailClosed />;
}
return (
<ErrorState
whatFailed={errorMessage(summaryQuery.error)}
mutationStatus="No vault mutation occurred."
reproducer="curl /vault/summary"
retrySafety="Retry: safe"
/>
);
}
if (!hasEntries) {
return <FailClosed />;
}
return (
<Panel
title="Vault"
toolbar={
<span className="font-mono text-xs text-[var(--color-text-muted)]">
{summary.entry_count} entries
</span>
}
>
<div className="grid min-h-0 gap-3">
<VaultSummaryStrip summary={summary} />
<SearchInput
placeholder="Filter by epistemic status, state, or index"
value={search}
onChange={setSearch}
/>
{entriesQuery.isLoading ? (
<LoadingState label="Loading vault entries..." />
) : entriesQuery.isError ? (
<ErrorState
whatFailed={errorMessage(entriesQuery.error)}
mutationStatus="No vault mutation occurred."
reproducer="curl /vault/entries"
retrySafety="Retry: safe"
/>
) : filteredEntries.length === 0 ? (
<EmptyState
statement="No vault entries match this filter."
nextAction={FAIL_CLOSED_ACTION}
/>
) : (
<VirtualizedList
ariaLabel="Vault entries"
estimateSize={72}
getKey={(entry) => String(entry.entry_index)}
height="calc(100vh - 18rem)"
initialRect={{ width: 480, height: 560 }}
items={filteredEntries}
onActivate={(entry) => selectEntry(entry)}
renderItem={(entry, _index, focused) => (
<VaultRow
entry={entry}
selected={entry.entry_index === selectedIndex}
focused={focused}
onSelect={() => selectEntry(entry)}
/>
)}
/>
)}
</div>
</Panel>
);
}

View file

@ -20,9 +20,6 @@ import { describe, expect, it } from "vitest";
*/
const NOT_YET_MIRRORED = new Set([
// R2-B backend read substrate (#712) — TS mirrors land with each R2 route:
"VaultSummary",
"VaultEntry",
// Wave R3 sealed turn replay backend — TS mirrors land with the frontend
// Replay Moment PR (which also retires the W-026 artifact-keyed
// ReplayComparison/ReplayDivergence pair on both sides):

View file

@ -1,10 +0,0 @@
import { EmptyState } from "../design/components/states/EmptyState";
export function VaultRoutePlaceholder() {
return (
<EmptyState
statement="Vault — no data loaded yet."
nextAction="Pending W-029"
/>
);
}

View file

@ -3,6 +3,7 @@
export type ErrorCode =
| "bad_request"
| "evidence_unavailable"
| "not_found"
| "unsupported"
| "read_error"
@ -263,6 +264,23 @@ export interface ReplayComparison {
divergences: ReplayDivergence[];
}
export interface VaultSummary {
source_path: string;
entry_count: number;
store_count: number;
reproject_interval: number;
max_entries: number | null;
persisted: boolean;
}
export interface VaultEntry {
entry_index: number;
epistemic_status: string;
epistemic_state: string;
metadata: Record<string, unknown>;
versor_digest: string | null;
}
// API envelope types
export interface ApiOk<T> {
ok: true;