feat(confidence-engine): use server investigation persistence
This commit is contained in:
@@ -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);
|
||||
@@ -11,10 +11,21 @@ export default function InvestigationPage({ params }) {
|
||||
const router = useRouter();
|
||||
const routeId = typeof params?.id === "string" ? params.id : "";
|
||||
const [existing, setExisting] = useState(null);
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!routeId) return;
|
||||
setExisting(loadInvestigation(routeId));
|
||||
let active = true;
|
||||
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]);
|
||||
|
||||
return (
|
||||
@@ -35,7 +46,9 @@ export default function InvestigationPage({ params }) {
|
||||
evidence-based structured reconstruction. This is a technical vertical
|
||||
slice — not a production system.
|
||||
</p>
|
||||
{existing ? (
|
||||
{!hydrated ? (
|
||||
<p className="text-sm text-gray-500">Loading investigation…</p>
|
||||
) : existing ? (
|
||||
<ScenarioForm
|
||||
investigationId={routeId}
|
||||
existingSnapshot={existing}
|
||||
|
||||
@@ -15,9 +15,20 @@ export default function ReportPage({ params }) {
|
||||
const generationAttempted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
setExisting(loadInvestigation(routeId));
|
||||
setHydrated(true);
|
||||
}, []);
|
||||
let active = 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)
|
||||
useEffect(() => {
|
||||
@@ -54,7 +65,8 @@ export default function ReportPage({ params }) {
|
||||
const rev = existing?.investigationRevision ?? 0;
|
||||
const reportData = { understanding: data.understanding, plausibleInterpretations: data.plausibleInterpretations, hasPlausibleInterpretations: true, generatedFromRevision: rev };
|
||||
setExisting((p) => {
|
||||
saveInvestigation({ ...p, investigationReport: reportData });
|
||||
void saveInvestigation({ ...p, investigationReport: reportData })
|
||||
.catch((error) => console.error("Investigation report save failed", error));
|
||||
return { ...p, investigationReport: reportData };
|
||||
});
|
||||
} else {
|
||||
@@ -73,7 +85,7 @@ export default function ReportPage({ params }) {
|
||||
if (updateLoading) return;
|
||||
setUpdateLoading(true);
|
||||
|
||||
const snap = loadInvestigation(routeId);
|
||||
const snap = await loadInvestigation(routeId);
|
||||
const situationGraph = snap?.situationGraph;
|
||||
const findings = snap?.findings ?? [];
|
||||
const rev = snap?.investigationRevision ?? 0;
|
||||
@@ -99,7 +111,8 @@ export default function ReportPage({ params }) {
|
||||
if (data.success) {
|
||||
const reportData = { understanding: data.understanding, plausibleInterpretations: data.plausibleInterpretations, hasPlausibleInterpretations: true, generatedFromRevision: rev };
|
||||
setExisting((p) => {
|
||||
saveInvestigation({ ...p, investigationReport: reportData });
|
||||
void saveInvestigation({ ...p, investigationReport: reportData })
|
||||
.catch((error) => console.error("Investigation report save failed", error));
|
||||
return { ...p, investigationReport: reportData };
|
||||
});
|
||||
}
|
||||
|
||||
+28
-5
@@ -8,10 +8,23 @@ import { useRouter } from "next/navigation";
|
||||
function Portfolio() {
|
||||
const router = useRouter();
|
||||
const [summaries, setSummaries] = React.useState([]);
|
||||
const [hydrated, setHydrated] = React.useState(false);
|
||||
const [loadError, setLoadError] = React.useState(null);
|
||||
const [showRestartConfirm, setShowRestartConfirm] = React.useState(false);
|
||||
|
||||
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 (
|
||||
@@ -96,10 +109,14 @@ function Portfolio() {
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onClick={async () => {
|
||||
setShowRestartConfirm(null);
|
||||
try { restartInvestigation(summary.id); } catch (_) { /* storage must not crash caller */ }
|
||||
setSummaries(listInvestigations());
|
||||
try {
|
||||
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"
|
||||
>
|
||||
@@ -116,7 +133,13 @@ function Portfolio() {
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
<h2 className="mb-4 text-[13px] font-bold tracking-[.18em] uppercase text-teal-700/80">
|
||||
Investigations
|
||||
|
||||
@@ -6,7 +6,7 @@ import DiagnosticsView from "@/components/diagnostics-view";
|
||||
import ReasoningWorkspace, { LoadingOverlay, ContinueLaterBanner } from "@/components/reasoning-workspace";
|
||||
import { mockFetch, AVAILABLE_SCENARIOS } from "@/lib/mocks/confidence-engine/mock-client";
|
||||
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 */
|
||||
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 ─────────────── */
|
||||
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) {
|
||||
setFindings((prev) => {
|
||||
@@ -544,8 +554,11 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
|
||||
/* Restore persisted session on mount ─────────── */
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const saved = investigationId ? loadInvestigation(investigationId) : null;
|
||||
if (!saved) return;
|
||||
let active = true;
|
||||
(async () => {
|
||||
try {
|
||||
const saved = investigationId ? await loadInvestigation(investigationId) : null;
|
||||
if (!active || !saved) return;
|
||||
|
||||
const hasGraph = Boolean(saved.situationGraph);
|
||||
|
||||
@@ -569,7 +582,12 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
|
||||
if (hasGraph) {
|
||||
setStatus("success");
|
||||
}
|
||||
}, []);
|
||||
} finally {
|
||||
if (active) setHydrated(true);
|
||||
}
|
||||
})();
|
||||
return () => { active = false; };
|
||||
}, [investigationId]);
|
||||
|
||||
/* ── 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.
|
||||
// Also prevents overwriting an existing saved investigation with the initial
|
||||
// empty state of a fresh ScenarioForm instance (hydration race guard).
|
||||
if (!result?.situationGraph) return;
|
||||
if (!hydrated || !result?.situationGraph) return;
|
||||
|
||||
void saveInvestigation({
|
||||
persist({
|
||||
id: investigationId,
|
||||
scenario,
|
||||
situationGraph: result.situationGraph,
|
||||
@@ -600,7 +618,7 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
|
||||
focusedContributions,
|
||||
findings,
|
||||
investigationReport,
|
||||
investigationRevision,
|
||||
investigationRevision, hydrated,
|
||||
]);
|
||||
|
||||
/* Restore facilitator dismiss preference (Experiment 05) ─── */
|
||||
@@ -680,7 +698,7 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
|
||||
setResult(normalised);
|
||||
/* ── v0.59a — provenance: first meaningful change sets revision to 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 {
|
||||
setStatus("error");
|
||||
setCurrentUnderstanding(data.summary ?? null);
|
||||
@@ -767,7 +785,7 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
|
||||
/* ── v0.59a — provenance: meaningful change advances revision ── */
|
||||
const nextRev = (investigationRevision ?? 0) + 1;
|
||||
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 {
|
||||
setUpdateStatus("error");
|
||||
setUpdateError(outcome);
|
||||
@@ -947,7 +965,7 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
|
||||
setResult((prev) => ({ ...(prev ?? {}), situationGraph: nextGraph }));
|
||||
}}
|
||||
onRestart={() => {
|
||||
restartInvestigation(investigationId);
|
||||
restartPersistedInvestigation();
|
||||
setInvestigationRevision(0);
|
||||
setStatus("idle");
|
||||
setResult(null);
|
||||
@@ -968,7 +986,7 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
|
||||
|
||||
{/* ── Continue later banner when session was restored ── */}
|
||||
{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 */}
|
||||
@@ -976,7 +994,7 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
|
||||
<div className="text-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
restartInvestigation(investigationId);
|
||||
restartPersistedInvestigation();
|
||||
setInvestigationRevision(0);
|
||||
setScenario("");
|
||||
setStatus("idle");
|
||||
|
||||
+27
-40
@@ -10,12 +10,20 @@ Initial-decomposition hardening is frozen for the current MVP stage.
|
||||
## 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- Server-authoritative investigation persistence via Supabase `confidence_engine.investigations` as durable authority; browser persistence flows through authenticated Next.js API (`/api/investigations`).
|
||||
- `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.
|
||||
|
||||
@@ -55,37 +63,16 @@ Does the complete investigation process leave real people materially clearer abo
|
||||
|
||||
## Repository checkpoint
|
||||
|
||||
- **Branch:** `feature/initial-decomposition-v0.61`
|
||||
- **HEAD:** `5878ce4` — experiment(confidence-engine): add reconstruction-only helper flag
|
||||
- **Working tree:** clean after this session's commit
|
||||
- **Branch:** `feature/product-platform-foundation-v0.62`
|
||||
- **HEAD:** `6dd447e` — feat(confidence-engine): add authenticated investigation persistence
|
||||
- **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.
|
||||
|
||||
Current production default: `reconstruct-v0.5` prompt + canonical reconstruction schema + Zod validation via `z.toJSONSchema()`.
|
||||
|
||||
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).
|
||||
- **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).
|
||||
- **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.
|
||||
|
||||
## Canonical experiment apparatus — currently valid
|
||||
|
||||
@@ -111,7 +98,7 @@ Three distinct routes:
|
||||
/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.
|
||||
|
||||
@@ -132,12 +119,12 @@ RAW USER EVIDENCE
|
||||
|
||||
## Persistence
|
||||
|
||||
- **Owner:** `lib/storage/providers/local-storage.js` (`saveInvestigation` / `loadInvestigation`)
|
||||
- **Key prefix:** `confidence-engine-investigation:<durable-id>`
|
||||
- **Storage contract:** `lib/storage/investigation-storage.js` (application-facing boundary)
|
||||
- **Identity:** durable `id` allocated by application, not storage
|
||||
- **First persistence:** when user produces meaningful state (scenario submitted), not on create-click
|
||||
- **Restart:** preserves container/id/scenario; clears reasoning/report state
|
||||
- **Owner:** `lib/storage/providers/server-http.js` (authenticated browser HTTP provider). `lib/storage/investigation-storage.js` owns the application-facing seam with coalescing autosave.
|
||||
- **Durable authority:** Supabase `confidence_engine.investigations` (RLS-scoped, user-owned).
|
||||
- **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.
|
||||
- **First persistence:** when user produces meaningful state (scenario submitted), not on create-click.
|
||||
- **Restart:** preserves container/id/scenario; clears reasoning/report state via shared transformation in `lib/storage/restart-investigation.js`.
|
||||
|
||||
## MVP boundaries
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -79,11 +79,11 @@ Three distinct routes, each with clear ownership:
|
||||
|
||||
### 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).
|
||||
- `saveInvestigation()` / `loadInvestigation()` are the canonical storage seams.
|
||||
- `listInvestigations()` returns lightweight summaries for Portfolio rendering.
|
||||
- **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, backed by server-HTTP provider with coalescing autosave in `lib/storage/investigation-storage.js`.
|
||||
- `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.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -1,43 +1,81 @@
|
||||
// investigation-storage — application-facing persistence boundary
|
||||
// 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.
|
||||
* When snapshot carries an id — persist under that id key (identity-aware).
|
||||
* When snapshot has no id — fall back to legacy singleton compatibility path.
|
||||
* A durable id is required and is sent to the authenticated server API.
|
||||
*/
|
||||
export function saveInvestigation(snapshot, explicitId) {
|
||||
if (snapshot && typeof snapshot === "object" && snapshot.id != null) {
|
||||
return _save(snapshot, snapshot.id);
|
||||
const id = snapshot?.id ?? explicitId;
|
||||
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
|
||||
return _save(snapshot, explicitId);
|
||||
const state = getSaveState(id);
|
||||
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;
|
||||
* fall back to legacy singleton path otherwise.
|
||||
* Canonical load contract: select by durable id through the authenticated server API.
|
||||
*/
|
||||
export function loadInvestigation(id) {
|
||||
return _load(id != null ? id : undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
export async function loadInvestigation(id) {
|
||||
if (!id) return null;
|
||||
return _load(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
|
||||
@@ -49,6 +87,8 @@ export function listInvestigations() {
|
||||
*
|
||||
* 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);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ const CANONICAL_KEY = "confidence-engine-investigation";
|
||||
const LEGACY_KEY = "confidence-engine-session";
|
||||
const SCHEMA_VERSION = 1;
|
||||
|
||||
import { restartSnapshot } from "../restart-investigation.js";
|
||||
|
||||
// Multi-Investigation key prefix (v0.60c)
|
||||
const INVESTIGATION_PREFIX = "confidence-engine-investigation:";
|
||||
|
||||
@@ -196,17 +198,7 @@ export function restartInvestigation(id) {
|
||||
const record = JSON.parse(raw);
|
||||
if (!isPlainObject(record)) return;
|
||||
|
||||
// Preserve container fields, reset reasoning-state fields
|
||||
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));
|
||||
_persist(storage, key, JSON.stringify(restartSnapshot(record)));
|
||||
} catch (_) { /* storage errors must not crash caller */ }
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
getAuthenticatedUser,
|
||||
} from "@/lib/supabase/server.js";
|
||||
import { restartSnapshot } from "./restart-investigation.js";
|
||||
|
||||
const SCHEMA = "confidence_engine";
|
||||
const TABLE = "investigations";
|
||||
@@ -50,17 +51,26 @@ export async function loadInvestigation(id) {
|
||||
|
||||
export async function listInvestigations() {
|
||||
const context = await getAuthenticatedPersistenceContext();
|
||||
if (!context) return null;
|
||||
if (!context) return [];
|
||||
|
||||
const { data, error } = await context.supabase
|
||||
.schema(SCHEMA)
|
||||
.from(TABLE)
|
||||
.select("id, created_at, updated_at")
|
||||
.select("id, snapshot, created_at, updated_at")
|
||||
.order("updated_at", { ascending: false });
|
||||
throwIfDatabaseError(error);
|
||||
return (data ?? []).map((record) => ({
|
||||
id: record.id,
|
||||
createdAt: record.created_at,
|
||||
updatedAt: record.updated_at,
|
||||
scenario: record.snapshot?.scenario ?? null,
|
||||
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);
|
||||
}
|
||||
@@ -61,8 +61,14 @@ describe("server investigation persistence", () => {
|
||||
});
|
||||
|
||||
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({
|
||||
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,
|
||||
});
|
||||
mockGetAuthenticatedUser.mockResolvedValue({ id: "trusted-user" });
|
||||
@@ -71,11 +77,34 @@ describe("server investigation persistence", () => {
|
||||
|
||||
await expect(listInvestigations()).resolves.toEqual([{
|
||||
id: "investigation-1",
|
||||
createdAt: "2026-09-08T00:00:00Z",
|
||||
updatedAt: "2026-09-08T01:00:00Z",
|
||||
scenario: "A situation",
|
||||
updatedAt: "2026-09-08T00:30:00Z",
|
||||
investigationRevision: 3,
|
||||
reportExists: true,
|
||||
reportGeneratedFromRevision: 2,
|
||||
}]);
|
||||
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 });
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user