Compare commits

...
Author SHA1 Message Date
robbond 719a65b3d3 Merge pull request 'Feature/product platform foundation v0.62' (#1) from feature/product-platform-foundation-v0.62 into feature/emergent-unknowns-v0.5
Reviewed-on: #1
2026-09-09 07:58:20 +01:00
robbond e6c78f87fe build(confidence-engine): add production container packaging 2026-09-09 06:35:15 +01:00
robbond d6df1d210e feat(confidence-engine): use server investigation persistence 2026-09-08 19:21:05 +01:00
robbond 6dd447e56a feat(confidence-engine): add authenticated investigation persistence 2026-09-08 17:15:46 +01:00
robbond b949eea831 feat(confidence-engine): define investigation persistence schema 2026-09-08 16:56:07 +01:00
robbond 30bf44f2e5 feat(confidence-engine): establish authenticated product boundary 2026-09-08 16:30:07 +01:00
robbond 1c17452bee feat(confidence-engine): add dark mode 2026-09-08 11:04:49 +01:00
robbond 24d9e466f6 docs(confidence-engine): checkpoint commercially testable product loop 2026-09-08 10:40:21 +01:00
robbond 85b9f4411f fix(confidence-engine): reopen resolved unknowns by graph state 2026-09-08 09:42:46 +01:00
robbond 0b5a38f83d fix(confidence-engine): persist done-for-now episode closure 2026-09-07 18:36:38 +01:00
robbond 7af708159e docs(confidence-engine): record Terra journey boundary fixes 2026-09-07 15:51:18 +01:00
robbond 949a7024b3 fix(confidence-engine): allow no-op episode reconsideration 2026-09-07 15:50:50 +01:00
robbond ae00e70ced fix(confidence-engine): unwrap synthesis provider response 2026-09-07 15:50:50 +01:00
robbond 7548a6af59 fix(confidence-engine): supply synthesis output schema 2026-09-07 13:44:57 +01:00
robbond 3f2e2e05ae fix(confidence-engine): define focused relationship contract 2026-09-07 13:28:38 +01:00
robbond 14630cf6b7 experiment(confidence-engine): trace focused deconstruction failures 2026-09-07 13:08:08 +01:00
55 changed files with 1945 additions and 151 deletions
+37
View File
@@ -0,0 +1,37 @@
node_modules
.next
out
dist
coverage
*.lcov
test-results
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
evaluation-results
provider-debug-results
tests-results
.playwright-mcp/
.evidence-temp/
# Git
.git
.gitignore
# Environment files with secrets (never bake into image)
.env
.env.local
.env.*.local
# Documentation / handoff (not needed for build)
docs
*.md
# IDE
.vscode
.idea
# OS generated files
.DS_Store
Thumbs.db
+6 -9
View File
@@ -1,15 +1,12 @@
# Local Ollama server address
OLLAMA_BASE_URL=http://192.168.x.x:11434
# ── Supabase Auth (public browser configuration only) ────────────────
NEXT_PUBLIC_SUPABASE_URL=https://supabase.rdbcloud.co.uk
NEXT_PUBLIC_SUPABASE_ANON_KEY=replace-with-supabase-anon-key
# Model name (e.g., llama3, mistral, codellama, etc.)
# ── Ollama provider (runtime, server-only) ────────────────────────────
OLLAMA_BASE_URL=http://192.168.x.x:11434
OLLAMA_MODEL=replace-with-model-name
# ── Mock / Demo Mode (UI development only) ──────────────────
# Set to "true" to use pre-recorded scenario fixtures instead of Ollama.
# ── Mock / Demo Mode (UI development only) ────────────────────────────
NEXT_PUBLIC_CONFIDENCE_ENGINE_MOCKS=true
# Mock delay mode: "instant" | "normal" (default, 700ms) | "slow" (2500ms)
NEXT_PUBLIC_CONFIDENCE_MOCK_DELAY=normal
# Scenario to replay: "complete" (jump to end after start) | "error" | "" (default sequential turns)
NEXT_PUBLIC_CONFIDENCE_ENGINE_MOCK_SCENARIO=complete
+45
View File
@@ -0,0 +1,45 @@
# ── Stage 1: Build ─────────────────────────────────────────────────────
FROM node:22-alpine AS builder
WORKDIR /app
ENV NEXT_PUBLIC_SUPABASE_URL="" \
NEXT_PUBLIC_SUPABASE_ANON_KEY=""
COPY package.json package-lock.json* yarn.lock* pnpm-lock.yaml* ./
RUN corepack enable && \
if [ -f pnpm-lock.yaml ]; then \
corepack prepare pnpm@latest --activate; \
pnpm install --frozen-lockfile; \
elif [ -f yarn.lock ]; then \
yarn install --frozen-lockfile; \
else \
npm ci; \
fi
COPY . .
RUN NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL} \
NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY} \
next build
# ── Stage 2: Production runtime ────────────────────────────────────────
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3000 \
HOSTNAME="0.0.0.0"
COPY --from=builder /app/public ./public
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
USER node
EXPOSE 3000
CMD ["node", "server.js"]
+4 -1
View File
@@ -3,8 +3,9 @@ import {
PROMPT_VERSIONS,
DEFAULT_PROMPT_VERSION,
} from "@/lib/analysis";
import { withAuthenticatedApi } from "@/lib/supabase/api-auth.js";
export async function POST(request) {
async function post(request) {
try {
const body = await request.json();
@@ -47,3 +48,5 @@ export async function POST(request) {
);
}
}
export const POST = withAuthenticatedApi(post);
+4 -1
View File
@@ -8,8 +8,9 @@
import { getProvider, getProviderModelName } from "@/lib/llm/provider.js";
import { synthesizeInvestigationOverview } from "@/lib/graph/investigation-overview-synthesis.js";
import { withAuthenticatedApi } from "@/lib/supabase/api-auth.js";
export async function POST(request) {
async function post(request) {
try {
const body = await request.json();
@@ -66,3 +67,5 @@ export async function POST(request) {
);
}
}
export const POST = withAuthenticatedApi(post);
+4 -1
View File
@@ -1,6 +1,7 @@
import { startCase } from "@/lib/graph/orchestrator.js";
import { withAuthenticatedApi } from "@/lib/supabase/api-auth.js";
export async function POST(request) {
async function post(request) {
try {
const body = await request.json();
const result = await startCase(body);
@@ -62,3 +63,5 @@ export async function POST(request) {
);
}
}
export const POST = withAuthenticatedApi(post);
+4 -1
View File
@@ -11,8 +11,9 @@
import { getProvider, getProviderModelName } from "@/lib/llm/provider.js";
import { synthesizeCurrentUnderstanding } from "@/lib/graph/current-understanding-synthesis.js";
import { withAuthenticatedApi } from "@/lib/supabase/api-auth.js";
export async function POST(request) {
async function post(request) {
try {
const body = await request.json();
@@ -69,3 +70,5 @@ export async function POST(request) {
);
}
}
export const POST = withAuthenticatedApi(post);
+12 -2
View File
@@ -2,6 +2,7 @@ import { updateCase, reconsiderCompletedEpisode } from "@/lib/graph/orchestrator
import { applyValidatedProposal } from "@/lib/graph/apply-proposal.js";
import { prepareCompletedEpisode } from "@/lib/graph/episode-preparation.js";
import { updateCaseEpisodeRequestSchema } from "@/lib/graph/schema.js";
import { withAuthenticatedApi } from "@/lib/supabase/api-auth.js";
function mapFailureStatus(result) {
switch (result?.stage) {
@@ -35,7 +36,7 @@ function buildFailureResponse(result) {
};
}
export async function POST(request) {
async function post(request) {
try {
const body = await request.json();
const isEpisodeMode = body?.episodeMode === true;
@@ -89,6 +90,8 @@ export async function POST(request) {
}
}
export const POST = withAuthenticatedApi(post);
/** Server-side completed-episode reconsideration flow. */
async function handleEpisodeMode(situationGraph, body) {
const prepared = prepareCompletedEpisode({
@@ -129,9 +132,16 @@ async function handleEpisodeMode(situationGraph, body) {
);
}
const resolvedNodeIds = new Set(application.updatedSituationGraph.resolvedNodeIds ?? []);
resolvedNodeIds.add(body.targetNodeId);
const updatedSituationGraph = {
...application.updatedSituationGraph,
resolvedNodeIds: [...resolvedNodeIds],
};
return Response.json({
success: true,
updatedSituationGraph: application.updatedSituationGraph,
updatedSituationGraph,
proposal: reasoning.proposal,
}, { status: 200 });
}
@@ -4,10 +4,14 @@ import {
focusedDeconstructJsonSchema,
validateFocusedDeconstructSchema,
} from "@/lib/graph/focused-investigation";
import { withAuthenticatedApi } from "@/lib/supabase/api-auth.js";
export async function POST(request) {
async function post(request) {
let targetNodeId = null;
let startedAt = null;
try {
const body = await request.json();
targetNodeId = body.targetNodeId ?? null;
if (!body.targetNodeId || typeof body.targetNodeId !== "string") {
return Response.json(
@@ -55,20 +59,49 @@ export async function POST(request) {
});
const provider = getProvider();
const startedAt = Date.now();
const modelName = getProviderModelName();
startedAt = Date.now();
console.info("[api/focused-investigation/deconstruct] start", {
targetNodeId,
modelName,
providerMode: process.env.CONFIDENCE_ENGINE_EXPERIMENT_PROVIDER ?? "ollama",
startedAt,
});
const wrapper = await provider.generateReconstruction(
prompt,
getProviderModelName(),
modelName,
focusedDeconstructJsonSchema,
);
const elapsedMs = Date.now() - startedAt;
// Unwrap the semantic deconstruction from the provider envelope.
const deconstruction = wrapper.response;
console.info("[api/focused-investigation/deconstruct] provider success", {
targetNodeId,
elapsedMs,
providerApiPath: wrapper.providerApiPath ?? null,
responsePresent: Boolean(deconstruction),
responseKeys: deconstruction && typeof deconstruction === "object"
? Object.keys(deconstruction)
: [],
});
// Validate schema (required fields present, no graph-mutation fields)
const validationErrors = validateFocusedDeconstructSchema(deconstruction);
console.info("[api/focused-investigation/deconstruct] validation", {
targetNodeId,
schemaValid: validationErrors.length === 0,
responseKeys: deconstruction && typeof deconstruction === "object"
? Object.keys(deconstruction)
: [],
validationErrors,
});
if (validationErrors.length > 0) {
console.info("[api/focused-investigation/deconstruct] end", {
targetNodeId,
status: 502,
elapsedMs,
});
return Response.json(
{
success: false,
@@ -81,7 +114,7 @@ export async function POST(request) {
);
}
return Response.json({
const response = Response.json({
success: true,
targetNodeId: body.targetNodeId,
observations: deconstruction.observations,
@@ -91,10 +124,34 @@ export async function POST(request) {
possibleFollowUpQuestions: deconstruction.possibleFollowUpQuestions,
elapsedMs,
});
console.info("[api/focused-investigation/deconstruct] end", {
targetNodeId,
status: 200,
elapsedMs,
});
return response;
} catch (e) {
const elapsedMs = startedAt == null ? null : Date.now() - startedAt;
console.error("[api/focused-investigation/deconstruct] provider failure", {
targetNodeId,
elapsedMs,
errorName: e?.name ?? "Error",
errorMessage: e?.message ?? "Unknown server error",
statusCode: e?.statusCode ?? e?.status ?? null,
providerApiPath: e?.providerApiPath ?? null,
errorCode: e?.code ?? null,
errorParam: e?.param ?? null,
});
console.info("[api/focused-investigation/deconstruct] end", {
targetNodeId,
status: 500,
elapsedMs,
});
return Response.json(
{ error: e.message || "Unknown server error" },
{ status: 500 },
);
}
}
export const POST = withAuthenticatedApi(post);
@@ -1,6 +1,7 @@
import { formulateQuestionForTarget } from "@/lib/graph/focused-investigation";
import { withAuthenticatedApi } from "@/lib/supabase/api-auth.js";
export async function POST(request) {
async function post(request) {
try {
const body = await request.json();
@@ -50,3 +51,5 @@ export async function POST(request) {
);
}
}
export const POST = withAuthenticatedApi(post);
@@ -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);
+14
View File
@@ -0,0 +1,14 @@
import { loadInvestigation } from "@/lib/storage/server-investigation-persistence.js";
import { withAuthenticatedApi } from "@/lib/supabase/api-auth.js";
async function get(_request, { params }) {
try {
const snapshot = await loadInvestigation(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 GET = withAuthenticatedApi(get);
+29
View File
@@ -0,0 +1,29 @@
import {
listInvestigations,
saveInvestigation,
} from "@/lib/storage/server-investigation-persistence.js";
import { withAuthenticatedApi } from "@/lib/supabase/api-auth.js";
async function get() {
try {
return Response.json({ investigations: await listInvestigations() });
} catch {
return Response.json({ error: "Investigation persistence request failed" }, { status: 500 });
}
}
async function post(request) {
try {
const { id, snapshot } = await request.json();
if (!id || !snapshot || typeof snapshot !== "object") {
return Response.json({ error: "An investigation id and snapshot are required" }, { status: 400 });
}
const savedSnapshot = await saveInvestigation(snapshot, id);
return Response.json({ snapshot: savedSnapshot }, { status: 200 });
} catch {
return Response.json({ error: "Investigation persistence request failed" }, { status: 500 });
}
}
export const GET = withAuthenticatedApi(get);
export const POST = withAuthenticatedApi(post);
+26
View File
@@ -0,0 +1,26 @@
import { createServerClient } from "@supabase/ssr";
import { NextResponse } from "next/server";
export async function GET(request) {
const requestUrl = new URL(request.url);
const code = requestUrl.searchParams.get("code");
const response = NextResponse.redirect(new URL("/", requestUrl.origin));
if (code) {
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
{
cookies: {
getAll: () => request.cookies.getAll(),
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => response.cookies.set(name, value, options));
},
},
},
);
await supabase.auth.exchangeCodeForSession(code);
}
return response;
}
+67 -1
View File
@@ -2,6 +2,72 @@
@tailwind components;
@tailwind utilities;
:root {
--ce-page: #f9fafb;
--ce-surface: #ffffff;
--ce-surface-muted: #f9fafb;
--ce-surface-elevated: #ffffff;
--ce-text: #111827;
--ce-text-muted: #6b7280;
--ce-border: #d1d5db;
--ce-teal: #0f766e;
--ce-teal-surface: #f0fdfa;
--ce-focus: #14b8a6;
--ce-skeleton: #e5e7eb;
}
html[data-theme="dark"] {
--ce-page: #172128;
--ce-surface: #202c34;
--ce-surface-muted: #1b262e;
--ce-surface-elevated: #293740;
--ce-text: #edf2f3;
--ce-text-muted: #b4c0c5;
--ce-border: #40515a;
--ce-teal: #62d3c5;
--ce-teal-surface: #203a3c;
--ce-focus: #78ded2;
--ce-skeleton: #3a4a53;
}
html[data-theme="dark"] body { background-color: var(--ce-page) !important; color: var(--ce-text) !important; }
html[data-theme="dark"] .app-chrome { background-color: var(--ce-surface-muted); border-color: var(--ce-border); }
html[data-theme="dark"] .theme-toggle { background-color: var(--ce-surface-elevated); border-color: var(--ce-border); color: var(--ce-text); }
html[data-theme="dark"] .theme-toggle:hover { background-color: #33444d; }
html[data-theme="dark"] .bg-white,
html[data-theme="dark"] [class*="bg-white"] { background-color: var(--ce-surface) !important; }
html[data-theme="dark"] [class*="bg-gray-50"],
html[data-theme="dark"] [class*="bg-gray-100"] { background-color: var(--ce-surface-muted) !important; }
html[data-theme="dark"] [class*="bg-gradient-to"] { background-image: none !important; background-color: var(--ce-surface) !important; }
html[data-theme="dark"] [class*="bg-teal-50"] { background-color: var(--ce-teal-surface) !important; }
html[data-theme="dark"] [class*="bg-amber-50"],
html[data-theme="dark"] [class*="bg-orange-50"],
html[data-theme="dark"] [class*="bg-yellow-50"] { background-color: #3a3324 !important; }
html[data-theme="dark"] [class*="bg-green-50"] { background-color: #20392f !important; }
html[data-theme="dark"] [class*="bg-blue-50"] { background-color: #243540 !important; }
html[data-theme="dark"] [class*="border-gray"],
html[data-theme="dark"] [class*="border-teal"],
html[data-theme="dark"] [class*="border-amber"],
html[data-theme="dark"] [class*="border-orange"],
html[data-theme="dark"] [class*="border-green"],
html[data-theme="dark"] [class*="border-blue"] { border-color: var(--ce-border) !important; }
html[data-theme="dark"] :is(.text-gray-900, .text-gray-800, .text-gray-700, .text-gray-600) { color: var(--ce-text) !important; }
html[data-theme="dark"] :is(.text-gray-500, .text-gray-400) { color: var(--ce-text-muted) !important; }
html[data-theme="dark"] :is(.text-teal-700, .text-teal-800) { color: var(--ce-teal) !important; }
html[data-theme="dark"] :is(.text-amber-700, .text-amber-800, .text-orange-700, .text-orange-800, .text-yellow-700, .text-yellow-800) { color: #f2c879 !important; }
html[data-theme="dark"] :is(.text-green-700, .text-green-800) { color: #8bd8a8 !important; }
html[data-theme="dark"] input,
html[data-theme="dark"] textarea,
html[data-theme="dark"] select { background-color: var(--ce-surface-elevated); color: var(--ce-text); border-color: var(--ce-border); }
html[data-theme="dark"] input::placeholder,
html[data-theme="dark"] textarea::placeholder { color: #94a3ab; }
html[data-theme="dark"] details { background-color: var(--ce-surface-muted) !important; }
html[data-theme="dark"] button:focus-visible,
html[data-theme="dark"] a:focus-visible,
html[data-theme="dark"] input:focus-visible,
html[data-theme="dark"] textarea:focus-visible,
html[data-theme="dark"] select:focus-visible { outline: 2px solid var(--ce-focus); outline-offset: 2px; }
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
@@ -41,7 +107,7 @@
.cu-skeleton-line {
height: 16px;
border-radius: 8px;
background-color: #e5e7eb;
background-color: var(--ce-skeleton);
position: relative;
overflow: hidden;
}
+16 -3
View File
@@ -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}
+19 -6
View File
@@ -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 };
});
}
+16
View File
@@ -1,4 +1,6 @@
import "./globals.css";
import ThemeToggle from "@/components/theme-toggle";
import LogoutButton from "@/components/logout-button";
export const metadata = {
title: "Confidence Engine",
@@ -9,6 +11,20 @@ export default function RootLayout({ children }) {
return (
<html lang="en">
<body className="min-h-screen bg-gray-50 text-gray-900">
<script
dangerouslySetInnerHTML={{
__html: `try { const saved = localStorage.getItem('confidence-engine-theme'); const theme = saved === 'dark' || saved === 'light' ? saved : (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); document.documentElement.dataset.theme = theme; document.documentElement.style.colorScheme = theme; } catch (_) {}`,
}}
/>
<header className="app-chrome border-b border-gray-200/80">
<div className="mx-auto flex max-w-[1600px] items-center justify-between px-6 py-3">
<span className="text-sm font-semibold tracking-wide text-teal-700">Confidence Engine</span>
<div className="flex items-center gap-2">
<ThemeToggle />
<LogoutButton />
</div>
</div>
</header>
{children}
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
import LoginForm from "@/components/login-form";
export default function LoginPage() {
return <LoginForm />;
}
+28 -5
View File
@@ -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
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { useState } from "react";
import { createClient, magicLinkRedirectTo } from "@/lib/supabase/browser.js";
export default function LoginForm() {
const [email, setEmail] = useState("");
const [status, setStatus] = useState("idle");
const [error, setError] = useState("");
async function sendMagicLink(event) {
event.preventDefault();
setStatus("pending");
setError("");
const { error: signInError } = await createClient().auth.signInWithOtp({
email,
options: { emailRedirectTo: magicLinkRedirectTo(window.location.origin) },
});
if (signInError) {
setError("We could not send a magic link. Please try again.");
setStatus("idle");
return;
}
setStatus("sent");
}
return (
<main className="mx-auto flex min-h-[calc(100vh-57px)] max-w-[640px] items-center px-6 py-16">
<section className="w-full rounded-xl border-[2.5px] border-teal-300/70 bg-gradient-to-b from-teal-50/60 to-white px-8 py-9 shadow-sm">
<p className="mb-3 text-[11px] font-bold uppercase tracking-[.18em] text-teal-700/70">Welcome</p>
<h1 className="text-3xl font-bold tracking-tight">Confidence Engine</h1>
<p className="mt-3 text-sm leading-relaxed text-gray-600">Enter your email and we will send you a secure link to continue.</p>
<form className="mt-7 space-y-4" onSubmit={sendMagicLink}>
<label className="block text-sm font-medium text-gray-700" htmlFor="email">Email address</label>
<input id="email" type="email" autoComplete="email" required value={email} onChange={(event) => setEmail(event.target.value)} className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:border-teal-600 focus:outline-none focus:ring-2 focus:ring-teal-400" />
<button type="submit" disabled={status === "pending"} className="rounded-lg bg-teal-700 px-5 py-2.5 text-sm font-medium text-white transition hover:bg-teal-600 disabled:cursor-wait disabled:opacity-60">
{status === "pending" ? "Sending magic link…" : "Send magic link"}
</button>
</form>
{status === "sent" && <p className="mt-5 text-sm text-green-700" role="status">Check your email for your magic link.</p>}
{error && <p className="mt-5 text-sm text-red-700" role="alert">{error}</p>}
</section>
</main>
);
}
+61
View File
@@ -0,0 +1,61 @@
"use client";
import { useEffect, useState } from "react";
import { createClient } from "@/lib/supabase/browser.js";
export default function LogoutButton() {
const [visible, setVisible] = useState(false);
const [error, setError] = useState("");
const [loggingOut, setLoggingOut] = useState(false);
useEffect(() => {
const client = createClient();
async function checkSession() {
const { data: { session } } = await client.auth.getSession();
setVisible(!!session);
}
checkSession();
const { data: { subscription } } = client.auth.onAuthStateChange((_event, session) => {
setVisible(!!session);
});
return () => subscription.unsubscribe();
}, []);
async function handleLogout() {
setLoggingOut(true);
setError("");
try {
const client = createClient();
await client.auth.signOut();
window.location.href = "/login";
} catch (err) {
setError("Could not logout. Try again.");
setLoggingOut(false);
}
}
if (!visible) return null;
return (
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleLogout}
disabled={loggingOut}
aria-label="Logout"
className="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-600 transition hover:bg-gray-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-500 focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-60"
>
{loggingOut ? "Logging out…" : "Logout"}
</button>
{error && (
<p className="text-xs text-red-600" role="alert">
{error}
</p>
)}
</div>
);
}
+30 -12
View File
@@ -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");
+47
View File
@@ -0,0 +1,47 @@
"use client";
import { useEffect, useState } from "react";
import {
readThemePreference,
resolveTheme,
saveThemePreference,
toggleTheme,
} from "@/lib/theme-preference.js";
function applyTheme(theme) {
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
}
export default function ThemeToggle() {
const [theme, setTheme] = useState("light");
useEffect(() => {
const nextTheme = resolveTheme({
savedTheme: readThemePreference(window.localStorage),
systemPrefersDark: window.matchMedia?.("(prefers-color-scheme: dark)").matches,
});
setTheme(nextTheme);
applyTheme(nextTheme);
}, []);
const switchTheme = () => {
const nextTheme = toggleTheme(theme);
setTheme(nextTheme);
saveThemePreference(nextTheme, window.localStorage);
applyTheme(nextTheme);
};
const isDark = theme === "dark";
return (
<button
type="button"
onClick={switchTheme}
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
className="theme-toggle rounded-lg border px-3 py-2 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-500 focus-visible:ring-offset-2"
>
<span aria-hidden="true" className="mr-1.5">{isDark ? "☀" : "☾"}</span>
{isDark ? "Light mode" : "Dark mode"}
</button>
);
}
@@ -0,0 +1,60 @@
# Confidence Engine Product Checkpoint — 2026-09-08
## Purpose
Record the evidence boundary at which Confidence Engine moves from proving isolated reasoning mechanics toward commercially testing the working product loop. This is a current checkpoint, not a claim of universal provider or production validation.
## PROVEN / OBSERVED
### Working product loop
```text
messy scenario → initial reconstruction / SituationGraph → Current Understanding
→ Open Questions → user selects a question → focused multi-turn investigation
→ canonical Findings and accumulated Contributions → Done for now
→ authoritative clarification → completed-episode reconsideration
→ Current Understanding resynthesis → user chooses what to investigate next
```
When all Open Questions are clarified, the direction is to surface a report derived from accumulated case understanding. It is not a recommendation engine and must not claim that the user is ready, sufficiently informed, confident, or should act.
The engine facilitates the user's reasoning; it does not steer it. The user owns question selection, depth, Done-for-now, re-opening, sufficiency, confidence, and eventual action.
### Multi-turn, closure, and persistence
- A manufacturing supplier thread retained one existing plus five additional answer turns: six learned Contributions in total.
- After the sixth contribution, Done for now clarified the supplier question, removed it from Open Questions, preserved the investigation, and resynthesised Current Understanding without retrying or selecting another question.
- A completed episode may legitimately have no semantic graph mutation. Done for now remains user-owned; the server makes the target authoritative in graph resolution state without fabricating semantic change.
- Persisted investigations resume Current Understanding, Open and clarified Questions, focused Contributions, Findings, and graph identity/provenance. Re-open preserves learned thread state.
### Re-open graph-state fix
Canonical persisted Done-for-now state is an `unknown` node with `status: "unknown"` whose ID is in `resolvedNodeIds`. The former Re-open check required `status: "resolved"`, silently no-oping across both Playwright Chrome and normal Chrome persisted state. Commit `85b9f44` uses authoritative resolution state for unknown nodes.
Deterministic evidence: `npx vitest run tests/graph/reopen-resolved-unknown.test.js --environment=node` — 1 file, 14/14 tests passed. Live evidence: supplier `resolvedNodeIds` changed from `["n9joe0e"]` to `[]`, and the supplier question visibly returned to Open Questions in both browsers.
### Provider position and measured Terra journeys
- Available routes: OpenAI / GPT-5.6 Terra and Ollama / network Qwen. Terra has shown good-enough semantic behavior on tested reasoning paths; neither route is universally validated.
- Clean shallow manufacturing journey (start → supplier question → one answer → Done for now → refreshed Current Understanding): 6 OpenAI requests and $0.12. Observed browser timings: `/start` 40.04s; formulate 0.56s; deconstruct 3.49s; synthesis 3.67s; `/update` 21.50s; synthesis 4.59s.
- Extending the same supplier thread from one to six Contributions required five additional answers and one Done-for-now completion, with no retries, other questions, or new investigation: +10 requests and +$0.05, moving the dashboard to 16 requests and $0.17. Current dashboard totals were 37.862K input, 6.822K output, 44.684K cumulative tokens. Earlier token figures lack a confirmed input/output split and are not total-token evidence.
## PROVISIONAL / EXTRAPOLATED
With five Open Questions remaining in the representative manufacturing scenario, a six-question investigation of broadly similar depth may plausibly cost $0.50$0.80 in Terra inference, with ~$0.60 as a working midpoint. This is an extrapolation, not a validated production unit cost; replace it with a measured full-scenario run.
Inference cost is not yet the evident commercial constraint. Latency is more conspicuous: graph-level `/start` is about 40s and observed `/update` runs are about 2040s, while focused deconstruction and synthesis are commonly low-single-digit seconds. The likely optimisation target is time-to-first-value, subject to measurement.
## KNOWN BUT NOT CURRENT WORK
- Measure the operations dominating `/start` and `/update`, and whether each must block the visible transition. Do not assume initial reconstruction alone is the issue because `/update` has comparable latency.
- Turns 36 revisited whether unit-level records could connect defects/materials to a supplier after evidence repeatedly established those records were unavailable. This is a future evidence-boundary-exhaustion question, not an instruction for the engine to stop or steer the user.
- Investigation-turn allowances may become a commercial entitlement mechanism, per investigation or monthly. They must never imply epistemic sufficiency or tell a user to stop.
## NEXT STRATEGIC DIRECTION
The immediate question is no longer whether the fundamental reasoning architecture can work at all. Prioritise complete realistic scenarios, trust-critical failures encountered in real use, a commercially testable report experience, a small realistic end-to-end set, prospective-user testing, repeat use, willingness to pay, and what users value. Do not use this transition to ignore genuine trust-critical defects or to return to theoretical perfection work before user-value evidence.
## Development discipline for bounded live work
A failed prescribed UI step is evidence, not permission to explore. Use exact semantic `page.getByRole(...)` locators; snapshot refs are observational only. Do not use selector fallbacks, navigate/restart/refresh without instruction, substitute questions, or retry model-backed actions. Stop at the first unexpected state and classify PRODUCT FAILURE or APPARATUS FAILURE. When a persisted browser state is manually positioned, do not navigate away. Define explicit action budgets for live cost experiments.
+60 -45
View File
@@ -3,10 +3,44 @@
> **Role:** Concise operational snapshot for resuming work today. Not a historical diary.
> The design evolution archive index at `docs/design-evolution/README.md` provides progressive loading of experiment history; load the relevant chapter only when a specific historical question requires it.
## v0.62d production Docker packaging established
- `Dockerfile` — minimal multi-stage Alpine build (Node 22), Next.js standalone output mode
- `.dockerignore` — excludes dev artefacts, secrets, docs from build context
- `next.config.mjs` — added `output: 'standalone'` (required for lean production container)
- `.env.example` — reorganized: Supabase → Ollama → Mock sections; Ollama vars now tracked as deployment-relevant
- **npm production build: PASS** ✓
- **Docker image build: BLOCKED** — no Docker runtime on development machine (apparatus limitation, not product defect)
- Production container starts / `/api/health` smoke test: pending Docker runtime availability
- No persistent application volume required
- Supabase remains external, Ollama remains private and server-reachable
- Public `NEXT_PUBLIC_*` variables may require build-time injection via `--build-arg` as established by the implementation (baked into browser bundle)
- **No deployment performed yet**
## CURRENT MVP DIRECTION
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 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.62c)
- 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.
Do not resume:
- repeated-same-input reconstruction experiments
- Qwen/Terra reconstruction comparison
@@ -17,15 +51,9 @@ Do not resume:
unless new end-to-end user-flow evidence reopens one of those boundaries.
**Immediate next evidence question:**
**Immediate strategic evidence question:**
Measure one real browser investigation configured for OpenAI/Terra across every LLM stage, including user-visible latency, call sequence, and cost.
If YES, the next live experiment is one timed/costed OpenAI UI investigation measuring:
- user-visible latency
- OpenAI call count
- token usage where available
- approximate cost per investigation
Does the complete investigation process leave real people materially clearer about genuinely difficult situations, repeatedly enough that they will pay to use it? Use measured realistic scenarios and bounded live action budgets; the September 8 checkpoint records the current Terra cost/latency evidence.
## Server-owned UI journey provider experiment
@@ -39,41 +67,26 @@ If YES, the next live experiment is one timed/costed OpenAI UI investigation mea
- The next live run exposed incomplete recursive strict projection: OpenAI rejected `relationships.items` because it lacked `additionalProperties: false`. The projector now recognizes every `type: "object"` node, including property-less objects in array items, and recursively enforces strict object schemas while preserving initial-reconstruction optionality/nullability behavior.
- The latest Terra request then exposed an inconsistent root `properties`/`required` contract. The projector now derives `required` after projection from the surviving property keys, and recursive tests verify `properties`, `required`, and `additionalProperties` consistency. Property-less object strictness and initial-reconstruction transport behavior remain preserved.
- Current deterministic final-fetch schema remains internally valid, yet the live rejection contradicts it. `CONFIDENCE_ENGINE_EXPERIMENT_TRACE_OPENAI_SCHEMA=1` now emits one safe, server-side structural summary immediately before the OpenAI fetch—no prompt, answer, request body, secret, or model output.
- Provider suite passes with zero live calls. Next boundary: one fresh focused UI submission with the OpenAI provider and schema-trace flags enabled; capture the single trace and OpenAI response, with no Retry.
- The focused-deconstruction route now emits complementary safe server diagnostics for start, provider success/failure, focused validation, and end status; the OpenAI schema trace remains provider-owned. No user content or secrets are logged.
- Canonical focused relationship items are now strict `{ from, to, type }`: all required non-empty strings, free-text `type`, and no `rationale`; `relationships` remains required and may be `[]`. Schema, prompt field names, and validator align; the stale rationale-bearing test fixture was corrected.
- Live Terra focused deconstruction now passes through the real UI, but the following Current Understanding synthesis exposed a separate caller/schema mismatch: its prompt and validator require `{ currentUnderstanding }` while the provider received the default initial-reconstruction schema.
- Terra synthesis now supplies its own output schema and unwraps `providerResult.response` before validation; deterministic synthesis coverage is 62/62 PASS and the saved isolated Terra synthesis POST returned HTTP 200.
- Real Terra completed-episode reconsideration can legitimately return no additional meaningful graph change. Completed episodes now tolerate only that exact compatibility outcome; ordinary no-op updates and invalid completed-episode proposals remain rejected, without fake graph mutation or new next-question steering.
- The full apply-proposal owner suite remains known-red in independent pre-existing 60B.43 tests, so the changed Done-for-now boundary was verified through exact isolated owner tests. Zero live calls occurred during closeout. Next boundary: reuse the existing investigation and click Done for now once, observing cases/update and subsequent synthesis.
- Live UI proved Done for now briefly clarified the question, then server graph replacement reopened it: the client sends `preDoneGraph`, and the server previously had no deterministic closure owner. Episode-mode update now adds the selected `targetNodeId` to `resolvedNodeIds` only after successful semantic application; semantic no-ops and meaningful mutations remain valid, while failed episodes do not resolve the target and ordinary updates are unchanged. Zero live calls occurred during implementation. Next boundary: one live Done-for-now check on the existing investigation.
## 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
@@ -99,7 +112,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.
@@ -120,12 +133,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
@@ -151,6 +164,7 @@ RAW USER EVIDENCE
- If a prescribed semantic locator cannot find its control → STOP. No fallback to CSS/XPath/DOM traversal.
- Live freeze: once Playwright verification begins, no production file edits until evidence is captured.
- **Tests are instruments, not product truth.** At first deterministic failure: classify PRODUCT vs APPARATUS, then stop.
- A failed prescribed UI step is evidence, not permission to explore: semantic `page.getByRole(...)` locators only; do not substitute actions, retry model-backed steps, or navigate away from a manually positioned persisted state. Define action budgets for live cost experiments.
## Current genuinely open boundaries
@@ -186,6 +200,7 @@ These results are documented as **historical experiment evidence**. The v0.61 li
| Methodology / RTO axioms | `docs/current-working-principles.md` §0 (A1A12) |
| Architecture guardrails | `.claude/architecture-guardrails.md` |
| Task routing by work type | `docs/task-context-packs.md` |
| September 8 product/economics checkpoint | `docs/confidence-engine-product-checkpoint-2026-09-08.md` |
| Full experiment history (specific) | `docs/design-evolution/README.md` → relevant chapter |
Consult `docs/current-project-state.md` for broader project context and passive classifier status.
+29 -5
View File
@@ -1,5 +1,19 @@
# Current Project State — Confidence Engine
## v0.62d Production Docker Packaging
- `Dockerfile` created: multi-stage Alpine build (Node 22), Next.js standalone output, configurable port 3000, health-check boundary via `/api/health`
- `.dockerignore` created: excludes `node_modules`, `.next`, secrets (`env.*.local`), docs, IDE, OS artefacts
- `next.config.mjs`: added `output: 'standalone'` for lean production container (only config change required)
- `.env.example`: reorganized; Ollama variables now tracked as deployment-relevant env vars
- npm production build: PASS ✓
- Docker image build: blocked — no Docker runtime on development machine (apparatus limitation, not product defect)
- Production container start / `/api/health` smoke test: pending Docker availability
- No persistent application volume required
- Supabase remains external; Ollama remains private and server-reachable
- Public `NEXT_PUBLIC_*` variables may require build-time injection via `--build-arg` (baked into browser bundle)
- **No deployment performed**
> Created by Experiment 27. This document is the starting point for any fresh session working on the Confidence Engine. Read this first, then follow the routing table below to task-specific references.
## 1. What the Confidence Engine Is
@@ -34,7 +48,17 @@ 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.
The user controls which question to investigate, 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.
**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.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.
## September 8, 2026 Product Checkpoint
`docs/confidence-engine-product-checkpoint-2026-09-08.md` is the current checkpoint for the established working loop, multi-turn supplier evidence, Done-for-now/Re-open graph-state behavior, persistence, provider position, measured Terra economics, and the move toward commercially testing realistic end-to-end use. It distinguishes proven observations from provisional cost extrapolation and known future questions.
For the current stage, the primary question is increasingly whether this process leaves real people materially clearer about difficult situations, repeatedly enough that they will pay to use it. This does not weaken the invariant that the user owns investigation choice, depth, closure, reopening, sufficiency, confidence, and action; nor does it excuse trust-critical defects.
## 3. Current Engine Capabilities
@@ -69,11 +93,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
+10 -1
View File
@@ -4089,7 +4089,16 @@ export async function applyValidatedProposal({
);
if (!proposalGraphValidation.valid) {
proposalCompatibilityErrors.push(...proposalGraphValidation.errors);
const acceptsCompletedEpisodeNoOp = evidenceContext?.isCompletedEpisode === true;
proposalCompatibilityErrors.push(
...proposalGraphValidation.errors.filter(
(error) =>
!(
acceptsCompletedEpisodeNoOp &&
error === "Update contains no meaningful change"
),
),
);
}
const existingEdgeIds = new Set(situationGraph.edges.map((edge) => edge.id));
+14 -4
View File
@@ -186,6 +186,15 @@ export const synthesisResponseSchema = z.object({
.min(1, "currentUnderstanding must be a non-empty string"),
});
export const synthesisOutputSchema = {
type: "object",
properties: {
currentUnderstanding: { type: "string" },
},
required: ["currentUnderstanding"],
additionalProperties: false,
};
/** Validate raw provider output against synthesis response schema */
export function validateSynthesisResponse(raw) {
if (raw == null) {
@@ -270,11 +279,12 @@ export async function synthesizeCurrentUnderstanding(
modelName = process.env.OLLAMA_MODEL ?? null;
}
let rawResponse;
let providerResult;
try {
rawResponse = await provider.generateReconstruction(
providerResult = await provider.generateReconstruction(
prompt,
modelName
modelName,
synthesisOutputSchema,
);
} catch (error) {
const err = new Error(error.message ?? "Synthesis provider call failed");
@@ -283,7 +293,7 @@ export async function synthesizeCurrentUnderstanding(
}
// 5. Validate response
const validated = validateSynthesisResponse(rawResponse);
const validated = validateSynthesisResponse(providerResult.response);
if (!validated.valid) {
const err = new Error(`Synthesis validation failed: ${validated.error}`);
err.statusCode = 502;
+38 -2
View File
@@ -16,7 +16,19 @@ export const focusedDeconstructJsonSchema = {
observations: { type: "array", items: { type: "string" } },
uncertainties: { type: "array", items: { type: "string" } },
assumptions: { type: "array", items: { type: "string" } },
relationships: { type: "array", items: { type: "object" } },
relationships: {
type: "array",
items: {
type: "object",
properties: {
from: { type: "string" },
to: { type: "string" },
type: { type: "string" },
},
required: ["from", "to", "type"],
additionalProperties: false,
},
},
possibleFollowUpQuestions: { type: "array", items: { type: "string" } },
},
required: FOCUSED_ANSWER_SCHEMA_FIELDS,
@@ -115,7 +127,7 @@ Field rules (semantic contract):
- assumptions: what unstated proposition does the user's answer itself rely upon for it to make sense? Include only when such a proposition is genuinely attributable to the user's reasoning. The boundary is narrow: attribute only propositions that the user's answer would cease to make sense if they were false. Do NOT import plausible interpretations from the wider investigation context, scenario framing, domain relevance, strategic implications, or model-generated analysis into this field — those belong in uncertainties, relationships (where permitted), or possibleFollowUpQuestions. Do NOT connect a factual statement the user makes to a broader capability or constraint concept unless the user explicitly links them. Example: answering "I only have bank account access" to a question about delegation constraints does NOT assume that "delegation feasibility is contingent upon banking access" — it only states a fact about access, and connecting that fact to delegation feasibility is your own scenario-level inference, not a user-held assumption. If the user's answer does not contain or rely upon an identifiable assumption, return assumptions: []. Do NOT require verbatim copying from the user's answer; paraphrasing is allowed only when the reasoning genuinely relies on it.
Answer-dependence test: Only attribute an assumption if the user's answer needs that proposition to make sense. If the proposition could be false and the user's answer would still make complete sense, do not attribute it. One observed success in a single concrete example does NOT by itself establish a general rule about competence, readiness, training, safety, transferability, or similar tasks across other work. Do not generalise from one successful example into a broader capability/readiness rule unless the user explicitly or implicitly relies on that broader proposition.
- relationships: must connect two distinct propositions that the user's answer itself links. Do not create a relationship by merely restating, reformatting, or relabelling an observation. Co-mentioned facts do not themselves create a relationship. Tentative, speculative, or conditional language must not be promoted into an established relationship. If the answer does not directly establish a relationship, return relationships: [].
- relationships: each non-empty item must be { "from": "first proposition", "to": "second distinct proposition", "type": "concise free-text relationship label" }. It must connect two distinct propositions that the user's answer itself links. Do not create a relationship by merely restating, reformatting, or relabelling an observation. Co-mentioned facts do not themselves create a relationship. Tentative, speculative, or conditional language must not be promoted into an established relationship. If the answer does not directly establish a relationship, return relationships: [].
- possibleFollowUpQuestions: must be a JSON array containing exactly one string — your single best follow-up question. Example shape: ["one question"]. This question must directly investigate the single uncertainty returned in uncertainties (uncertainties[0] → possibleFollowUpQuestions[0]): one unresolved proposition mapped to one question designed to clarify it. The question must not introduce a second unresolved issue, must not broaden beyond the uncertainty it is meant to resolve, and must not contain more than one investigative step. Do not provide alternatives, a roadmap, or questions that belong after this one has been answered. A later question must be generated only after the current question has been answered and deconstructed. Do not ask about consequences, expansion, requirements, interventions, or other branches until the immediate unresolved relationship has been clarified. Those may become later questions after new evidence is obtained. Ask only what the Engine has earned the right to ask now. Each epistemic step waits its turn — do not combine steps that should happen in sequence across multiple turns: one question that investigates one thing only, never a bundle of future reasoning joined together. Before formulating, check whether the question tests a proposition against the current epistemic state: if an explanation, deficit, dependency, cause, intervention, recommendation, or solution has not been established by prior evidence, phrase the question so it tests whether that proposition is true rather than assuming it — verify the unresolved fact before seeking remedy. Prefer questions that identify what remains unknown, distinguish competing explanations, test whether a suspected factor actually matters, clarify scope, or identify what evidence would change the investigation. Do not jump to implementation details unless the answer has already established that intervention as the relevant next issue. Use ordinary language that a capable person with no specialist vocabulary can understand immediately. If the question needs abstract phrases, management jargon, specialist terminology, or several concepts joined together to express it, break the reasoning down again before returning it. Simple wording of an over-composed idea is still a failure: first ask "what is the smallest thing we actually do not know yet?" then express that one thing simply.
- cross-field ownership: preserve who or what owns each proposition. When a statement expresses the user's comfort, willingness, threshold, belief, uncertainty, preference, or judgement, keep it attached to that stance — do not elevate it into an objective requirement, capability fact, or situational constraint.
@@ -150,6 +162,30 @@ export function validateFocusedDeconstructSchema(result) {
}
}
if (!Array.isArray(result.relationships)) {
errors.push("relationships must be an array");
} else {
result.relationships.forEach((relationship, index) => {
if (!relationship || typeof relationship !== "object" || Array.isArray(relationship)) {
errors.push(`relationships[${index}] must be an object`);
return;
}
const keys = Object.keys(relationship);
for (const field of ["from", "to", "type"]) {
if (!(field in relationship)) {
errors.push(`relationships[${index}] missing required field: ${field}`);
} else if (typeof relationship[field] !== "string" || relationship[field].trim().length === 0) {
errors.push(`relationships[${index}].${field} must be a non-empty string`);
}
}
for (const field of keys) {
if (!["from", "to", "type"].includes(field)) {
errors.push(`relationships[${index}] contains unknown field: ${field}`);
}
}
});
}
return errors;
}
+6 -3
View File
@@ -1,11 +1,12 @@
/**
* Deterministically reopen a resolved unknown node in a SituationGraph.
*
* Transition: node.status "resolved" → "unknown", and removes the node's ID
* Transition: removes the node's ID from resolvedNodeIds and establishes
* node.status "unknown".
* from resolvedNodeIds. This reverses canonical resolution so the question
* reappears among Open Questions for further investigation.
*
* Idempotent — if the target is not a currently-resolved unknown, returns the
* Idempotent — if the target is not an unknown marked resolved in graph state, returns the
* original graph unchanged (no mutation). Does NOT create nodes, delete edges,
* or touch contributions/findings/historical evidence.
*/
@@ -17,7 +18,9 @@ export function reopenResolvedUnknown(situationGraph, nodeId) {
if (nodeIndex === -1) return situationGraph;
const node = situationGraph.nodes[nodeIndex];
if (node.kind !== "unknown" || node.status !== "resolved") return situationGraph;
if (node.kind !== "unknown" || !situationGraph.resolvedNodeIds?.includes(nodeId)) {
return situationGraph;
}
const newNode = { ...node, status: "unknown" };
const newNodes = [...situationGraph.nodes];
+63 -23
View File
@@ -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);
}
+3 -11
View File
@@ -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 */ }
}
+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,
};
}
@@ -0,0 +1,76 @@
import {
createServerSupabaseClient,
getAuthenticatedUser,
} from "@/lib/supabase/server.js";
import { restartSnapshot } from "./restart-investigation.js";
const SCHEMA = "confidence_engine";
const TABLE = "investigations";
async function getAuthenticatedPersistenceContext() {
const user = await getAuthenticatedUser();
if (!user) return null;
return { user, supabase: createServerSupabaseClient() };
}
function throwIfDatabaseError(error) {
if (error) throw new Error("Investigation persistence request failed");
}
export async function saveInvestigation(snapshot, id = snapshot?.id) {
if (!snapshot || typeof snapshot !== "object" || !id) {
throw new Error("Investigation snapshot and id are required");
}
const context = await getAuthenticatedPersistenceContext();
if (!context) return null;
const { data, error } = await context.supabase
.schema(SCHEMA)
.from(TABLE)
.upsert({ id, user_id: context.user.id, snapshot }, { onConflict: "id" })
.select("id, snapshot, created_at, updated_at")
.single();
throwIfDatabaseError(error);
return data?.snapshot ?? null;
}
export async function loadInvestigation(id) {
if (!id) return null;
const context = await getAuthenticatedPersistenceContext();
if (!context) return null;
const { data, error } = await context.supabase
.schema(SCHEMA)
.from(TABLE)
.select("snapshot")
.eq("id", id)
.maybeSingle();
throwIfDatabaseError(error);
return data?.snapshot ?? null;
}
export async function listInvestigations() {
const context = await getAuthenticatedPersistenceContext();
if (!context) return [];
const { data, error } = await context.supabase
.schema(SCHEMA)
.from(TABLE)
.select("id, snapshot, created_at, updated_at")
.order("updated_at", { ascending: false });
throwIfDatabaseError(error);
return (data ?? []).map((record) => ({
id: record.id,
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);
}
+13
View File
@@ -0,0 +1,13 @@
import { getAuthenticatedUser } from "@/lib/supabase/server.js";
export function unauthorizedResponse() {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
export function withAuthenticatedApi(handler) {
return async function authenticatedApiHandler(request, context) {
const user = await getAuthenticatedUser();
if (!user) return unauthorizedResponse();
return handler(request, context);
};
}
+14
View File
@@ -0,0 +1,14 @@
"use client";
import { createBrowserClient } from "@supabase/ssr";
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
);
}
export function magicLinkRedirectTo(origin) {
return `${origin}/auth/callback`;
}
+33
View File
@@ -0,0 +1,33 @@
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
function getSupabaseConfig() {
return {
url: process.env.NEXT_PUBLIC_SUPABASE_URL,
key: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
};
}
export function createServerSupabaseClient() {
const cookieStore = cookies();
const { url, key } = getSupabaseConfig();
return createServerClient(url, key, {
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options));
} catch {
// Server Components cannot write cookies; middleware refreshes sessions.
}
},
},
});
}
export async function getAuthenticatedUser() {
const { data: { user } } = await createServerSupabaseClient().auth.getUser();
return user;
}
+22
View File
@@ -0,0 +1,22 @@
export const THEME_STORAGE_KEY = "confidence-engine-theme";
export function resolveTheme({ savedTheme, systemPrefersDark = false } = {}) {
if (savedTheme === "light" || savedTheme === "dark") return savedTheme;
return systemPrefersDark ? "dark" : "light";
}
export function toggleTheme(theme) {
return theme === "dark" ? "light" : "dark";
}
export function readThemePreference(storage) {
try {
return storage?.getItem(THEME_STORAGE_KEY) ?? null;
} catch {
return null;
}
}
export function saveThemePreference(theme, storage) {
storage?.setItem(THEME_STORAGE_KEY, theme);
}
+36
View File
@@ -0,0 +1,36 @@
import { createServerClient } from "@supabase/ssr";
import { NextResponse } from "next/server";
const PUBLIC_PATHS = ["/login", "/auth"];
export async function middleware(request) {
const pathname = request.nextUrl.pathname;
if (PUBLIC_PATHS.some((path) => pathname === path || pathname.startsWith(`${path}/`))) {
return NextResponse.next();
}
let response = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
{
cookies: {
getAll: () => request.cookies.getAll(),
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => request.cookies.set(name, value));
response = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) => response.cookies.set(name, value, options));
},
},
},
);
const { data: { user } } = await supabase.auth.getUser();
if (user) return response;
if (pathname.startsWith("/api/")) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const loginUrl = request.nextUrl.clone();
loginUrl.pathname = "/login";
loginUrl.searchParams.set("next", pathname);
return NextResponse.redirect(loginUrl);
}
export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"] };
+4 -1
View File
@@ -1,3 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
const nextConfig = {
output: 'standalone',
};
export default nextConfig;
+128
View File
@@ -8,6 +8,8 @@
"name": "confidence-engine",
"version": "0.2.0-experimental",
"dependencies": {
"@supabase/ssr": "^0.12.7",
"@supabase/supabase-js": "^2.116.0",
"next": "^14.2.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
@@ -1321,6 +1323,110 @@
"dev": true,
"license": "MIT"
},
"node_modules/@supabase/auth-js": {
"version": "2.116.0",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.116.0.tgz",
"integrity": "sha512-Cmosty12gyKGK9N3bQb+lMmuAFev5nmUzaR1AsmZHqKOAGzqX1VQzmp49CNPwOx/pw0H9Qqk4rs9yhwTlKpfDg==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.116.0",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.116.0.tgz",
"integrity": "sha512-E+VOc2QDcni/fySqkBFiZhnoB3SGydEdZgFI6/dEAGAHx6yEhB46TN9qb2wXs+E+RSzOBV0R6dasiSlw4xlZAA==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@supabase/phoenix": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz",
"integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==",
"license": "MIT"
},
"node_modules/@supabase/postgrest-js": {
"version": "2.116.0",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.116.0.tgz",
"integrity": "sha512-kGpVZTDHxFTJS3tu+rU0iTAZ+4U0bcLVjxwCk8f3gRhjw3qdCZjTBlgYvc4kGH2XccmAzbkKwXL/mrNHMGSc+A==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.116.0",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.116.0.tgz",
"integrity": "sha512-MHAnlXxi2s6yiJsZsQMfs2B3RFxeVfQWxerqYhIMqcCQV/FuY3LIeouPEkXw/ah7wUWMLYwempF9MOCUScyddg==",
"license": "MIT",
"dependencies": {
"@supabase/phoenix": "0.4.5",
"tslib": "2.8.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@supabase/ssr": {
"version": "0.12.7",
"resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.12.7.tgz",
"integrity": "sha512-wiBtEie1KkRJi9RrZWY3R2imRhX1JY7qMyUCH2z9AUk15gQebNEplM+urbCKamdxaTJLXUU6LlpkJsaxhojCEg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.2"
},
"peerDependencies": {
"@supabase/supabase-js": "^2.114.0"
}
},
"node_modules/@supabase/storage-js": {
"version": "2.116.0",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.116.0.tgz",
"integrity": "sha512-6/3hR6vccBP6oGM5B6RfbwZcTCKmQOodd/ZWQdsw8yJsU5zO/a//oBL6yLnmgxcjnHSrelW8rsO7hL5DPybyUQ==",
"license": "MIT",
"dependencies": {
"iceberg-js": "^0.8.1",
"tslib": "2.8.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.116.0",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.116.0.tgz",
"integrity": "sha512-YyWmKXt2NspV9iO8FPnlswUFJIRnrLd3oTCb+3ZyYRuKZtBH0xCUDgnUqoyA0fGUxpM/UhfwDjYf/dht/9bp7g==",
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.116.0",
"@supabase/functions-js": "2.116.0",
"@supabase/postgrest-js": "2.116.0",
"@supabase/realtime-js": "2.116.0",
"@supabase/storage-js": "2.116.0"
},
"engines": {
"node": ">=22.0.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.0.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
}
}
},
"node_modules/@swc/counter": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
@@ -2813,6 +2919,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -4296,6 +4415,15 @@
"node": ">= 0.4"
}
},
"node_modules/iceberg-js": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+2
View File
@@ -14,6 +14,8 @@
"test:watch": "vitest"
},
"dependencies": {
"@supabase/ssr": "^0.12.7",
"@supabase/supabase-js": "^2.116.0",
"next": "^14.2.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
@@ -0,0 +1,53 @@
create schema if not exists confidence_engine;
create table confidence_engine.investigations (
id uuid primary key,
user_id uuid not null references auth.users(id) on delete cascade,
snapshot jsonb not null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index investigations_user_id_idx
on confidence_engine.investigations (user_id);
create function confidence_engine.set_updated_at()
returns trigger
language plpgsql
set search_path = ''
as $$
begin
new.updated_at = now();
return new;
end;
$$;
create trigger investigations_set_updated_at
before update on confidence_engine.investigations
for each row execute function confidence_engine.set_updated_at();
alter table confidence_engine.investigations enable row level security;
grant usage on schema confidence_engine to authenticated;
grant select, insert, update, delete on confidence_engine.investigations to authenticated;
create policy "Users can select their own investigations"
on confidence_engine.investigations
for select to authenticated
using (user_id = auth.uid());
create policy "Users can insert their own investigations"
on confidence_engine.investigations
for insert to authenticated
with check (user_id = auth.uid());
create policy "Users can update their own investigations"
on confidence_engine.investigations
for update to authenticated
using (user_id = auth.uid())
with check (user_id = auth.uid());
create policy "Users can delete their own investigations"
on confidence_engine.investigations
for delete to authenticated
using (user_id = auth.uid());
+95
View File
@@ -1,11 +1,45 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { makeGraph, makeNode } from "@/lib/graph/schema.js";
const mockUpdateCase = vi.fn();
const mockReconsiderCompletedEpisode = vi.fn();
const mockApplyValidatedProposal = vi.fn();
const mockPrepareCompletedEpisode = vi.fn();
vi.mock("@/lib/graph/orchestrator.js", () => ({
updateCase: (...args) => mockUpdateCase(...args),
reconsiderCompletedEpisode: (...args) => mockReconsiderCompletedEpisode(...args),
}));
vi.mock("@/lib/graph/apply-proposal.js", () => ({
applyValidatedProposal: (...args) => mockApplyValidatedProposal(...args),
}));
vi.mock("@/lib/graph/episode-preparation.js", () => ({
prepareCompletedEpisode: (...args) => mockPrepareCompletedEpisode(...args),
}));
function makeEpisodeGraph() {
return makeGraph({
centralStatement: "Episode scenario",
currentSummary: "Episode graph",
nodes: [makeNode({ id: "target", label: "Target question", kind: "unknown", status: "unknown" })],
edges: [],
activeUnknownNodeId: "target",
resolvedNodeIds: ["already-resolved"],
});
}
function mockSuccessfulEpisodeApplication(graph, overrides = {}) {
mockPrepareCompletedEpisode.mockReturnValue({ turns: [{ question: "Q?", answer: "A." }] });
mockReconsiderCompletedEpisode.mockResolvedValue({ success: true, proposal: {} });
mockApplyValidatedProposal.mockResolvedValue({
success: true,
updatedSituationGraph: graph,
...overrides,
});
}
function makeSuccessResult() {
return {
success: true,
@@ -76,6 +110,67 @@ describe("app/api/cases/update route", () => {
expect(mockUpdateCase.mock.calls[0][0]).not.toHaveProperty("modelName");
});
it("authoritatively resolves the selected target after a successful no-op episode", async () => {
const graph = makeEpisodeGraph();
mockSuccessfulEpisodeApplication(graph);
const { POST } = await import("@/app/api/cases/update/route.js");
const response = await POST(new Request("http://localhost/api/cases/update", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
episodeMode: true,
situationGraph: graph,
targetNodeId: "target",
contributions: [{ targetNodeId: "target" }],
}),
}));
const body = await response.json();
expect(response.status).toBe(200);
expect(body.updatedSituationGraph.resolvedNodeIds).toEqual(
expect.arrayContaining(["already-resolved", "target"]),
);
expect(body.updatedSituationGraph.nodes).toEqual(graph.nodes);
expect(body.updatedSituationGraph.edges).toEqual(graph.edges);
});
it("preserves meaningful episode mutations while authoritatively resolving the target", async () => {
const graph = makeEpisodeGraph();
const mutatedGraph = {
...graph,
nodes: [...graph.nodes, makeNode({ id: "meaningful", label: "Meaningful change", kind: "observation", status: "known" })],
};
mockSuccessfulEpisodeApplication(mutatedGraph);
const { POST } = await import("@/app/api/cases/update/route.js");
const response = await POST(new Request("http://localhost/api/cases/update", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ episodeMode: true, situationGraph: graph, targetNodeId: "target", contributions: [{ targetNodeId: "target" }] }),
}));
const body = await response.json();
expect(body.updatedSituationGraph.nodes).toEqual(mutatedGraph.nodes);
expect(body.updatedSituationGraph.resolvedNodeIds).toContain("target");
});
it("does not return an authoritative closure when completed-episode reconsideration fails", async () => {
const graph = makeEpisodeGraph();
mockPrepareCompletedEpisode.mockReturnValue({ turns: [{ question: "Q?", answer: "A." }] });
mockReconsiderCompletedEpisode.mockResolvedValue({ success: false, stage: "proposal_compatibility", error: "invalid" });
const { POST } = await import("@/app/api/cases/update/route.js");
const response = await POST(new Request("http://localhost/api/cases/update", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ episodeMode: true, situationGraph: graph, targetNodeId: "target", contributions: [{ targetNodeId: "target" }] }),
}));
expect(response.status).toBe(422);
await expect(response.json()).resolves.not.toHaveProperty("updatedSituationGraph");
});
it("invalid JSON returns 400", async () => {
const { POST } = await import("@/app/api/cases/update/route.js");
const request = {
+80
View File
@@ -0,0 +1,80 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
const mockGetAuthenticatedUser = vi.fn();
const mockStartCase = vi.fn();
const mockGetUser = vi.fn();
const mockGetConfig = vi.fn();
vi.mock("@/lib/supabase/server.js", () => ({
getAuthenticatedUser: () => mockGetAuthenticatedUser(),
}));
vi.mock("@/lib/graph/orchestrator.js", () => ({
startCase: (...args) => mockStartCase(...args),
}));
vi.mock("@/lib/config", () => ({
getConfig: () => mockGetConfig(),
}));
vi.mock("@supabase/ssr", () => ({
createServerClient: () => ({ auth: { getUser: () => mockGetUser() } }),
}));
describe("authenticated product boundary", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("rejects an unauthenticated protected API request", async () => {
mockGetAuthenticatedUser.mockResolvedValue(null);
const { withAuthenticatedApi } = await import("@/lib/supabase/api-auth.js");
const handler = vi.fn();
const response = await withAuthenticatedApi(handler)(new Request("http://localhost/api/cases/start"));
expect(response.status).toBe(401);
expect(handler).not.toHaveBeenCalled();
});
it("allows an authenticated protected API request to reach existing route behavior", async () => {
mockGetAuthenticatedUser.mockResolvedValue({ id: "user-1" });
mockStartCase.mockResolvedValue({ success: true, updatedSituationGraph: {} });
const { POST } = await import("@/app/api/cases/start/route.js");
const response = await POST(new Request("http://localhost/api/cases/start", {
method: "POST",
body: JSON.stringify({ scenario: "A scenario" }),
}));
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({ success: true });
expect(mockStartCase).toHaveBeenCalledWith({ scenario: "A scenario" });
});
it("supplies the auth callback as the magic-link redirect target", async () => {
const { magicLinkRedirectTo } = await import("@/lib/supabase/browser.js");
expect(magicLinkRedirectTo("http://localhost:3000")).toBe("http://localhost:3000/auth/callback");
});
it("keeps infrastructure health public", async () => {
mockGetConfig.mockReturnValue({ ok: false });
const { GET } = await import("@/app/api/health/route.js");
const response = await GET();
expect(response.status).toBe(500);
await expect(response.json()).resolves.toMatchObject({ configPresent: false });
expect(mockGetAuthenticatedUser).not.toHaveBeenCalled();
});
it("redirects unauthenticated product access to the login surface", async () => {
mockGetUser.mockResolvedValue({ data: { user: null } });
const { middleware } = await import("@/middleware.js");
const response = await middleware(new NextRequest("http://localhost:3000/"));
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe("http://localhost:3000/login?next=%2F");
});
});
+92 -5
View File
@@ -7,7 +7,7 @@
*/
import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";
import { focusedDeconstructJsonSchema } from "@/lib/graph/focused-investigation";
import { focusedDeconstructJsonSchema, validateFocusedDeconstructSchema } from "@/lib/graph/focused-investigation";
// ── helpers ──────────────────────────────────────────────────────────────
@@ -20,8 +20,8 @@ function makeMockProvider(inventedTargetNodeId) {
uncertainties: ["whether formal docs can capture tacit knowledge"],
assumptions: ["documentation is primary mechanism for knowledge transfer"],
relationships: [
{ from: "founder", to: "processes", type: "holds", rationale: "tacit" },
{ from: "ops-context", to: "docs-infra", type: "depends_on", rationale: "formal docs required" },
{ from: "founder", to: "processes", type: "holds" },
{ from: "ops-context", to: "docs-infra", type: "depends_on" },
],
possibleFollowUpQuestions: [
"What processes does the founder hold tacitly?",
@@ -45,6 +45,33 @@ describe("focused-deconstruct targetNodeId identity boundary", () => {
vi.doUnmock("@/lib/llm/provider");
});
it("enforces the canonical focused relationship structure", () => {
const base = {
targetNodeId: "node-id", observations: [], uncertainties: [], assumptions: [],
possibleFollowUpQuestions: [],
};
expect(validateFocusedDeconstructSchema({ ...base, relationships: [] })).toEqual([]);
expect(validateFocusedDeconstructSchema({
...base,
relationships: [{ from: "supplier changed", to: "defect rate increased", type: "associated with" }],
})).toEqual([]);
const invalidRelationships = [
"not an array",
[{}],
[{ from: "a", type: "links" }],
[{ from: "a", to: "b" }],
[{ from: "", to: "b", type: "links" }],
[{ from: "a", to: "", type: "links" }],
[{ from: "a", to: "b", type: "" }],
[{ from: "a", to: "b", type: "links", rationale: "extra" }],
[{ from: "a", to: "b", type: "links", extra: "extra" }],
];
invalidRelationships.forEach((relationships) => {
expect(validateFocusedDeconstructSchema({ ...base, relationships }).length).toBeGreaterThan(0);
});
});
it("request targetNodeId overrides model-invented targetNodeId", async () => {
const requestTargetNodeId = "nk04xvk"; // original graph node ID
const inventedModelId = "invented-model-id";
@@ -128,8 +155,8 @@ describe("focused-deconstruct targetNodeId identity boundary", () => {
const mockUnc = ["whether formal docs can capture tacit knowledge"];
const mockAssm = ["documentation is primary mechanism for knowledge transfer"];
const mockRel = [
{ from: "founder", to: "processes", type: "holds", rationale: "tacit" },
{ from: "ops-context", to: "docs-infra", type: "depends_on", rationale: "formal docs required" },
{ from: "founder", to: "processes", type: "holds" },
{ from: "ops-context", to: "docs-infra", type: "depends_on" },
];
const mockFuq = [
"What processes does the founder hold tacitly?",
@@ -328,4 +355,64 @@ describe("focused-deconstruct targetNodeId identity boundary", () => {
expect(json.providerApiPath).toBeUndefined();
expect(json.providerExecution).toBeUndefined();
});
it("preserves the 500 provider-failure contract while logging structural diagnostics", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.doMock("@/lib/llm/provider", () => ({
getProvider: () => ({
generateReconstruction: vi.fn().mockRejectedValue(Object.assign(new Error("provider failed"), {
providerApiPath: "/v1/responses",
statusCode: 400,
})),
}),
getProviderModelName: () => "gpt-5.6-terra",
}));
const { POST } = await import("../app/api/focused-investigation/deconstruct/route.js");
try {
const response = await POST(new Request("http://localhost/api/focused-investigation/deconstruct", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetNodeId: "node-id", targetLabel: "label", targetDescription: "description",
centralStatement: "central", question: "question?", answer: "answer.",
}),
}));
expect(response.status).toBe(500);
await expect(response.json()).resolves.toEqual({ error: "provider failed" });
expect(errorSpy).toHaveBeenCalledWith(
"[api/focused-investigation/deconstruct] provider failure",
expect.objectContaining({ targetNodeId: "node-id", providerApiPath: "/v1/responses" }),
);
} finally {
errorSpy.mockRestore();
}
});
it("preserves the 502 validation-failure contract with diagnostics", async () => {
vi.doMock("@/lib/llm/provider", () => ({
getProvider: () => ({
generateReconstruction: vi.fn().mockResolvedValue({
response: {}, providerApiPath: "/v1/responses",
}),
}),
getProviderModelName: () => "gpt-5.6-terra",
}));
const { POST } = await import("../app/api/focused-investigation/deconstruct/route.js");
const response = await POST(new Request("http://localhost/api/focused-investigation/deconstruct", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetNodeId: "node-id", targetLabel: "label", targetDescription: "description",
centralStatement: "central", question: "question?", answer: "answer.",
}),
}));
expect(response.status).toBe(502);
await expect(response.json()).resolves.toMatchObject({
success: false,
error: "Focused deconstruction result did not match expected schema",
targetNodeId: "node-id",
});
});
});
+64 -2
View File
@@ -706,10 +706,10 @@ describe("applyValidatedProposal", () => {
expect(graph).toEqual(originalGraph);
});
it("rejects a proposal with no meaningful change", () => {
it("rejects a proposal with no meaningful change", async () => {
const { graph } = makeApplicationFixture();
const result = applyValidatedProposal({
const result = await applyValidatedProposal({
situationGraph: graph,
proposal: {
addedNodes: [],
@@ -740,6 +740,68 @@ describe("applyValidatedProposal", () => {
);
});
it("accepts a completed-episode proposal with no additional graph mutation", async () => {
const { graph } = makeApplicationFixture();
const originalGraph = JSON.parse(JSON.stringify(graph));
const noOpProposal = {
addedNodes: [],
updatedNodes: [
{
nodeId: "n-quality-deterioration",
previousStatus: null,
newStatus: null,
previousValue: null,
newValue: null,
reason: "No further graph change.",
},
],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [],
affectedNodeIds: [],
selectedQuestion: null,
};
const result = await applyValidatedProposal({
situationGraph: graph,
proposal: noOpProposal,
evidenceContext: {
isCompletedEpisode: true,
episodeEvidence: {
turns: [{ question: "What did you find?", answer: "No further change." }],
eligibleCanonicalFindings: [{ proposition: "The episode is preserved." }],
},
},
});
expect(result.success).toBe(true);
expect(result.updatedSituationGraph.nodes).toEqual(originalGraph.nodes);
expect(result.updatedSituationGraph.edges).toEqual(originalGraph.edges);
});
it("still rejects an invalid completed-episode proposal", async () => {
const { graph } = makeApplicationFixture();
const result = await applyValidatedProposal({
situationGraph: graph,
proposal: {
addedNodes: [],
updatedNodes: [{ nodeId: "missing-node", reason: "Invalid target." }],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [],
affectedNodeIds: [],
selectedQuestion: null,
},
evidenceContext: { isCompletedEpisode: true, episodeEvidence: { turns: [] } },
});
expect(result).toMatchObject({ success: false, stage: "proposal_compatibility" });
expect(result.errors).toEqual(expect.arrayContaining([
expect.stringContaining("Cannot update non-existent node"),
]));
});
it("resolves one unknown and adds consequential unknowns with one selected question", () => {
const { graph, ids } = makeApplicationFixture();
@@ -56,9 +56,10 @@ const agreedFinding = () => ({
/** Fake provider factory for tests */
const makeFakeProvider = (defaultResponse) => ({
generateReconstruction: vi.fn(async () => {
return typeof defaultResponse === "function"
? defaultResponse()
const response = typeof defaultResponse === "function"
? await defaultResponse()
: JSON.stringify({ currentUnderstanding: "Synthesized output" });
return { response, providerApiPath: "/test-provider" };
}),
});
@@ -375,6 +376,20 @@ describe("synthesizeCurrentUnderstanding — full seam", () => {
const prompt = fake.generateReconstruction.mock.calls[0][0];
expect(prompt).toContain("Complaint count increased by 35%");
expect(prompt).toContain("[known]");
expect(fake.generateReconstruction.mock.calls[0][2]).toEqual({
type: "object",
properties: { currentUnderstanding: { type: "string" } },
required: ["currentUnderstanding"],
additionalProperties: false,
});
});
it("unwraps the provider response before validating the synthesis narrative", async () => {
const fake = makeFakeProvider(() => ({ currentUnderstanding: "Wrapped narrative" }));
await expect(synthesizeCurrentUnderstanding(
{ situationGraph: fullScenario, findings: [] },
{ provider: fake },
)).resolves.toEqual({ currentUnderstanding: "Wrapped narrative" });
});
it("supported node content flows to provider via synthesis prompt", async () => {
@@ -145,6 +145,23 @@ describe("reopenResolvedUnknown — pure deterministic transformation", () => {
});
});
describe("canonical resolved-node-ID target", () => {
it("reopens an unknown-status node marked resolved by graph state without mutating input", () => {
const graph = makeTestGraph();
graph.nodes[0] = { ...graph.nodes[0], status: "unknown" };
const result = reopenResolvedUnknown(graph, "u-1");
const targetNode = result.nodes.find((n) => n.id === "u-1");
expect(targetNode.kind).toBe("unknown");
expect(targetNode.status).toBe("unknown");
expect(result.resolvedNodeIds).not.toContain("u-1");
expect(result.resolvedNodeIds).toContain("u-2");
expect(graph.nodes.find((n) => n.id === "u-1").status).toBe("unknown");
expect(graph.resolvedNodeIds).toContain("u-1");
});
});
describe("invalid/no-op cases", () => {
it("returns original graph when node does not exist", () => {
const graph = makeTestGraph();
@@ -0,0 +1,110 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockGetAuthenticatedUser = vi.fn();
const mockCreateServerSupabaseClient = vi.fn();
vi.mock("@/lib/supabase/server.js", () => ({
getAuthenticatedUser: () => mockGetAuthenticatedUser(),
createServerSupabaseClient: () => mockCreateServerSupabaseClient(),
}));
function makeClient(result) {
const query = {
from: vi.fn(() => query),
upsert: vi.fn(() => query),
select: vi.fn(() => query),
eq: vi.fn(() => query),
maybeSingle: vi.fn(() => Promise.resolve(result)),
single: vi.fn(() => Promise.resolve(result)),
order: vi.fn(() => Promise.resolve(result)),
};
return { client: { schema: vi.fn(() => query) }, query };
}
describe("server investigation persistence", () => {
beforeEach(() => vi.clearAllMocks());
it("rejects unauthenticated persistence access", async () => {
mockGetAuthenticatedUser.mockResolvedValue(null);
const { saveInvestigation } = await import("@/lib/storage/server-investigation-persistence.js");
await expect(saveInvestigation({ id: "investigation-1" })).resolves.toBeNull();
expect(mockCreateServerSupabaseClient).not.toHaveBeenCalled();
});
it("saves an unchanged snapshot with user identity derived on the server", async () => {
const snapshot = { id: "investigation-1", scenario: "A situation", user_id: "untrusted" };
const { client, query } = makeClient({ data: { snapshot }, error: null });
mockGetAuthenticatedUser.mockResolvedValue({ id: "trusted-user" });
mockCreateServerSupabaseClient.mockReturnValue(client);
const { saveInvestigation } = await import("@/lib/storage/server-investigation-persistence.js");
await expect(saveInvestigation(snapshot)).resolves.toBe(snapshot);
expect(client.schema).toHaveBeenCalledWith("confidence_engine");
expect(query.from).toHaveBeenCalledWith("investigations");
expect(query.upsert).toHaveBeenCalledWith(
{ id: "investigation-1", user_id: "trusted-user", snapshot },
{ onConflict: "id" },
);
});
it("loads the RLS-scoped stored snapshot", async () => {
const snapshot = { id: "investigation-1", findings: [] };
const { client, query } = makeClient({ data: { snapshot }, error: null });
mockGetAuthenticatedUser.mockResolvedValue({ id: "trusted-user" });
mockCreateServerSupabaseClient.mockReturnValue(client);
const { loadInvestigation } = await import("@/lib/storage/server-investigation-persistence.js");
await expect(loadInvestigation("investigation-1")).resolves.toBe(snapshot);
expect(client.schema).toHaveBeenCalledWith("confidence_engine");
expect(query.eq).toHaveBeenCalledWith("id", "investigation-1");
});
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", snapshot, created_at: "2026-09-08T00:00:00Z", updated_at: "2026-09-08T01:00:00Z" }],
error: null,
});
mockGetAuthenticatedUser.mockResolvedValue({ id: "trusted-user" });
mockCreateServerSupabaseClient.mockReturnValue(client);
const { listInvestigations } = await import("@/lib/storage/server-investigation-persistence.js");
await expect(listInvestigations()).resolves.toEqual([{
id: "investigation-1",
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, 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");
});
});
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import {
readThemePreference,
resolveTheme,
saveThemePreference,
THEME_STORAGE_KEY,
toggleTheme,
} from "@/lib/theme-preference.js";
function makeStorage(initial = {}) {
const values = new Map(Object.entries(initial));
return {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => values.set(key, value),
};
}
describe("theme preference", () => {
it("restores a saved dark preference ahead of system preference", () => {
const storage = makeStorage({ [THEME_STORAGE_KEY]: "dark" });
expect(resolveTheme({ savedTheme: readThemePreference(storage), systemPrefersDark: false })).toBe("dark");
});
it("uses system dark preference when no explicit choice exists", () => {
expect(resolveTheme({ savedTheme: null, systemPrefersDark: true })).toBe("dark");
});
it("switches themes and persists the explicit choice", () => {
const storage = makeStorage();
const nextTheme = toggleTheme("light");
saveThemePreference(nextTheme, storage);
expect(nextTheme).toBe("dark");
expect(readThemePreference(storage)).toBe("dark");
});
});