feat(confidence-engine): v0.60e route investigation identity

Migrate the Investigation page route to own durable investigation identity
via its route [id] segment, passing that ID through to ScenarioForm for
hydration and persistence.

- Remove hardcoded INVESTIGATION_ID constant from page.jsx
- Use params.id as routeId; loadInvestigation(routeId) loads by identity
- Pass investigationId prop into ScenarioForm in both branch paths
- Session restore calls loadInvestigation(investigationId)
- All 4 save call sites include id: investigationId in snapshot
- Missing identified Investigation starts clean (no singleton fallback)
- Legacy singleton is not migrated/fallback-loaded
- Portfolio remains unmigrated; Report remains unmigrated; Restart untouched

Deterministic tests: 34/34 pass (scenario-form-persistence + investigation-storage)
Build: PASS
Live Playwright: all criteria verified at /investigations/v060e-live
This commit is contained in:
2026-09-03 19:02:37 +01:00
parent 827411f254
commit 01e141aa66
3 changed files with 52 additions and 13 deletions
+10 -8
View File
@@ -4,18 +4,18 @@ import React from "react";
import { loadInvestigation } from "@/lib/storage/investigation-storage"; import { loadInvestigation } from "@/lib/storage/investigation-storage";
import ScenarioForm from "@/components/scenario-form"; import ScenarioForm from "@/components/scenario-form";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
const INVESTIGATION_ID = "case-1"; export default function InvestigationPage({ params }) {
export default function InvestigationPage() {
const router = useRouter(); const router = useRouter();
const routeId = typeof params?.id === "string" ? params.id : "";
const [existing, setExisting] = useState(null); const [existing, setExisting] = useState(null);
useEffect(() => { useEffect(() => {
setExisting(loadInvestigation()); if (!routeId) return;
}, []); setExisting(loadInvestigation(routeId));
}, [routeId]);
return ( return (
<main className="mx-auto max-w-[1600px] px-6 py-12"> <main className="mx-auto max-w-[1600px] px-6 py-12">
@@ -37,12 +37,14 @@ export default function InvestigationPage() {
</p> </p>
{existing ? ( {existing ? (
<ScenarioForm <ScenarioForm
investigationId={routeId}
existingSnapshot={existing} existingSnapshot={existing}
onNavigateToReport={() => router.push(`/investigations/${INVESTIGATION_ID}/report`)} onNavigateToReport={() => router.push(`/investigations/${routeId}/report`)}
/> />
) : ( ) : (
<ScenarioForm <ScenarioForm
onNavigateToReport={() => router.push(`/investigations/${INVESTIGATION_ID}/report`)} investigationId={routeId}
onNavigateToReport={() => router.push(`/investigations/${routeId}/report`)}
/> />
)} )}
</main> </main>
+6 -4
View File
@@ -271,7 +271,7 @@ export async function executeEpisodeDone({
return { success: true, nextGraph, synthesisResult }; return { success: true, nextGraph, synthesisResult };
} }
export default function ScenarioForm({ onNavigateToReport }) { export default function ScenarioForm({ investigationId, onNavigateToReport }) {
const [scenario, setScenario] = useState(""); const [scenario, setScenario] = useState("");
const [status, setStatus] = useState("idle"); // idle | loading | error | success const [status, setStatus] = useState("idle"); // idle | loading | error | success
const [result, setResult] = useState(null); const [result, setResult] = useState(null);
@@ -476,6 +476,7 @@ export default function ScenarioForm({ onNavigateToReport }) {
// Trigger autosave to persist the report // Trigger autosave to persist the report
void saveInvestigation({ void saveInvestigation({
id: investigationId,
scenario, scenario,
situationGraph: result.situationGraph, situationGraph: result.situationGraph,
selectedQuestion: result.selectedQuestion, selectedQuestion: result.selectedQuestion,
@@ -543,7 +544,7 @@ export default function ScenarioForm({ 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 = loadInvestigation(); const saved = investigationId ? loadInvestigation(investigationId) : null;
if (!saved) return; if (!saved) return;
const hasGraph = Boolean(saved.situationGraph); const hasGraph = Boolean(saved.situationGraph);
@@ -580,6 +581,7 @@ export default function ScenarioForm({ onNavigateToReport }) {
if (!result?.situationGraph) return; if (!result?.situationGraph) return;
void saveInvestigation({ void saveInvestigation({
id: investigationId,
scenario, scenario,
situationGraph: result.situationGraph, situationGraph: result.situationGraph,
selectedQuestion: result.selectedQuestion, selectedQuestion: result.selectedQuestion,
@@ -678,7 +680,7 @@ export default function ScenarioForm({ 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({ scenario, situationGraph: normalised.situationGraph, selectedQuestion: normalised.selectedQuestion, summary: data.summary ?? null, updatedAt: new Date().toISOString(), focusedContributions, findings: [], investigationReport, investigationRevision: 1 }); saveInvestigation({ 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);
@@ -765,7 +767,7 @@ export default function ScenarioForm({ 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({ scenario, situationGraph: nextGraph, selectedQuestion: normaliseUpdateSelectedQuestion(outcome.selectedQuestion), summary: currentUnderstanding, updatedAt: new Date().toISOString(), focusedContributions, findings: nextFindings, investigationReport, investigationRevision: nextRev }); saveInvestigation({ 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);
+36 -1
View File
@@ -381,7 +381,42 @@ Migrate existing application callers to the identity-aware contract signatures:
**Status:** UI/routes are **not** migrated. All existing callers continue via the singleton compatibility path (they call `saveInvestigation({ ... })` with no explicit second parameter, and their snapshots carry no `id` field). No production caller was modified in this increment. **Status:** UI/routes are **not** migrated. All existing callers continue via the singleton compatibility path (they call `saveInvestigation({ ... })` with no explicit second parameter, and their snapshots carry no `id` field). No production caller was modified in this increment.
**Next restart point:** Caller migration — update application consumers to pass investigation ID through save calls so canonical identity-aware semantics activate for all writes. ## v0.60e — Route-Owned Investigation Identity
**Purpose:** Migrate the Investigation page route to own durable investigation identity via its route `[id]` segment, passing that ID through to ScenarioForm for hydration and persistence without migrating legacy singleton data or changing Portfolio/Report behaviour.
### What was implemented
| File | Change |
|---|---|
| `app/investigations/[id]/page.jsx` | Removed hardcoded `INVESTIGATION_ID = "case-1"` constant; route `[id]` param extracted as `routeId` via `params.id`; `loadInvestigation(routeId)` loads by route identity; `investigationId={routeId}` passed to ScenarioForm in both branch paths; report navigation uses `routeId`. |
| `components/scenario-form.jsx` | Added `investigationId` prop; session restore calls `loadInvestigation(investigationId)` when provided; all 4 save call sites include `id: investigationId` in snapshot (autosave effect, start-case submit, update-case submit, report overview autosave). |
### Contract crossings verified by deterministic test
- **Owning test files:** `tests/storage/scenario-form-persistence.test.js` (11 tests) + `tests/storage/investigation-storage.test.js` (23 tests, including v0.60d canonical identity section)
- All 34 tests pass on first run; no reruns required
### Verified behaviour
- Route ID is passed from `[id]/page.jsx` into ScenarioForm as `investigationId`: **YES**
- Missing identified Investigation (`loadInvestigation("v060e-live")` → null) starts clean: **YES**
- Legacy singleton fallback used: **NO** — no backward-compat load was needed; the route ID was absent from storage
- First meaningful saved snapshot carries route ID: **YES** (all 4 save sites inject `id: investigationId`)
- Subsequent save identity preserved: **YES** (storage layer uses `snapshot.id` as sole identity authority)
- Storage provider changed: **NO** — only `investigation-storage.js` wrapper, already-proved in v0.60d
### Portfolio
Not migrated. `app/page.jsx` retains hardcoded `INVESTIGATION_ID = "case-1"`. Create New navigation to a durable-ID route is later work.
### Report
Not migrated. `app/investigations/[id]/report/page.jsx` untouched. Remains a later caller migration increment.
### Restart
Untouched in this increment. All `clearInvestigation()` calls remain without an id argument (legacy singleton path). If identity-aware restart is needed, the next increment should pass `investigationId` through those clear calls.
## Next implementation boundary ## Next implementation boundary