feat(confidence-engine): use server investigation persistence

This commit is contained in:
2026-09-08 19:21:05 +01:00
parent 6dd447e56a
commit d6df1d210e
15 changed files with 425 additions and 114 deletions
@@ -0,0 +1,14 @@
import { restartInvestigation } from "@/lib/storage/server-investigation-persistence.js";
import { withAuthenticatedApi } from "@/lib/supabase/api-auth.js";
async function post(_request, { params }) {
try {
const snapshot = await restartInvestigation(params.id);
if (!snapshot) return Response.json({ error: "Investigation not found" }, { status: 404 });
return Response.json({ snapshot });
} catch {
return Response.json({ error: "Investigation persistence request failed" }, { status: 500 });
}
}
export const POST = withAuthenticatedApi(post);
+16 -3
View File
@@ -11,10 +11,21 @@ export default function InvestigationPage({ params }) {
const router = useRouter(); const router = useRouter();
const routeId = typeof params?.id === "string" ? params.id : ""; const routeId = typeof params?.id === "string" ? params.id : "";
const [existing, setExisting] = useState(null); const [existing, setExisting] = useState(null);
const [hydrated, setHydrated] = useState(false);
useEffect(() => { useEffect(() => {
if (!routeId) return; let active = true;
setExisting(loadInvestigation(routeId)); setHydrated(false);
if (!routeId) { setHydrated(true); return; }
(async () => {
try {
const snapshot = await loadInvestigation(routeId);
if (active) setExisting(snapshot);
} finally {
if (active) setHydrated(true);
}
})();
return () => { active = false; };
}, [routeId]); }, [routeId]);
return ( return (
@@ -35,7 +46,9 @@ export default function InvestigationPage({ params }) {
evidence-based structured reconstruction. This is a technical vertical evidence-based structured reconstruction. This is a technical vertical
slice not a production system. slice not a production system.
</p> </p>
{existing ? ( {!hydrated ? (
<p className="text-sm text-gray-500">Loading investigation</p>
) : existing ? (
<ScenarioForm <ScenarioForm
investigationId={routeId} investigationId={routeId}
existingSnapshot={existing} existingSnapshot={existing}
+19 -6
View File
@@ -15,9 +15,20 @@ export default function ReportPage({ params }) {
const generationAttempted = useRef(false); const generationAttempted = useRef(false);
useEffect(() => { useEffect(() => {
setExisting(loadInvestigation(routeId)); let active = true;
setHydrated(true); setHydrated(false);
}, []); (async () => {
try {
const snapshot = await loadInvestigation(routeId);
if (active) setExisting(snapshot);
} catch {
if (active) setGenerationError(true);
} finally {
if (active) setHydrated(true);
}
})();
return () => { active = false; };
}, [routeId]);
// First-generation: create report when none persists (v0.58) // First-generation: create report when none persists (v0.58)
useEffect(() => { useEffect(() => {
@@ -54,7 +65,8 @@ export default function ReportPage({ params }) {
const rev = existing?.investigationRevision ?? 0; const rev = existing?.investigationRevision ?? 0;
const reportData = { understanding: data.understanding, plausibleInterpretations: data.plausibleInterpretations, hasPlausibleInterpretations: true, generatedFromRevision: rev }; const reportData = { understanding: data.understanding, plausibleInterpretations: data.plausibleInterpretations, hasPlausibleInterpretations: true, generatedFromRevision: rev };
setExisting((p) => { setExisting((p) => {
saveInvestigation({ ...p, investigationReport: reportData }); void saveInvestigation({ ...p, investigationReport: reportData })
.catch((error) => console.error("Investigation report save failed", error));
return { ...p, investigationReport: reportData }; return { ...p, investigationReport: reportData };
}); });
} else { } else {
@@ -73,7 +85,7 @@ export default function ReportPage({ params }) {
if (updateLoading) return; if (updateLoading) return;
setUpdateLoading(true); setUpdateLoading(true);
const snap = loadInvestigation(routeId); const snap = await loadInvestigation(routeId);
const situationGraph = snap?.situationGraph; const situationGraph = snap?.situationGraph;
const findings = snap?.findings ?? []; const findings = snap?.findings ?? [];
const rev = snap?.investigationRevision ?? 0; const rev = snap?.investigationRevision ?? 0;
@@ -99,7 +111,8 @@ export default function ReportPage({ params }) {
if (data.success) { if (data.success) {
const reportData = { understanding: data.understanding, plausibleInterpretations: data.plausibleInterpretations, hasPlausibleInterpretations: true, generatedFromRevision: rev }; const reportData = { understanding: data.understanding, plausibleInterpretations: data.plausibleInterpretations, hasPlausibleInterpretations: true, generatedFromRevision: rev };
setExisting((p) => { setExisting((p) => {
saveInvestigation({ ...p, investigationReport: reportData }); void saveInvestigation({ ...p, investigationReport: reportData })
.catch((error) => console.error("Investigation report save failed", error));
return { ...p, investigationReport: reportData }; return { ...p, investigationReport: reportData };
}); });
} }
+28 -5
View File
@@ -8,10 +8,23 @@ import { useRouter } from "next/navigation";
function Portfolio() { function Portfolio() {
const router = useRouter(); const router = useRouter();
const [summaries, setSummaries] = React.useState([]); const [summaries, setSummaries] = React.useState([]);
const [hydrated, setHydrated] = React.useState(false);
const [loadError, setLoadError] = React.useState(null);
const [showRestartConfirm, setShowRestartConfirm] = React.useState(false); const [showRestartConfirm, setShowRestartConfirm] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
setSummaries(listInvestigations()); let active = true;
(async () => {
try {
const investigations = await listInvestigations();
if (active) setSummaries(investigations);
} catch (error) {
if (active) setLoadError(error);
} finally {
if (active) setHydrated(true);
}
})();
return () => { active = false; };
}, []); }, []);
return ( return (
@@ -96,10 +109,14 @@ function Portfolio() {
Cancel Cancel
</button> </button>
<button <button
onClick={() => { onClick={async () => {
setShowRestartConfirm(null); setShowRestartConfirm(null);
try { restartInvestigation(summary.id); } catch (_) { /* storage must not crash caller */ } try {
setSummaries(listInvestigations()); await restartInvestigation(summary.id);
setSummaries(await listInvestigations());
} catch (error) {
setLoadError(error);
}
}} }}
className="rounded-lg border border-red-400 bg-white px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-50 transition" className="rounded-lg border border-red-400 bg-white px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-50 transition"
> >
@@ -116,7 +133,13 @@ function Portfolio() {
)} )}
{/* No investigations */} {/* No investigations */}
{summaries.length === 0 && ( {!hydrated && (
<section className="mb-10"><h2 className="mb-4 text-[13px] font-bold tracking-[.18em] uppercase text-teal-700/80">Investigations</h2><p className="text-sm text-gray-500 italic">Loading investigations</p></section>
)}
{hydrated && loadError && (
<section className="mb-10"><h2 className="mb-4 text-[13px] font-bold tracking-[.18em] uppercase text-teal-700/80">Investigations</h2><p className="text-sm text-red-600">Unable to load investigations.</p></section>
)}
{hydrated && !loadError && summaries.length === 0 && (
<section className="mb-10"> <section className="mb-10">
<h2 className="mb-4 text-[13px] font-bold tracking-[.18em] uppercase text-teal-700/80"> <h2 className="mb-4 text-[13px] font-bold tracking-[.18em] uppercase text-teal-700/80">
Investigations Investigations
+30 -12
View File
@@ -6,7 +6,7 @@ import DiagnosticsView from "@/components/diagnostics-view";
import ReasoningWorkspace, { LoadingOverlay, ContinueLaterBanner } from "@/components/reasoning-workspace"; import ReasoningWorkspace, { LoadingOverlay, ContinueLaterBanner } from "@/components/reasoning-workspace";
import { mockFetch, AVAILABLE_SCENARIOS } from "@/lib/mocks/confidence-engine/mock-client"; import { mockFetch, AVAILABLE_SCENARIOS } from "@/lib/mocks/confidence-engine/mock-client";
import { deriveFindingsFromContributions, normalizeFindings } from "@/lib/graph/finding-helpers"; import { deriveFindingsFromContributions, normalizeFindings } from "@/lib/graph/finding-helpers";
import { loadInvestigation, saveInvestigation, restartInvestigation, clearInvestigation } from "@/lib/storage/investigation-storage"; import { loadInvestigation, saveInvestigation, restartInvestigation } from "@/lib/storage/investigation-storage";
/* Compile-time env resolution — NEXT_PUBLIC_ vars are injected by Next.js at build */ /* Compile-time env resolution — NEXT_PUBLIC_ vars are injected by Next.js at build */
const MOCK_ENABLED = process.env.NEXT_PUBLIC_CONFIDENCE_ENGINE_MOCKS === "true"; const MOCK_ENABLED = process.env.NEXT_PUBLIC_CONFIDENCE_ENGINE_MOCKS === "true";
@@ -303,6 +303,16 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
/* ── v2 findings from focused contributions ─────────────── */ /* ── v2 findings from focused contributions ─────────────── */
const [findings, setFindings] = useState([]); const [findings, setFindings] = useState([]);
const [hydrated, setHydrated] = useState(false);
function persist(snapshot) {
void saveInvestigation(snapshot).catch((error) => console.error("Investigation autosave failed", error));
}
function restartPersistedInvestigation() {
void restartInvestigation(investigationId)
.catch((error) => console.error("Investigation restart failed", error));
}
function appendFinding(finding) { function appendFinding(finding) {
setFindings((prev) => { setFindings((prev) => {
@@ -544,8 +554,11 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
/* Restore persisted session on mount ─────────── */ /* Restore persisted session on mount ─────────── */
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
const saved = investigationId ? loadInvestigation(investigationId) : null; let active = true;
if (!saved) return; (async () => {
try {
const saved = investigationId ? await loadInvestigation(investigationId) : null;
if (!active || !saved) return;
const hasGraph = Boolean(saved.situationGraph); const hasGraph = Boolean(saved.situationGraph);
@@ -569,7 +582,12 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
if (hasGraph) { if (hasGraph) {
setStatus("success"); setStatus("success");
} }
}, []); } finally {
if (active) setHydrated(true);
}
})();
return () => { active = false; };
}, [investigationId]);
/* ── Canonical autosave — persist whenever state changes (Phase 2) ── */ /* ── Canonical autosave — persist whenever state changes (Phase 2) ── */
@@ -578,9 +596,9 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
// Guard: no valid investigation yet → skip autosave during idle/start flows. // Guard: no valid investigation yet → skip autosave during idle/start flows.
// Also prevents overwriting an existing saved investigation with the initial // Also prevents overwriting an existing saved investigation with the initial
// empty state of a fresh ScenarioForm instance (hydration race guard). // empty state of a fresh ScenarioForm instance (hydration race guard).
if (!result?.situationGraph) return; if (!hydrated || !result?.situationGraph) return;
void saveInvestigation({ persist({
id: investigationId, id: investigationId,
scenario, scenario,
situationGraph: result.situationGraph, situationGraph: result.situationGraph,
@@ -600,7 +618,7 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
focusedContributions, focusedContributions,
findings, findings,
investigationReport, investigationReport,
investigationRevision, investigationRevision, hydrated,
]); ]);
/* Restore facilitator dismiss preference (Experiment 05) ─── */ /* Restore facilitator dismiss preference (Experiment 05) ─── */
@@ -680,7 +698,7 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
setResult(normalised); setResult(normalised);
/* ── v0.59a — provenance: first meaningful change sets revision to 1 ── */ /* ── v0.59a — provenance: first meaningful change sets revision to 1 ── */
setInvestigationRevision(1); setInvestigationRevision(1);
saveInvestigation({ id: investigationId, scenario, situationGraph: normalised.situationGraph, selectedQuestion: normalised.selectedQuestion, summary: data.summary ?? null, updatedAt: new Date().toISOString(), focusedContributions, findings: [], investigationReport, investigationRevision: 1 }); persist({ id: investigationId, scenario, situationGraph: normalised.situationGraph, selectedQuestion: normalised.selectedQuestion, summary: data.summary ?? null, updatedAt: new Date().toISOString(), focusedContributions, findings: [], investigationReport, investigationRevision: 1 });
} else { } else {
setStatus("error"); setStatus("error");
setCurrentUnderstanding(data.summary ?? null); setCurrentUnderstanding(data.summary ?? null);
@@ -767,7 +785,7 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
/* ── v0.59a — provenance: meaningful change advances revision ── */ /* ── v0.59a — provenance: meaningful change advances revision ── */
const nextRev = (investigationRevision ?? 0) + 1; const nextRev = (investigationRevision ?? 0) + 1;
setInvestigationRevision(nextRev); setInvestigationRevision(nextRev);
saveInvestigation({ id: investigationId, scenario, situationGraph: nextGraph, selectedQuestion: normaliseUpdateSelectedQuestion(outcome.selectedQuestion), summary: currentUnderstanding, updatedAt: new Date().toISOString(), focusedContributions, findings: nextFindings, investigationReport, investigationRevision: nextRev }); persist({ id: investigationId, scenario, situationGraph: nextGraph, selectedQuestion: normaliseUpdateSelectedQuestion(outcome.selectedQuestion), summary: currentUnderstanding, updatedAt: new Date().toISOString(), focusedContributions, findings: nextFindings, investigationReport, investigationRevision: nextRev });
} else { } else {
setUpdateStatus("error"); setUpdateStatus("error");
setUpdateError(outcome); setUpdateError(outcome);
@@ -947,7 +965,7 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
setResult((prev) => ({ ...(prev ?? {}), situationGraph: nextGraph })); setResult((prev) => ({ ...(prev ?? {}), situationGraph: nextGraph }));
}} }}
onRestart={() => { onRestart={() => {
restartInvestigation(investigationId); restartPersistedInvestigation();
setInvestigationRevision(0); setInvestigationRevision(0);
setStatus("idle"); setStatus("idle");
setResult(null); setResult(null);
@@ -968,7 +986,7 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
{/* ── Continue later banner when session was restored ── */} {/* ── Continue later banner when session was restored ── */}
{status === "success" && result?.updatedAt && ( {status === "success" && result?.updatedAt && (
<ContinueLaterBanner onRestart={() => { restartInvestigation(investigationId); setInvestigationRevision(0); setStatus("idle"); setResult(null); setAnswer(""); setUpdateStatus("idle"); setCurrentUnderstanding(null); setFocusedContributions([]); setFindings([]); }} /> <ContinueLaterBanner onRestart={() => { restartPersistedInvestigation(); setInvestigationRevision(0); setStatus("idle"); setResult(null); setAnswer(""); setUpdateStatus("idle"); setCurrentUnderstanding(null); setFocusedContributions([]); setFindings([]); }} />
)} )}
{/* Reset button after successful analysis */} {/* Reset button after successful analysis */}
@@ -976,7 +994,7 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
<div className="text-center"> <div className="text-center">
<button <button
onClick={() => { onClick={() => {
restartInvestigation(investigationId); restartPersistedInvestigation();
setInvestigationRevision(0); setInvestigationRevision(0);
setScenario(""); setScenario("");
setStatus("idle"); setStatus("idle");
+27 -40
View File
@@ -10,12 +10,20 @@ Initial-decomposition hardening is frozen for the current MVP stage.
## Authenticated product boundary (v0.62a) ## Authenticated product boundary (v0.62a)
- Confidence Engine uses self-hosted Supabase Auth with magic-link email, `/auth/callback` code exchange, cookie-backed sessions, and protected product routes/API requests; unauthenticated API requests receive 401. - Confidence Engine uses self-hosted Supabase Auth with magic-link email, `/auth/callback` code exchange, cookie-backed sessions, and protected product routes/API requests; unauthenticated API requests receive 401.
- Investigation persistence remains wholly localStorage-backed and independent of authentication. No `confidence_engine` database schema, tables, snapshot ownership fields, Supabase server configuration, or PostgREST configuration were changed; server persistence remains future work. - Investigation persistence is now server-authoritative via Supabase `confidence_engine.investigations`. Browser persistence flows through authenticated Next.js API. No `confidence_engine` database schema, tables, snapshot ownership fields, or PostgREST configuration were changed in v0.62c (established in v0.62b).
## Database foundation (v0.62b) ## Database foundation (v0.62c)
- The version-controlled `confidence_engine.investigations` migration is live and PostgREST exposure was configured externally. Live SQL proved RLS rejects one authenticated user inserting a row owned by another. - Server-authoritative investigation persistence via Supabase `confidence_engine.investigations` as durable authority; browser persistence flows through authenticated Next.js API (`/api/investigations`).
- Authenticated server save/load/list capability now uses the RLS-scoped `confidence_engine` schema with server-derived identity. Production CE persistence remains localStorage-backed: no migration or cutover has occurred. - `lib/storage/providers/server-http.js` replaces localStorage as the backing provider for `lib/storage/investigation-storage.js`. The storage seam now owns async load/save and per-investigation coalescing autosave (rapid concurrent saves collapse to the latest snapshot).
- Async hydration adapted across Portfolio, Investigation, Report, and ScenarioForm.
- Restart preserved via shared transformation in `lib/storage/restart-investigation.js`; server-backed restart endpoint reuses this same transformation.
- Portfolio-compatible server summary projection (`scenario`, `updatedAt`, `investigationRevision`, `reportExists`, `reportGeneratedFromRevision`).
- Missing-new-investigation 404 maps to `null` at the load boundary in `server-http.js`.
- Live save and Portfolio reload persistence proven by manual evidence on Sep 8.
- Live application-level user isolation proven: second authenticated user sees clean Portfolio; original user regains only their server-backed investigation.
- localStorage is no longer production authority. Legacy localStorage investigations remain physically present but invisible to normal product flow. No dual-write. No automatic legacy import.
- Duplicate investigation GETs observed on development reload; one database row and one Portfolio card confirmed. No data-integrity defect established. No optimisation undertaken.
**Current product checkpoint:** Read `docs/confidence-engine-product-checkpoint-2026-09-08.md` before planning new product, live-evidence, or commercial work. The core investigation loop is now sufficiently established to prioritise realistic end-to-end use, report experience, prospective-user value, repeat use, and willingness to pay—not endless isolated reasoning-mechanics experiments. Preserve user ownership and address trust-critical defects when found. **Current product checkpoint:** Read `docs/confidence-engine-product-checkpoint-2026-09-08.md` before planning new product, live-evidence, or commercial work. The core investigation loop is now sufficiently established to prioritise realistic end-to-end use, report experience, prospective-user value, repeat use, and willingness to pay—not endless isolated reasoning-mechanics experiments. Preserve user ownership and address trust-critical defects when found.
@@ -55,37 +63,16 @@ Does the complete investigation process leave real people materially clearer abo
## Repository checkpoint ## Repository checkpoint
- **Branch:** `feature/initial-decomposition-v0.61` - **Branch:** `feature/product-platform-foundation-v0.62`
- **HEAD:** `5878ce4` — experiment(confidence-engine): add reconstruction-only helper flag - **HEAD:** `6dd447e` — feat(confidence-engine): add authenticated investigation persistence
- **Working tree:** clean after this session's commit - **Working tree:** dirty with completed v0.62c cutover (server-authoritative persistence, async seam, 404→null correction)
## Initial reconstruction — current status ## Persistence
**Semantically stable enough for current MVP stage.** Exact graph topology is not stable and is not treated as an invariant. Trust-critical meaning must remain stable. Some compression is acceptable when meaning survives downstream. Missing meaning cannot be faithfully recovered downstream. Causal hypotheses must remain visibly provisional. - **Owner:** `lib/storage/providers/server-http.js` (authenticated browser HTTP provider). `lib/storage/investigation-storage.js` owns the application-facing boundary with coalescing autosave and async load/save.
- **Durable authority:** Supabase `confidence_engine.investigations` (RLS-scoped, user-owned).
Current production default: `reconstruct-v0.5` prompt + canonical reconstruction schema + Zod validation via `z.toJSONSchema()`. - **localStorage:** legacy only — physically present but invisible to normal product flow. No dual-write. No automatic import.
- **Restart transformation:** shared in `lib/storage/restart-investigation.js`; used by both browser seam and server persistence layer.
The `/api/cases/start` route returns validated initial reconstruction, situation graph, and selected question. Observability seam exposes the exact object used by `buildInitialGraph()` for comparison.
**Frozen:** initial decomposition, prompt refinement, Qwen/Terra comparison — see CURRENT MVP DIRECTION above.
## Focused investigation — current status
Focused deconstruction plumbing fixes are complete:
- Schema mismatch resolved (focused route now supplies its own `focusedDeconstructJsonSchema`)
- Provider envelope no longer leaks into validator (inner `.response` unwrapped correctly)
- All 48 focused-investigation-boundary tests pass on first run
Focused deconstruction receives only:
- `centralStatement`
- `targetLabel`
- `targetDescription`
- `question`
- `answer`
Full SituationGraph / original scenario / previous findings are **not** supplied to that route. This is intentional epistemic separation.
Repeatability: supplier/weekend-shift epistemic separation repeated 3/3 on the fixed case after plumbing fix. Previous pre-fix semantic runs remain invalid (contaminated by provider-envelope misuse + wrong transport schema).
## Canonical experiment apparatus — currently valid ## Canonical experiment apparatus — currently valid
@@ -111,7 +98,7 @@ Three distinct routes:
/investigations/{id}/report → Investigation Report (derived summary) /investigations/{id}/report → Investigation Report (derived summary)
``` ```
**Portfolio:** investigation collection with actions per card (View report, Continue investigation, Restart). "+ Create new investigation" allocates durable ID via `crypto.randomUUID()` + navigates. **Portfolio:** investigation collection loaded from server API (`/api/investigations`). Actions per card: View report, Continue investigation, Restart. "+ Create new investigation" allocates durable ID via `crypto.randomUUID()` + navigates.
**Investigation:** `ScenarioForm` + `ReasoningWorkspace`. Handles focused turns, Done/Re-open semantics, Current Understanding synthesis. **Investigation:** `ScenarioForm` + `ReasoningWorkspace`. Handles focused turns, Done/Re-open semantics, Current Understanding synthesis.
@@ -132,12 +119,12 @@ RAW USER EVIDENCE
## Persistence ## Persistence
- **Owner:** `lib/storage/providers/local-storage.js` (`saveInvestigation` / `loadInvestigation`) - **Owner:** `lib/storage/providers/server-http.js` (authenticated browser HTTP provider). `lib/storage/investigation-storage.js` owns the application-facing seam with coalescing autosave.
- **Key prefix:** `confidence-engine-investigation:<durable-id>` - **Durable authority:** Supabase `confidence_engine.investigations` (RLS-scoped, user-owned).
- **Storage contract:** `lib/storage/investigation-storage.js` (application-facing boundary) - **localStorage:** legacy only — physically present but invisible to normal product flow. No dual-write. No automatic import.
- **Identity:** durable `id` allocated by application, not storage - **Identity:** durable `id` allocated by application, not storage.
- **First persistence:** when user produces meaningful state (scenario submitted), not on create-click - **First persistence:** when user produces meaningful state (scenario submitted), not on create-click.
- **Restart:** preserves container/id/scenario; clears reasoning/report state - **Restart:** preserves container/id/scenario; clears reasoning/report state via shared transformation in `lib/storage/restart-investigation.js`.
## MVP boundaries ## MVP boundaries
+6 -6
View File
@@ -34,9 +34,9 @@ The product direction is a **facilitated investigation** presented across three
**Report:** Renders persisted `investigationReport` snapshot. Generation is on-demand (exactly one `/api/cases/overview` call on first visit; zero on subsequent visits). The Report is a derived artefact, not canonical reasoning evidence. **Report:** Renders persisted `investigationReport` snapshot. Generation is on-demand (exactly one `/api/cases/overview` call on first visit; zero on subsequent visits). The Report is a derived artefact, not canonical reasoning evidence.
**Authentication boundary:** Supabase Auth magic links gate product and CE API routes. Sessions are cookie-backed and `/auth/callback` exchanges the auth code before returning to `/`. This does not alter localStorage investigation persistence or introduce user ownership into CE snapshots; dedicated `confidence_engine` PostgreSQL persistence remains future work. **Authentication boundary:** Supabase Auth magic links gate product and CE API routes. Sessions are cookie-backed and `/auth/callback` exchanges the auth code before returning to `/`. Server-authoritative investigation persistence via authenticated browser HTTP provider; localStorage is legacy only.
**Database contract (v0.62b):** The applied `confidence_engine.investigations` schema sits outside `public`. Its platform metadata is `id`, `user_id`, and timestamps; the CE payload remains an opaque JSONB `snapshot`. Authenticated RLS ownership is `user_id = auth.uid()`, and external PostgREST configuration exposes the schema. Server save/load/list capability is available through the authenticated/RLS path, but localStorage remains the production persistence authority; controlled cutover and legacy migration are future work. **Database contract (v0.62c):** The applied `confidence_engine.investigations` schema sits outside `public`. Its platform metadata is `id`, `user_id`, and timestamps; the CE payload remains an opaque JSONB `snapshot`. Authenticated RLS ownership is `user_id = auth.uid()`, and external PostgREST configuration exposes the schema. Server persistence is now the production authority; localStorage is legacy only. No dual-write. No automatic legacy import.
The user controls which question to investigate, how deeply to investigate it, when to say Done for now, whether Current Understanding is sufficient, whether to reopen work, and when to review the Report. The engine facilitates — it does not steer or prioritise. The user controls which question to investigate, how deeply to investigate it, when to say Done for now, whether Current Understanding is sufficient, whether to reopen work, and when to review the Report. The engine facilitates — it does not steer or prioritise.
@@ -79,11 +79,11 @@ Three distinct routes, each with clear ownership:
### Persistence and report lifecycle ### Persistence and report lifecycle
- Multi-Investigation collection via localStorage (key prefix `confidence-engine-investigation:<durable-id>`). Legacy singleton path retained for backward compatibility (unused by current product). - **Server-authoritative:** Supabase `confidence_engine.investigations` via authenticated browser HTTP provider (`lib/storage/providers/server-http.js`). localStorage is legacy only — invisible to normal product flow. No dual-write. No automatic legacy import.
- `saveInvestigation()` / `loadInvestigation()` are the canonical storage seams. - `saveInvestigation()` / `loadInvestigation()` are the canonical storage seams, backed by server-HTTP provider with coalescing autosave in `lib/storage/investigation-storage.js`.
- `listInvestigations()` returns lightweight summaries for Portfolio rendering. - `listInvestigations()` returns lightweight summaries for Portfolio rendering from the server API.
- Report generation: first visit → one synthesis call + persist; subsequent visits → zero calls, renders persisted snapshot. - Report generation: first visit → one synthesis call + persist; subsequent visits → zero calls, renders persisted snapshot.
- Restart is destructive and confirmation-gated (dialog → explicit second confirmation`clearInvestigation()`). - Restart is destructive and confirmation-gated (dialog → explicit second confirmation); uses shared transformation in `lib/storage/restart-investigation.js`.
### Reasoning-engine vs UX/product version lineage ### Reasoning-engine vs UX/product version lineage
+63 -23
View File
@@ -1,43 +1,81 @@
// investigation-storage — application-facing persistence boundary // investigation-storage — application-facing persistence boundary
// Owns the canonical identity contract: snapshot.id is the sole save identity. // Owns the canonical identity contract: snapshot.id is the sole save identity.
// Delegates to the concrete localStorage provider internally. // Delegates to the authenticated browser HTTP provider internally.
import { loadInvestigation as _load, saveInvestigation as _save, clearInvestigation as _clear, listInvestigations as _list, restartInvestigation as _restart } from "./providers/local-storage.js"; import { loadInvestigation as _load, saveInvestigation as _save, listInvestigations as _list, restartInvestigation as _restart } from "./providers/server-http.js";
const saveStates = new Map();
function observeUnhandledRejection(promise) {
promise.catch(() => {});
return promise;
}
function getSaveState(id) {
if (!saveStates.has(id)) saveStates.set(id, { inFlight: false, pending: null, idleWaiters: [] });
return saveStates.get(id);
}
async function drainSaveState(id, state) {
while (state.pending) {
const pending = state.pending;
state.pending = null;
try {
const saved = await _save(pending.snapshot, id);
pending.waiters.forEach(({ resolve }) => resolve(saved));
} catch (error) {
pending.waiters.forEach(({ reject }) => reject(error));
}
}
state.inFlight = false;
state.idleWaiters.splice(0).forEach((resolve) => resolve());
}
function waitForSaves(id) {
const state = saveStates.get(id);
if (!state?.inFlight && !state?.pending) return Promise.resolve();
return new Promise((resolve) => state.idleWaiters.push(resolve));
}
/** /**
* Canonical save contract: snapshot.id is the sole identity authority. * Canonical save contract: snapshot.id is the sole identity authority.
* When snapshot carries an id — persist under that id key (identity-aware). * A durable id is required and is sent to the authenticated server API.
* When snapshot has no id — fall back to legacy singleton compatibility path.
*/ */
export function saveInvestigation(snapshot, explicitId) { export function saveInvestigation(snapshot, explicitId) {
if (snapshot && typeof snapshot === "object" && snapshot.id != null) { const id = snapshot?.id ?? explicitId;
return _save(snapshot, snapshot.id); if (!snapshot || typeof snapshot !== "object" || !id) {
return observeUnhandledRejection(Promise.reject(new Error("Investigation snapshot and id are required")));
} }
// Legacy unidentified snapshot — singleton fallback for unmigrated callers const state = getSaveState(id);
return _save(snapshot, explicitId); const promise = new Promise((resolve, reject) => {
// A pending entry has not started yet, so replacing it safely coalesces intermediate autosaves.
if (state.pending) {
state.pending.snapshot = snapshot;
state.pending.waiters.push({ resolve, reject });
} else {
state.pending = { snapshot, waiters: [{ resolve, reject }] };
}
});
if (!state.inFlight) {
state.inFlight = true;
void drainSaveState(id, state);
}
return observeUnhandledRejection(promise);
} }
/** /**
* Canonical load contract: select by durable id when supplied; * Canonical load contract: select by durable id through the authenticated server API.
* fall back to legacy singleton path otherwise.
*/ */
export function loadInvestigation(id) { export async function loadInvestigation(id) {
return _load(id != null ? id : undefined); if (!id) return null;
} return _load(id);
/**
* Canonical clear contract: remove by identity-aware key when supplied;
* fall back to legacy singleton keys otherwise.
*/
export function clearInvestigation(id) {
return _clear(id ?? undefined);
} }
/** /**
* Lists all durable-ID Investigation records as lightweight summaries. * Lists all durable-ID Investigation records as lightweight summaries.
* Excludes legacy singleton, sessionStorage state, unrelated storage, malformed entries. * Server persistence is the authority; legacy browser storage is not consulted.
*/ */
export function listInvestigations() { export async function listInvestigations() {
return _list(); return _list();
} }
@@ -49,6 +87,8 @@ export function listInvestigations() {
* *
* Missing/invalid id → silently no-op (does NOT fall back to legacy singleton). * Missing/invalid id → silently no-op (does NOT fall back to legacy singleton).
*/ */
export function restartInvestigation(id) { export async function restartInvestigation(id) {
if (!id) return null;
await waitForSaves(id);
return _restart(id); return _restart(id);
} }
+3 -11
View File
@@ -4,6 +4,8 @@ const CANONICAL_KEY = "confidence-engine-investigation";
const LEGACY_KEY = "confidence-engine-session"; const LEGACY_KEY = "confidence-engine-session";
const SCHEMA_VERSION = 1; const SCHEMA_VERSION = 1;
import { restartSnapshot } from "../restart-investigation.js";
// Multi-Investigation key prefix (v0.60c) // Multi-Investigation key prefix (v0.60c)
const INVESTIGATION_PREFIX = "confidence-engine-investigation:"; const INVESTIGATION_PREFIX = "confidence-engine-investigation:";
@@ -196,17 +198,7 @@ export function restartInvestigation(id) {
const record = JSON.parse(raw); const record = JSON.parse(raw);
if (!isPlainObject(record)) return; if (!isPlainObject(record)) return;
// Preserve container fields, reset reasoning-state fields _persist(storage, key, JSON.stringify(restartSnapshot(record)));
record.situationGraph = null;
record.selectedQuestion = null;
record.summary = null;
record.focusedContributions = [];
record.findings = [];
record.investigationReport = null;
record.investigationRevision = 0;
record.updatedAt = new Date().toISOString();
_persist(storage, key, JSON.stringify(record));
} catch (_) { /* storage errors must not crash caller */ } } catch (_) { /* storage errors must not crash caller */ }
} }
+41
View File
@@ -0,0 +1,41 @@
async function request(path, options) {
const response = await fetch(path, options);
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(body.error || "Investigation persistence request failed");
}
return body;
}
export async function saveInvestigation(snapshot, id) {
const body = await request("/api/investigations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, snapshot }),
});
return body.snapshot ?? null;
}
export async function loadInvestigation(id) {
const url = `/api/investigations/${encodeURIComponent(id)}`;
const response = await fetch(url);
if (!response.ok) {
if (response.status === 404) return null;
const body = await response.json().catch(() => ({}));
throw new Error(body.error || "Investigation persistence request failed");
}
const body = await response.json();
return body.snapshot ?? null;
}
export async function listInvestigations() {
const body = await request("/api/investigations");
return body.investigations ?? [];
}
export async function restartInvestigation(id) {
const body = await request(`/api/investigations/${encodeURIComponent(id)}/restart`, {
method: "POST",
});
return body.snapshot ?? null;
}
+13
View File
@@ -0,0 +1,13 @@
export function restartSnapshot(snapshot, now = new Date().toISOString()) {
return {
...snapshot,
situationGraph: null,
selectedQuestion: null,
summary: null,
focusedContributions: [],
findings: [],
investigationReport: null,
investigationRevision: 0,
updatedAt: now,
};
}
@@ -2,6 +2,7 @@ import {
createServerSupabaseClient, createServerSupabaseClient,
getAuthenticatedUser, getAuthenticatedUser,
} from "@/lib/supabase/server.js"; } from "@/lib/supabase/server.js";
import { restartSnapshot } from "./restart-investigation.js";
const SCHEMA = "confidence_engine"; const SCHEMA = "confidence_engine";
const TABLE = "investigations"; const TABLE = "investigations";
@@ -50,17 +51,26 @@ export async function loadInvestigation(id) {
export async function listInvestigations() { export async function listInvestigations() {
const context = await getAuthenticatedPersistenceContext(); const context = await getAuthenticatedPersistenceContext();
if (!context) return null; if (!context) return [];
const { data, error } = await context.supabase const { data, error } = await context.supabase
.schema(SCHEMA) .schema(SCHEMA)
.from(TABLE) .from(TABLE)
.select("id, created_at, updated_at") .select("id, snapshot, created_at, updated_at")
.order("updated_at", { ascending: false }); .order("updated_at", { ascending: false });
throwIfDatabaseError(error); throwIfDatabaseError(error);
return (data ?? []).map((record) => ({ return (data ?? []).map((record) => ({
id: record.id, id: record.id,
createdAt: record.created_at, scenario: record.snapshot?.scenario ?? null,
updatedAt: record.updated_at, updatedAt: record.snapshot?.updatedAt ?? record.updated_at,
investigationRevision: record.snapshot?.investigationRevision ?? 0,
reportExists: !!record.snapshot?.investigationReport,
reportGeneratedFromRevision: record.snapshot?.investigationReport?.generatedFromRevision ?? null,
})); }));
} }
export async function restartInvestigation(id) {
const snapshot = await loadInvestigation(id);
if (!snapshot) return null;
return saveInvestigation(restartSnapshot(snapshot), id);
}
+33 -4
View File
@@ -61,8 +61,14 @@ describe("server investigation persistence", () => {
}); });
it("lists only RLS-visible persistence records", async () => { it("lists only RLS-visible persistence records", async () => {
const snapshot = {
scenario: "A situation",
updatedAt: "2026-09-08T00:30:00Z",
investigationRevision: 3,
investigationReport: { generatedFromRevision: 2 },
};
const { client, query } = makeClient({ const { client, query } = makeClient({
data: [{ id: "investigation-1", created_at: "2026-09-08T00:00:00Z", updated_at: "2026-09-08T01:00:00Z" }], data: [{ id: "investigation-1", snapshot, created_at: "2026-09-08T00:00:00Z", updated_at: "2026-09-08T01:00:00Z" }],
error: null, error: null,
}); });
mockGetAuthenticatedUser.mockResolvedValue({ id: "trusted-user" }); mockGetAuthenticatedUser.mockResolvedValue({ id: "trusted-user" });
@@ -71,11 +77,34 @@ describe("server investigation persistence", () => {
await expect(listInvestigations()).resolves.toEqual([{ await expect(listInvestigations()).resolves.toEqual([{
id: "investigation-1", id: "investigation-1",
createdAt: "2026-09-08T00:00:00Z", scenario: "A situation",
updatedAt: "2026-09-08T01:00:00Z", updatedAt: "2026-09-08T00:30:00Z",
investigationRevision: 3,
reportExists: true,
reportGeneratedFromRevision: 2,
}]); }]);
expect(client.schema).toHaveBeenCalledWith("confidence_engine"); expect(client.schema).toHaveBeenCalledWith("confidence_engine");
expect(query.select).toHaveBeenCalledWith("id, created_at, updated_at"); expect(query.select).toHaveBeenCalledWith("id, snapshot, created_at, updated_at");
expect(query.order).toHaveBeenCalledWith("updated_at", { ascending: false }); expect(query.order).toHaveBeenCalledWith("updated_at", { ascending: false });
}); });
it("restarts an owned snapshot through the server path using the established transformation", async () => {
const snapshot = {
id: "investigation-1", scenario: "A situation", situationGraph: {}, selectedQuestion: "Question",
summary: "Summary", focusedContributions: [{}], findings: [{}], investigationReport: {}, investigationRevision: 4,
};
const { client, query } = makeClient({ data: { snapshot }, error: null });
mockGetAuthenticatedUser.mockResolvedValue({ id: "trusted-user" });
mockCreateServerSupabaseClient.mockReturnValue(client);
const { restartInvestigation } = await import("@/lib/storage/server-investigation-persistence.js");
await restartInvestigation("investigation-1");
expect(query.upsert).toHaveBeenCalledWith(expect.objectContaining({
id: "investigation-1", user_id: "trusted-user", snapshot: expect.objectContaining({
id: "investigation-1", scenario: "A situation", situationGraph: null, selectedQuestion: null,
summary: null, focusedContributions: [], findings: [], investigationReport: null, investigationRevision: 0,
}),
}), { onConflict: "id" });
});
}); });
@@ -0,0 +1,80 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const save = vi.fn();
const load = vi.fn();
const list = vi.fn();
const restart = vi.fn();
vi.mock("@/lib/storage/providers/server-http.js", () => ({
saveInvestigation: (...args) => save(...args),
loadInvestigation: (...args) => load(...args),
listInvestigations: (...args) => list(...args),
restartInvestigation: (...args) => restart(...args),
}));
function deferred() {
let resolve;
const promise = new Promise((next) => { resolve = next; });
return { promise, resolve };
}
describe("server-authoritative investigation storage seam", () => {
beforeEach(() => {
vi.clearAllMocks();
save.mockResolvedValue(null);
});
it("uses only the server provider for async load and list", async () => {
const storage = await import("@/lib/storage/investigation-storage.js");
load.mockResolvedValue({ id: "inv-1" });
list.mockResolvedValue([{ id: "inv-1" }]);
await expect(storage.loadInvestigation("inv-1")).resolves.toEqual({ id: "inv-1" });
await expect(storage.listInvestigations()).resolves.toEqual([{ id: "inv-1" }]);
expect(load).toHaveBeenCalledWith("inv-1");
expect(list).toHaveBeenCalledTimes(1);
});
it("allows only one in-flight save per investigation and coalesces rapid pending saves to the latest snapshot", async () => {
const storage = await import("@/lib/storage/investigation-storage.js");
const first = deferred();
const second = deferred();
save.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
const one = storage.saveInvestigation({ id: "inv-1", revision: 1 });
const two = storage.saveInvestigation({ id: "inv-1", revision: 2 });
const three = storage.saveInvestigation({ id: "inv-1", revision: 3 });
expect(save).toHaveBeenCalledTimes(1);
expect(save).toHaveBeenCalledWith({ id: "inv-1", revision: 1 }, "inv-1");
first.resolve({ id: "inv-1", revision: 1 });
await Promise.resolve();
expect(save).toHaveBeenCalledTimes(2);
expect(save).toHaveBeenLastCalledWith({ id: "inv-1", revision: 3 }, "inv-1");
second.resolve({ id: "inv-1", revision: 3 });
await expect(Promise.all([one, two, three])).resolves.toEqual([
{ id: "inv-1", revision: 1 },
{ id: "inv-1", revision: 3 },
{ id: "inv-1", revision: 3 },
]);
});
it("does not permit an older request to complete after a newer request becomes durable", async () => {
const storage = await import("@/lib/storage/investigation-storage.js");
const first = deferred();
const second = deferred();
save.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
const older = storage.saveInvestigation({ id: "inv-2", revision: 2 });
const newer = storage.saveInvestigation({ id: "inv-2", revision: 3 });
expect(save).toHaveBeenCalledTimes(1);
first.resolve({ id: "inv-2", revision: 2 });
await Promise.resolve();
expect(save).toHaveBeenLastCalledWith({ id: "inv-2", revision: 3 }, "inv-2");
second.resolve({ id: "inv-2", revision: 3 });
await Promise.all([older, newer]);
expect(save).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,38 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import * as provider from "@/lib/storage/providers/server-http.js";
afterEach(() => vi.unstubAllGlobals());
describe("server HTTP investigation provider", () => {
it("maps save, load, list, and restart to authenticated investigation API paths", async () => {
const fetch = vi.fn()
.mockResolvedValueOnce({ ok: true, json: async () => ({ snapshot: { id: "inv/a" } }) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ snapshot: { id: "inv/a" } }) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ investigations: [] }) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ snapshot: { id: "inv/a" } }) });
vi.stubGlobal("fetch", fetch);
await expect(provider.saveInvestigation({ id: "inv/a" }, "inv/a")).resolves.toEqual({ id: "inv/a" });
await expect(provider.loadInvestigation("inv/a")).resolves.toEqual({ id: "inv/a" });
await expect(provider.listInvestigations()).resolves.toEqual([]);
await expect(provider.restartInvestigation("inv/a")).resolves.toEqual({ id: "inv/a" });
expect(fetch).toHaveBeenNthCalledWith(1, "/api/investigations", expect.objectContaining({
method: "POST",
body: JSON.stringify({ id: "inv/a", snapshot: { id: "inv/a" } }),
}));
expect(fetch).toHaveBeenNthCalledWith(2, "/api/investigations/inv%2Fa");
expect(fetch).toHaveBeenNthCalledWith(3, "/api/investigations", undefined);
expect(fetch).toHaveBeenNthCalledWith(4, "/api/investigations/inv%2Fa/restart", { method: "POST" });
});
it("returns null for a missing investigation (HTTP 404) rather than throwing", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 404, json: async () => ({ error: "Investigation not found" }) }));
await expect(provider.loadInvestigation("inv/missing")).resolves.toBeNull();
});
it("surfaces API failures rather than returning an empty result", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 500, json: async () => ({ error: "Unauthorized" }) }));
await expect(provider.listInvestigations()).rejects.toThrow("Unauthorized");
});
});