diff --git a/apps/pwa/CLAUDE.md b/apps/pwa/CLAUDE.md
index b5416b9cd..1e52a824f 100644
--- a/apps/pwa/CLAUDE.md
+++ b/apps/pwa/CLAUDE.md
@@ -12,6 +12,7 @@ Free PWA. Session-only by default; opt-in local persistence; education + trainin
## Invariants
- **Architecture aligned with Azure per ADR-078** (same product, gated tiers): shared domain Zustand stores from `@variscout/stores` (`useProjectStore`, `useInvestigationStore`, `useImprovementStore`, `useSessionStore`, `useWallLayoutStore`, `useCanvasStore`); state shapes tier-agnostic, persistence tier-gated (Q8-revised: IndexedDB Hub-of-one + `.vrs`); tier-gated features check `isPaidTier()` at mount; shared orchestration components live in `@variscout/ui` with ~40 LOC route-shell per app. The "DataContext only, no Zustand" rule was retired by ADR-078.
+- **Persistence boundary** (F1+F2 PR2): hub-blob writes flow through `pwaHubRepository` (`apps/pwa/src/persistence/`, module-scoped singleton implementing `@variscout/core/persistence#HubRepository`). Direct `hubRepository.{saveHub,loadHub}` outside `apps/pwa/src/persistence/` is the boundary contract (F2 PR3 will enforce with an ESLint rule); `getOptInFlag`/`setOptInFlag` are documented exceptions. `HUB_PERSIST_SNAPSHOT` is the bootstrap action — bypasses the "no active hub" guard.
- Embedded mode supported for iframes (see flows in `docs/02-journeys/flows/pwa-education.md`).
- Entry: `src/components/Dashboard.tsx`. Hosts the timeline-window picker (investigation-time, default `open-ended`; session-local in V1).
diff --git a/apps/pwa/package.json b/apps/pwa/package.json
index 301fcf529..c53b042a5 100644
--- a/apps/pwa/package.json
+++ b/apps/pwa/package.json
@@ -25,6 +25,7 @@
"d3-array": "^3.2.4",
"dexie": "^4.4.2",
"html-to-image": "^1.11.13",
+ "immer": "^11.1.4",
"lucide-react": "^1.14.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
diff --git a/apps/pwa/src/App.tsx b/apps/pwa/src/App.tsx
index fc3bcebaf..f554b3bdf 100644
--- a/apps/pwa/src/App.tsx
+++ b/apps/pwa/src/App.tsx
@@ -31,6 +31,7 @@ import { SaveToBrowserButton } from './components/SaveToBrowserButton';
import { VrsExportButton } from './components/VrsExportButton';
import { SessionProvider, useSession } from './store/sessionStore';
import { hubRepository } from './db/hubRepository';
+import { pwaHubRepository } from './persistence';
import { Beaker, Settings, Download, Table2, RotateCcw, FileText } from 'lucide-react';
import {
useFindings,
@@ -161,7 +162,10 @@ function AppMain() {
let cancelled = false;
void hubRepository.getOptInFlag().then(async opted => {
if (!opted || cancelled) return;
- const loaded = await hubRepository.loadHub();
+ // Load via repository pattern (P4.2). pwaHubRepository.hubs.list() returns
+ // [] or [hub]; no literal ID needed. hubRepository.getOptInFlag stays
+ // direct-call — no HubAction equivalent until F3 adds HubMetaAction.
+ const [loaded] = await pwaHubRepository.hubs.list();
if (loaded && !cancelled) setSessionHub(loaded);
});
return () => {
diff --git a/apps/pwa/src/components/SaveToBrowserButton.tsx b/apps/pwa/src/components/SaveToBrowserButton.tsx
index 4139177f9..d81692229 100644
--- a/apps/pwa/src/components/SaveToBrowserButton.tsx
+++ b/apps/pwa/src/components/SaveToBrowserButton.tsx
@@ -1,7 +1,8 @@
// apps/pwa/src/components/SaveToBrowserButton.tsx
import { useEffect, useState } from 'react';
import type { ProcessHub } from '@variscout/core/processHub';
-import { hubRepository } from '../db/hubRepository';
+import { hubRepository } from '../db/hubRepository'; // getOptInFlag / setOptInFlag only
+import { pwaHubRepository } from '../persistence';
export interface SaveToBrowserButtonProps {
currentHub: ProcessHub;
@@ -18,7 +19,7 @@ export function SaveToBrowserButton({ currentHub }: SaveToBrowserButtonProps) {
// Auto-save on Hub change once opted in
useEffect(() => {
if (optedIn) {
- void hubRepository.saveHub(currentHub);
+ void pwaHubRepository.dispatch({ kind: 'HUB_PERSIST_SNAPSHOT', hub: currentHub });
}
}, [optedIn, currentHub]);
@@ -33,7 +34,7 @@ export function SaveToBrowserButton({ currentHub }: SaveToBrowserButtonProps) {
onClick={async () => {
setBusy(true);
await hubRepository.setOptInFlag(true);
- await hubRepository.saveHub(currentHub);
+ await pwaHubRepository.dispatch({ kind: 'HUB_PERSIST_SNAPSHOT', hub: currentHub });
setOptedIn(true);
setBusy(false);
}}
diff --git a/apps/pwa/src/components/__tests__/SaveToBrowserButton.test.tsx b/apps/pwa/src/components/__tests__/SaveToBrowserButton.test.tsx
index f2d351388..be023e849 100644
--- a/apps/pwa/src/components/__tests__/SaveToBrowserButton.test.tsx
+++ b/apps/pwa/src/components/__tests__/SaveToBrowserButton.test.tsx
@@ -4,6 +4,7 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { SaveToBrowserButton } from '../SaveToBrowserButton';
import { hubRepository } from '../../db/hubRepository';
+import { pwaHubRepository } from '../../persistence';
import { DEFAULT_PROCESS_HUB } from '@variscout/core/processHub';
const hub = { ...DEFAULT_PROCESS_HUB, processGoal: 'Test goal.' };
@@ -43,4 +44,20 @@ describe('SaveToBrowserButton', () => {
await waitFor(async () => expect(await hubRepository.getOptInFlag()).toBe(false));
expect(await hubRepository.loadHub()).toBeNull();
});
+
+ it('clicking save routes through pwaHubRepository.dispatch with HUB_PERSIST_SNAPSHOT', async () => {
+ // Verifies the dispatch path is exercised — the write goes through
+ // pwaHubRepository.dispatch rather than hubRepository.saveHub directly.
+ const dispatchSpy = vi.spyOn(pwaHubRepository, 'dispatch');
+ render();
+ fireEvent.click(await screen.findByRole('button', { name: /save to this browser/i }));
+ await waitFor(() => expect(dispatchSpy).toHaveBeenCalled());
+ expect(dispatchSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ kind: 'HUB_PERSIST_SNAPSHOT',
+ hub: expect.objectContaining({ processGoal: 'Test goal.' }),
+ })
+ );
+ dispatchSpy.mockRestore();
+ });
});
diff --git a/apps/pwa/src/persistence/PwaHubRepository.ts b/apps/pwa/src/persistence/PwaHubRepository.ts
new file mode 100644
index 000000000..fbe7e109a
--- /dev/null
+++ b/apps/pwa/src/persistence/PwaHubRepository.ts
@@ -0,0 +1,175 @@
+// apps/pwa/src/persistence/PwaHubRepository.ts
+//
+// PWA persistence model: Hub-of-one blob only.
+// The PWA stores exactly one row in IndexedDB — `{ id: 'hub-of-one', hub: ProcessHub }`.
+// There are no per-entity tables. The grouped read APIs below serve data from
+// that single hub blob; F3 will normalize into dedicated tables.
+
+import type {
+ HubRepository,
+ HubReadAPI,
+ OutcomeReadAPI,
+ EvidenceSnapshotReadAPI,
+ EvidenceSourceReadAPI,
+ InvestigationReadAPI,
+ FindingReadAPI,
+ QuestionReadAPI,
+ CausalLinkReadAPI,
+ SuspectedCauseReadAPI,
+ CanvasStateReadAPI,
+} from '@variscout/core/persistence';
+import type { HubAction } from '@variscout/core/actions';
+import { hubRepository } from '../db/hubRepository';
+import { applyAction } from './applyAction';
+
+export class PwaHubRepository implements HubRepository {
+ // ---------------------------------------------------------------------------
+ // Single write path
+ // ---------------------------------------------------------------------------
+
+ async dispatch(action: HubAction): Promise {
+ // HUB_PERSIST_SNAPSHOT is the bootstrap/save path — the action carries the
+ // full hub blob, so no existing hub needs to be loaded first. This is the
+ // only action that can execute before a hub has been persisted (e.g. the
+ // first "Save to this browser" click). applyAction still handles this kind
+ // for purity over HubAction, but dispatch short-circuits to avoid the
+ // unnecessary load round-trip and to support the null-hub bootstrap case.
+ if (action.kind === 'HUB_PERSIST_SNAPSHOT') {
+ await hubRepository.saveHub(action.hub);
+ return;
+ }
+ const hub = await hubRepository.loadHub();
+ if (!hub) {
+ throw new Error('No active hub to dispatch action against');
+ }
+ const next = applyAction(hub, action);
+ await hubRepository.saveHub(next);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Read APIs — hubs
+ // ---------------------------------------------------------------------------
+
+ hubs: HubReadAPI = {
+ async get(id) {
+ const hub = await hubRepository.loadHub();
+ return hub?.id === id ? hub : undefined;
+ },
+ async list() {
+ const hub = await hubRepository.loadHub();
+ return hub ? [hub] : [];
+ },
+ };
+
+ // ---------------------------------------------------------------------------
+ // Read APIs — outcomes
+ // Outcomes are hub-resident arrays; filter for live entries (deletedAt === null).
+ // ---------------------------------------------------------------------------
+
+ outcomes: OutcomeReadAPI = {
+ async get(id) {
+ const hub = await hubRepository.loadHub();
+ return hub?.outcomes?.find(o => o.id === id && o.deletedAt === null);
+ },
+ async listByHub(hubId) {
+ const hub = await hubRepository.loadHub();
+ if (!hub || hub.id !== hubId) return [];
+ return (hub.outcomes ?? []).filter(o => o.deletedAt === null);
+ },
+ };
+
+ // ---------------------------------------------------------------------------
+ // Read APIs — canvas state
+ // canonicalProcessMap is the hub's canvas snapshot.
+ // ---------------------------------------------------------------------------
+
+ canvasState: CanvasStateReadAPI = {
+ async getByHub(hubId) {
+ const hub = await hubRepository.loadHub();
+ if (!hub || hub.id !== hubId) return undefined;
+ return hub.canonicalProcessMap;
+ },
+ };
+
+ // ---------------------------------------------------------------------------
+ // Stub read APIs — entities not yet stored in PWA hub blob.
+ // PWA persists hub blob only; F3 normalizes these into dedicated tables.
+ // ---------------------------------------------------------------------------
+
+ evidenceSnapshots: EvidenceSnapshotReadAPI = {
+ // PWA persists hub blob only; F3 normalizes evidenceSnapshots into a dedicated table.
+ async get(_id) {
+ return undefined;
+ },
+ async listByHub(_hubId) {
+ return [];
+ },
+ };
+
+ evidenceSources: EvidenceSourceReadAPI = {
+ // PWA persists hub blob only; F3 normalizes evidenceSources into a dedicated table.
+ async get(_id) {
+ return undefined;
+ },
+ async listByHub(_hubId) {
+ return [];
+ },
+ async getCursor(_hubId, _sourceId) {
+ return undefined;
+ },
+ };
+
+ investigations: InvestigationReadAPI = {
+ // PWA persists hub blob only; investigations live in session-only Zustand store today.
+ async get(_id) {
+ return undefined;
+ },
+ async listByHub(_hubId) {
+ return [];
+ },
+ };
+
+ findings: FindingReadAPI = {
+ // PWA persists hub blob only; findings live in session-only Zustand store today.
+ async get(_id) {
+ return undefined;
+ },
+ async listByInvestigation(_investigationId) {
+ return [];
+ },
+ };
+
+ questions: QuestionReadAPI = {
+ // PWA persists hub blob only; questions live in session-only Zustand store today.
+ async get(_id) {
+ return undefined;
+ },
+ async listByInvestigation(_investigationId) {
+ return [];
+ },
+ };
+
+ causalLinks: CausalLinkReadAPI = {
+ // PWA persists hub blob only; causalLinks live in session-only Zustand store today.
+ async get(_id) {
+ return undefined;
+ },
+ async listByInvestigation(_investigationId) {
+ return [];
+ },
+ };
+
+ suspectedCauses: SuspectedCauseReadAPI = {
+ // PWA persists hub blob only; suspectedCauses live in session-only Zustand store today.
+ async get(_id) {
+ return undefined;
+ },
+ async listByInvestigation(_investigationId) {
+ return [];
+ },
+ };
+}
+
+// Module-scoped singleton. Composition root + dispatch boundary documented in apps/pwa/CLAUDE.md.
+// Vitest module-mocking handles test override.
+export const pwaHubRepository = new PwaHubRepository();
diff --git a/apps/pwa/src/persistence/__tests__/PwaHubRepository.test.ts b/apps/pwa/src/persistence/__tests__/PwaHubRepository.test.ts
new file mode 100644
index 000000000..073ab875e
--- /dev/null
+++ b/apps/pwa/src/persistence/__tests__/PwaHubRepository.test.ts
@@ -0,0 +1,505 @@
+// apps/pwa/src/persistence/__tests__/PwaHubRepository.test.ts
+//
+// Smoke tests for PwaHubRepository skeleton (P3.1).
+// - dispatch error paths (no-hub, applyAction-not-implemented)
+// - hubs.get / hubs.list happy + empty paths
+// - outcomes.listByHub with live + tombstoned entries
+// - canvasState.getByHub with matching + mismatched hubId
+// - stub APIs return undefined / [] as documented
+//
+// Mocking strategy:
+// vi.hoisted() ensures mock vars are available inside vi.mock factory closures.
+// vi.mock() BEFORE subject imports — required per testing.md and MEMORY.md.
+// vi is imported explicitly (globals:true gives runtime access; tsc needs the import).
+
+import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
+
+// vi.hoisted + vi.mock must appear before the subject imports so that vitest
+// can hoist them above the import block at transform time.
+const mocks = vi.hoisted(() => ({
+ loadHub: vi.fn(),
+ saveHub: vi.fn(),
+}));
+
+vi.mock('../../db/hubRepository', () => ({
+ hubRepository: {
+ loadHub: mocks.loadHub,
+ saveHub: mocks.saveHub,
+ getOptInFlag: vi.fn(),
+ setOptInFlag: vi.fn(),
+ clearHub: vi.fn(),
+ clearAll: vi.fn(),
+ },
+}));
+
+import { PwaHubRepository } from '../PwaHubRepository';
+import type { ProcessHub, OutcomeSpec } from '@variscout/core/processHub';
+import type { ProcessMap } from '@variscout/core/frame';
+import type { HubAction } from '@variscout/core/actions';
+
+// ---------------------------------------------------------------------------
+// Minimal ProcessMap fixture (required fields only)
+// ---------------------------------------------------------------------------
+
+const FIXTURE_MAP: ProcessMap = {
+ version: 1,
+ nodes: [],
+ tributaries: [],
+ createdAt: '2026-05-06T00:00:00.000Z',
+ updatedAt: '2026-05-06T00:00:00.000Z',
+};
+
+// ---------------------------------------------------------------------------
+// Fixture helpers
+// ---------------------------------------------------------------------------
+
+const NOW = 1_700_000_000_000;
+
+function makeHub(overrides: Partial = {}): ProcessHub {
+ return {
+ id: 'hub-of-one',
+ name: 'Test Hub',
+ createdAt: NOW,
+ deletedAt: null,
+ ...overrides,
+ };
+}
+
+function makeOutcome(id: string, deletedAt: number | null = null): OutcomeSpec {
+ return {
+ id,
+ hubId: 'hub-of-one',
+ columnName: 'fill_weight',
+ characteristicType: 'nominalIsBest',
+ createdAt: NOW,
+ deletedAt,
+ };
+}
+
+// Minimal stub action — kind does not matter for skeleton dispatch tests because
+// applyAction will throw before reaching any switch branch.
+const STUB_ACTION: HubAction = {
+ kind: 'OUTCOME_UPSERT',
+ payload: makeOutcome('outcome-1'),
+} as unknown as HubAction;
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe('PwaHubRepository', () => {
+ let repo: PwaHubRepository;
+
+ beforeEach(() => {
+ repo = new PwaHubRepository();
+ mocks.loadHub.mockReset();
+ mocks.saveHub.mockReset();
+ });
+
+ // ---- dispatch ----
+
+ describe('dispatch', () => {
+ it('throws "No active hub" when loadHub returns null', async () => {
+ mocks.loadHub.mockResolvedValue(null);
+ await expect(repo.dispatch(STUB_ACTION)).rejects.toThrow(
+ 'No active hub to dispatch action against'
+ );
+ });
+
+ it('calls saveHub with the next hub snapshot when dispatch succeeds', async () => {
+ const hub = makeHub();
+ mocks.loadHub.mockResolvedValue(hub);
+ mocks.saveHub.mockResolvedValue(undefined);
+ // OUTCOME_UPDATE on a hub with no outcomes is a no-op that still saves
+ const action: HubAction = {
+ kind: 'OUTCOME_UPDATE',
+ outcomeId: 'nonexistent',
+ patch: { target: 5 },
+ };
+ await repo.dispatch(action);
+ expect(mocks.saveHub).toHaveBeenCalledOnce();
+ // The saved hub should equal the input hub (OUTCOME_UPDATE on nonexistent id is a no-op)
+ expect(mocks.saveHub).toHaveBeenCalledWith(expect.objectContaining({ id: 'hub-of-one' }));
+ });
+
+ it('does not call saveHub when loadHub returns null', async () => {
+ mocks.loadHub.mockResolvedValue(null);
+ await expect(repo.dispatch(STUB_ACTION)).rejects.toThrow();
+ expect(mocks.saveHub).not.toHaveBeenCalled();
+ });
+ });
+
+ // ---- hubs.get ----
+
+ describe('hubs.get', () => {
+ it('returns the hub when ids match', async () => {
+ const hub = makeHub({ id: 'hub-of-one' });
+ mocks.loadHub.mockResolvedValue(hub);
+ const result = await repo.hubs.get('hub-of-one');
+ expect(result).toEqual(hub);
+ });
+
+ it('returns undefined when ids do not match', async () => {
+ mocks.loadHub.mockResolvedValue(makeHub({ id: 'hub-of-one' }));
+ const result = await repo.hubs.get('different-id');
+ expect(result).toBeUndefined();
+ });
+
+ it('returns undefined when no hub is loaded', async () => {
+ mocks.loadHub.mockResolvedValue(null);
+ const result = await repo.hubs.get('hub-of-one');
+ expect(result).toBeUndefined();
+ });
+ });
+
+ // ---- hubs.list ----
+
+ describe('hubs.list', () => {
+ it('returns [hub] when a hub is loaded', async () => {
+ const hub = makeHub();
+ mocks.loadHub.mockResolvedValue(hub);
+ const result = await repo.hubs.list();
+ expect(result).toEqual([hub]);
+ });
+
+ it('returns [] when no hub is loaded', async () => {
+ mocks.loadHub.mockResolvedValue(null);
+ const result = await repo.hubs.list();
+ expect(result).toEqual([]);
+ });
+ });
+
+ // ---- outcomes.listByHub ----
+
+ describe('outcomes.listByHub', () => {
+ it('returns only live outcomes (deletedAt === null) for matching hubId', async () => {
+ const live = makeOutcome('outcome-live', null);
+ const tombstoned = makeOutcome('outcome-dead', NOW);
+ mocks.loadHub.mockResolvedValue(makeHub({ outcomes: [live, tombstoned] }));
+ const result = await repo.outcomes.listByHub('hub-of-one');
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe('outcome-live');
+ });
+
+ it('returns [] when hubId does not match', async () => {
+ mocks.loadHub.mockResolvedValue(makeHub({ outcomes: [makeOutcome('outcome-1')] }));
+ const result = await repo.outcomes.listByHub('other-hub');
+ expect(result).toEqual([]);
+ });
+
+ it('returns [] when hub has no outcomes array', async () => {
+ mocks.loadHub.mockResolvedValue(makeHub({ outcomes: undefined }));
+ const result = await repo.outcomes.listByHub('hub-of-one');
+ expect(result).toEqual([]);
+ });
+
+ it('returns [] when no hub is loaded', async () => {
+ mocks.loadHub.mockResolvedValue(null);
+ const result = await repo.outcomes.listByHub('hub-of-one');
+ expect(result).toEqual([]);
+ });
+ });
+
+ // ---- outcomes.get ----
+
+ describe('outcomes.get', () => {
+ it('returns the matching live outcome', async () => {
+ const outcome = makeOutcome('outcome-1');
+ mocks.loadHub.mockResolvedValue(makeHub({ outcomes: [outcome] }));
+ const result = await repo.outcomes.get('outcome-1');
+ expect(result).toEqual(outcome);
+ });
+
+ it('returns undefined for a tombstoned outcome', async () => {
+ const tombstoned = makeOutcome('outcome-dead', NOW);
+ mocks.loadHub.mockResolvedValue(makeHub({ outcomes: [tombstoned] }));
+ const result = await repo.outcomes.get('outcome-dead');
+ expect(result).toBeUndefined();
+ });
+ });
+
+ // ---- canvasState.getByHub ----
+
+ describe('canvasState.getByHub', () => {
+ it('returns canonicalProcessMap when hubId matches', async () => {
+ mocks.loadHub.mockResolvedValue(makeHub({ canonicalProcessMap: FIXTURE_MAP }));
+ const result = await repo.canvasState.getByHub('hub-of-one');
+ expect(result).toEqual(FIXTURE_MAP);
+ });
+
+ it('returns undefined when hubId does not match', async () => {
+ mocks.loadHub.mockResolvedValue(makeHub({ canonicalProcessMap: FIXTURE_MAP }));
+ const result = await repo.canvasState.getByHub('other-hub');
+ expect(result).toBeUndefined();
+ });
+
+ it('returns undefined when hub has no canonicalProcessMap', async () => {
+ mocks.loadHub.mockResolvedValue(makeHub({ canonicalProcessMap: undefined }));
+ const result = await repo.canvasState.getByHub('hub-of-one');
+ expect(result).toBeUndefined();
+ });
+
+ it('returns undefined when no hub is loaded', async () => {
+ mocks.loadHub.mockResolvedValue(null);
+ const result = await repo.canvasState.getByHub('hub-of-one');
+ expect(result).toBeUndefined();
+ });
+ });
+
+ // ---- dispatch end-to-end ----
+ //
+ // These integration tests prove that dispatch() calls loadHub → applyAction → saveHub
+ // with the correct contract for each action category.
+ //
+ // applyAction.test.ts proves recipe correctness in isolation;
+ // these tests prove the wiring is intact through the repository layer.
+
+ describe('dispatch end-to-end', () => {
+ // Freeze time for all tests in this block so Date.now()-dependent assertions are
+ // deterministic. Use the plan's reference timestamp: 2026-05-06T12:00:00.000Z.
+ const FROZEN_MS = new Date('2026-05-06T12:00:00.000Z').getTime();
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-05-06T12:00:00.000Z'));
+ mocks.saveHub.mockResolvedValue(undefined);
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('HUB_PERSIST_SNAPSHOT replaces the hub blob entirely (existing hub in store)', async () => {
+ // dispatch short-circuits for HUB_PERSIST_SNAPSHOT — loadHub is NOT called.
+ // The action payload IS the new hub; no load+merge round-trip needed.
+ const replacement = makeHub({
+ id: 'hub-of-one',
+ name: 'New Hub',
+ processGoal: 'ship on time',
+ });
+
+ await repo.dispatch({ kind: 'HUB_PERSIST_SNAPSHOT', hub: replacement });
+
+ expect(mocks.loadHub).not.toHaveBeenCalled();
+ expect(mocks.saveHub).toHaveBeenCalledOnce();
+ const saved = mocks.saveHub.mock.calls[0][0] as ProcessHub;
+ expect(saved).toEqual(replacement);
+ });
+
+ it('HUB_PERSIST_SNAPSHOT works when no hub is loaded (bootstrap path)', async () => {
+ // First "Save to this browser" click: no hub in IndexedDB yet. dispatch must
+ // not throw even when loadHub would return null.
+ mocks.loadHub.mockResolvedValue(null);
+ const newHub = makeHub({ id: 'hub-of-one', name: 'Brand New Hub' });
+
+ await expect(
+ repo.dispatch({ kind: 'HUB_PERSIST_SNAPSHOT', hub: newHub })
+ ).resolves.toBeUndefined();
+
+ expect(mocks.loadHub).not.toHaveBeenCalled();
+ expect(mocks.saveHub).toHaveBeenCalledOnce();
+ expect(mocks.saveHub).toHaveBeenCalledWith(newHub);
+ });
+
+ it('HUB_PERSIST_SNAPSHOT saves action.hub, not the previously loaded hub', async () => {
+ // Replacement semantics: even if a different hub is in the store, the
+ // action payload wins — no merge, no load.
+ const actionHub = makeHub({ id: 'hub-of-one', name: 'Action Hub', processGoal: 'new goal' });
+
+ await repo.dispatch({ kind: 'HUB_PERSIST_SNAPSHOT', hub: actionHub });
+
+ expect(mocks.saveHub).toHaveBeenCalledWith(actionHub);
+ });
+
+ it('HUB_UPDATE_GOAL persists the new processGoal and sets updatedAt', async () => {
+ const hub = makeHub({ id: 'hub-of-one', processGoal: 'old goal' });
+ mocks.loadHub.mockResolvedValue(hub);
+
+ await repo.dispatch({
+ kind: 'HUB_UPDATE_GOAL',
+ hubId: 'hub-of-one',
+ processGoal: 'new goal',
+ });
+
+ expect(mocks.saveHub).toHaveBeenCalledOnce();
+ const saved = mocks.saveHub.mock.calls[0][0] as ProcessHub;
+ expect(saved.processGoal).toBe('new goal');
+ expect(saved.updatedAt).toBe(FROZEN_MS);
+ });
+
+ it('OUTCOME_ADD pushes the new outcome to the outcomes array', async () => {
+ const hub = makeHub({ id: 'hub-of-one', outcomes: [] });
+ mocks.loadHub.mockResolvedValue(hub);
+ const newOutcome = makeOutcome('outcome-new');
+
+ await repo.dispatch({
+ kind: 'OUTCOME_ADD',
+ hubId: 'hub-of-one',
+ outcome: newOutcome,
+ });
+
+ expect(mocks.saveHub).toHaveBeenCalledOnce();
+ const saved = mocks.saveHub.mock.calls[0][0] as ProcessHub;
+ expect(saved.outcomes).toHaveLength(1);
+ expect(saved.outcomes![0].id).toBe('outcome-new');
+ });
+
+ it('OUTCOME_UPDATE patches the target on the matching outcome', async () => {
+ const outcome = makeOutcome('outcome-1');
+ const hub = makeHub({ id: 'hub-of-one', outcomes: [outcome] });
+ mocks.loadHub.mockResolvedValue(hub);
+
+ await repo.dispatch({
+ kind: 'OUTCOME_UPDATE',
+ outcomeId: 'outcome-1',
+ patch: { target: 5 },
+ });
+
+ expect(mocks.saveHub).toHaveBeenCalledOnce();
+ const saved = mocks.saveHub.mock.calls[0][0] as ProcessHub;
+ expect(saved.outcomes![0].target).toBe(5);
+ });
+
+ it('OUTCOME_ARCHIVE soft-marks deletedAt on the matching outcome', async () => {
+ const outcome = makeOutcome('outcome-1', null);
+ const hub = makeHub({ id: 'hub-of-one', outcomes: [outcome] });
+ mocks.loadHub.mockResolvedValue(hub);
+
+ await repo.dispatch({ kind: 'OUTCOME_ARCHIVE', outcomeId: 'outcome-1' });
+
+ expect(mocks.saveHub).toHaveBeenCalledOnce();
+ const saved = mocks.saveHub.mock.calls[0][0] as ProcessHub;
+ expect(saved.outcomes![0].deletedAt).toBe(FROZEN_MS);
+ });
+
+ it('OUTCOME_ARCHIVE is idempotent: second dispatch preserves the first deletedAt', async () => {
+ const outcome = makeOutcome('outcome-1', null);
+ const hub = makeHub({ id: 'hub-of-one', outcomes: [outcome] });
+
+ // First dispatch — captures FROZEN_MS as deletedAt
+ mocks.loadHub.mockResolvedValue(hub);
+ await repo.dispatch({ kind: 'OUTCOME_ARCHIVE', outcomeId: 'outcome-1' });
+ const savedFirst = mocks.saveHub.mock.calls[0][0] as ProcessHub;
+ const firstDeletedAt = savedFirst.outcomes![0].deletedAt;
+ expect(firstDeletedAt).toBe(FROZEN_MS);
+
+ // Advance frozen clock so a second mutation would use a different timestamp
+ vi.advanceTimersByTime(30_000);
+
+ // Second dispatch — loadHub returns the already-archived hub from the first call
+ mocks.saveHub.mockReset();
+ mocks.saveHub.mockResolvedValue(undefined);
+ mocks.loadHub.mockResolvedValue(savedFirst);
+ await repo.dispatch({ kind: 'OUTCOME_ARCHIVE', outcomeId: 'outcome-1' });
+
+ expect(mocks.saveHub).toHaveBeenCalledOnce();
+ const savedSecond = mocks.saveHub.mock.calls[0][0] as ProcessHub;
+ // deletedAt must not be overwritten by the second dispatch
+ expect(savedSecond.outcomes![0].deletedAt).toBe(firstDeletedAt);
+ });
+
+ it('INVESTIGATION_ARCHIVE is a no-op for the PWA blob (investigations are session-only)', async () => {
+ // Contract: INVESTIGATION_ARCHIVE dispatches successfully but does not mutate
+ // the hub blob on PWA, because investigations are not blob-resident (F3 normalizes).
+ // The hub is saved unchanged (same reference equality via Immer structural sharing
+ // is not guaranteed, but the shape must be deep-equal to the input).
+ const outcome = makeOutcome('outcome-1', null);
+ const hub = makeHub({ id: 'hub-of-one', outcomes: [outcome] });
+ mocks.loadHub.mockResolvedValue(hub);
+
+ await repo.dispatch({ kind: 'INVESTIGATION_ARCHIVE', investigationId: 'inv-1' });
+
+ expect(mocks.saveHub).toHaveBeenCalledOnce();
+ const saved = mocks.saveHub.mock.calls[0][0] as ProcessHub;
+ // Hub shape is unchanged — no outcome mutations because investigation cascade
+ // finds no blob-resident children (hub→outcome only cascades when parentKind='hub').
+ expect(saved.outcomes).toHaveLength(1);
+ expect(saved.outcomes![0].deletedAt).toBeNull();
+ });
+
+ it('PLACE_CHIP_ON_STEP is a no-op for the PWA blob (canvas mutations live in canvasStore, R15)', async () => {
+ // Contract: canvas actions dispatch successfully but do not mutate the hub blob.
+ // Canvas state is owned by canvasStore; the blob is overwritten via
+ // HUB_PERSIST_SNAPSHOT when the user saves. See R15 in applyAction.ts.
+ const hub = makeHub({ id: 'hub-of-one', outcomes: [makeOutcome('outcome-1', null)] });
+ mocks.loadHub.mockResolvedValue(hub);
+
+ await repo.dispatch({ kind: 'PLACE_CHIP_ON_STEP', chipId: 'chip-1', stepId: 'step-1' });
+
+ expect(mocks.saveHub).toHaveBeenCalledOnce();
+ const saved = mocks.saveHub.mock.calls[0][0] as ProcessHub;
+ // Hub blob is unchanged — canvas is not a blob-resident structure on PWA.
+ expect(saved).toMatchObject({ id: 'hub-of-one' });
+ expect(saved.outcomes).toHaveLength(1);
+ expect(saved.outcomes![0].deletedAt).toBeNull();
+ });
+ });
+
+ // ---- stub APIs ----
+
+ describe('stub read APIs (F3 not yet implemented)', () => {
+ beforeEach(() => {
+ mocks.loadHub.mockResolvedValue(makeHub());
+ });
+
+ it('evidenceSnapshots.get returns undefined', async () => {
+ expect(await repo.evidenceSnapshots.get('any')).toBeUndefined();
+ });
+
+ it('evidenceSnapshots.listByHub returns []', async () => {
+ expect(await repo.evidenceSnapshots.listByHub('hub-of-one')).toEqual([]);
+ });
+
+ it('evidenceSources.get returns undefined', async () => {
+ expect(await repo.evidenceSources.get('any')).toBeUndefined();
+ });
+
+ it('evidenceSources.listByHub returns []', async () => {
+ expect(await repo.evidenceSources.listByHub('hub-of-one')).toEqual([]);
+ });
+
+ it('evidenceSources.getCursor returns undefined', async () => {
+ expect(await repo.evidenceSources.getCursor('hub-of-one', 'src-1')).toBeUndefined();
+ });
+
+ it('investigations.get returns undefined', async () => {
+ expect(await repo.investigations.get('any')).toBeUndefined();
+ });
+
+ it('investigations.listByHub returns []', async () => {
+ expect(await repo.investigations.listByHub('hub-of-one')).toEqual([]);
+ });
+
+ it('findings.get returns undefined', async () => {
+ expect(await repo.findings.get('any')).toBeUndefined();
+ });
+
+ it('findings.listByInvestigation returns []', async () => {
+ expect(await repo.findings.listByInvestigation('inv-1')).toEqual([]);
+ });
+
+ it('questions.get returns undefined', async () => {
+ expect(await repo.questions.get('any')).toBeUndefined();
+ });
+
+ it('questions.listByInvestigation returns []', async () => {
+ expect(await repo.questions.listByInvestigation('inv-1')).toEqual([]);
+ });
+
+ it('causalLinks.get returns undefined', async () => {
+ expect(await repo.causalLinks.get('any')).toBeUndefined();
+ });
+
+ it('causalLinks.listByInvestigation returns []', async () => {
+ expect(await repo.causalLinks.listByInvestigation('inv-1')).toEqual([]);
+ });
+
+ it('suspectedCauses.get returns undefined', async () => {
+ expect(await repo.suspectedCauses.get('any')).toBeUndefined();
+ });
+
+ it('suspectedCauses.listByInvestigation returns []', async () => {
+ expect(await repo.suspectedCauses.listByInvestigation('inv-1')).toEqual([]);
+ });
+ });
+});
diff --git a/apps/pwa/src/persistence/__tests__/applyAction.test.ts b/apps/pwa/src/persistence/__tests__/applyAction.test.ts
new file mode 100644
index 000000000..f65bf24b6
--- /dev/null
+++ b/apps/pwa/src/persistence/__tests__/applyAction.test.ts
@@ -0,0 +1,638 @@
+// apps/pwa/src/persistence/__tests__/applyAction.test.ts
+//
+// Unit tests for applyAction — per-action Immer recipe dispatcher.
+//
+// Coverage:
+// - Each hub-resident action produces the expected diff
+// - OUTCOME_ARCHIVE soft-marks deletedAt; idempotent if already archived
+// - Non-hub-resident actions return the input hub unchanged (deep-equal)
+// - HUB_PERSIST_SNAPSHOT returns action.hub directly (reference equality)
+// - Canvas actions return input hub unchanged (canvasStore is canonical)
+// - assertNever fires for unhandled actions (TypeScript prevents this in prod)
+// - Cascade helpers via transitiveCascade (P3.3):
+// hub→outcome cascade soft-marks all hub outcomes (only observable path on PWA blob)
+// investigation/finding/question/causalLink/suspectedCause archive actions are no-ops
+// hub→outcome cascade is idempotent (already-archived outcomes keep original timestamp)
+//
+// Determinism: vi.useFakeTimers + vi.setSystemTime pins Date.now() to a fixed value.
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { produce } from 'immer';
+import { applyAction, cascadeArchiveDescendantsInDraft } from '../applyAction';
+import type { ProcessHub, OutcomeSpec } from '@variscout/core/processHub';
+import type { OutcomeAction, HubMetaAction, CanvasAction } from '@variscout/core/actions';
+
+// ---------------------------------------------------------------------------
+// Fixed time for deterministic Date.now() assertions
+// ---------------------------------------------------------------------------
+
+const FIXED_NOW = new Date('2026-05-06T12:00:00.000Z');
+const FIXED_NOW_MS = FIXED_NOW.getTime();
+
+// ---------------------------------------------------------------------------
+// Fixtures
+// ---------------------------------------------------------------------------
+
+const BASE_HUB: ProcessHub = {
+ id: 'hub-001',
+ name: 'Test Hub',
+ createdAt: 1_000_000,
+ deletedAt: null,
+ processGoal: 'Reduce cycle time',
+ primaryScopeDimensions: ['shift'],
+ outcomes: [],
+};
+
+const BASE_OUTCOME: OutcomeSpec = {
+ id: 'outcome-001',
+ hubId: 'hub-001',
+ createdAt: 1_000_000,
+ deletedAt: null,
+ columnName: 'cycle_time',
+ characteristicType: 'smallerIsBetter',
+ target: 10,
+};
+
+const HUB_WITH_OUTCOMES: ProcessHub = {
+ ...BASE_HUB,
+ outcomes: [{ ...BASE_OUTCOME }],
+};
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/** Deep clone a plain object (fixture safety — prevents draft mutations leaking). */
+function clone(obj: T): T {
+ return JSON.parse(JSON.stringify(obj)) as T;
+}
+
+// ---------------------------------------------------------------------------
+// Setup / teardown
+// ---------------------------------------------------------------------------
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(FIXED_NOW);
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+// ---------------------------------------------------------------------------
+// HUB_PERSIST_SNAPSHOT
+// ---------------------------------------------------------------------------
+
+describe('HUB_PERSIST_SNAPSHOT', () => {
+ it('returns action.hub directly (reference equality)', () => {
+ const replacement = clone({ ...BASE_HUB, name: 'Replaced Hub' });
+ const action: HubMetaAction = { kind: 'HUB_PERSIST_SNAPSHOT', hub: replacement };
+ const result = applyAction(clone(BASE_HUB), action);
+ expect(result).toBe(replacement); // reference equality — bypasses produce
+ });
+
+ it('discards any prior hub state', () => {
+ const replacement: ProcessHub = {
+ ...BASE_HUB,
+ processGoal: 'New goal',
+ outcomes: [{ ...BASE_OUTCOME, columnName: 'throughput' }],
+ };
+ const action: HubMetaAction = { kind: 'HUB_PERSIST_SNAPSHOT', hub: replacement };
+ const result = applyAction(clone(HUB_WITH_OUTCOMES), action);
+ expect(result.processGoal).toBe('New goal');
+ expect(result.outcomes).toHaveLength(1);
+ expect(result.outcomes![0].columnName).toBe('throughput');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// HUB_UPDATE_GOAL
+// ---------------------------------------------------------------------------
+
+describe('HUB_UPDATE_GOAL', () => {
+ it('sets processGoal and bumps updatedAt', () => {
+ const action: HubMetaAction = {
+ kind: 'HUB_UPDATE_GOAL',
+ hubId: 'hub-001',
+ processGoal: 'Eliminate defects',
+ };
+ const result = applyAction(clone(BASE_HUB), action);
+ expect(result.processGoal).toBe('Eliminate defects');
+ expect(result.updatedAt).toBe(FIXED_NOW_MS);
+ });
+
+ it('is a no-op when hubId does not match', () => {
+ const action: HubMetaAction = {
+ kind: 'HUB_UPDATE_GOAL',
+ hubId: 'different-hub',
+ processGoal: 'Should not apply',
+ };
+ const input = clone(BASE_HUB);
+ const result = applyAction(input, action);
+ expect(result.processGoal).toBe('Reduce cycle time');
+ expect(result.updatedAt).toBeUndefined();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// HUB_UPDATE_PRIMARY_SCOPE_DIMENSIONS
+// ---------------------------------------------------------------------------
+
+describe('HUB_UPDATE_PRIMARY_SCOPE_DIMENSIONS', () => {
+ it('sets primaryScopeDimensions and bumps updatedAt', () => {
+ const action: HubMetaAction = {
+ kind: 'HUB_UPDATE_PRIMARY_SCOPE_DIMENSIONS',
+ hubId: 'hub-001',
+ dimensions: ['shift', 'line'],
+ };
+ const result = applyAction(clone(BASE_HUB), action);
+ expect(result.primaryScopeDimensions).toEqual(['shift', 'line']);
+ expect(result.updatedAt).toBe(FIXED_NOW_MS);
+ });
+
+ it('is a no-op when hubId does not match', () => {
+ const action: HubMetaAction = {
+ kind: 'HUB_UPDATE_PRIMARY_SCOPE_DIMENSIONS',
+ hubId: 'different-hub',
+ dimensions: ['should-not-apply'],
+ };
+ const input = clone(BASE_HUB);
+ const result = applyAction(input, action);
+ expect(result.primaryScopeDimensions).toEqual(['shift']);
+ expect(result.updatedAt).toBeUndefined();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// OUTCOME_ADD
+// ---------------------------------------------------------------------------
+
+describe('OUTCOME_ADD', () => {
+ it('pushes outcome into outcomes array', () => {
+ const action: OutcomeAction = {
+ kind: 'OUTCOME_ADD',
+ hubId: 'hub-001',
+ outcome: { ...BASE_OUTCOME, id: 'outcome-002', columnName: 'defect_rate' },
+ };
+ const input = clone({ ...BASE_HUB, outcomes: [] });
+ const result = applyAction(input, action);
+ expect(result.outcomes).toHaveLength(1);
+ expect(result.outcomes![0].columnName).toBe('defect_rate');
+ });
+
+ it('initializes outcomes array if undefined', () => {
+ const hubWithoutOutcomes: ProcessHub = {
+ id: 'hub-001',
+ name: 'Test Hub',
+ createdAt: 1_000_000,
+ deletedAt: null,
+ // outcomes intentionally absent
+ };
+ const action: OutcomeAction = {
+ kind: 'OUTCOME_ADD',
+ hubId: 'hub-001',
+ outcome: { ...BASE_OUTCOME },
+ };
+ const result = applyAction(clone(hubWithoutOutcomes), action);
+ expect(result.outcomes).toHaveLength(1);
+ expect(result.outcomes![0].id).toBe('outcome-001');
+ });
+
+ it('throws when hubId mismatches (hub-of-one constraint)', () => {
+ const action: OutcomeAction = {
+ kind: 'OUTCOME_ADD',
+ hubId: 'different-hub',
+ outcome: { ...BASE_OUTCOME },
+ };
+ expect(() => applyAction(clone(BASE_HUB), action)).toThrow('OUTCOME_ADD hubId mismatch');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// OUTCOME_UPDATE
+// ---------------------------------------------------------------------------
+
+describe('OUTCOME_UPDATE', () => {
+ it('merges patch into matching outcome', () => {
+ const action: OutcomeAction = {
+ kind: 'OUTCOME_UPDATE',
+ outcomeId: 'outcome-001',
+ patch: { target: 8, usl: 15 },
+ };
+ const result = applyAction(clone(HUB_WITH_OUTCOMES), action);
+ const updated = result.outcomes!.find(o => o.id === 'outcome-001')!;
+ expect(updated.target).toBe(8);
+ expect(updated.usl).toBe(15);
+ expect(updated.columnName).toBe('cycle_time'); // unchanged
+ });
+
+ it('is a no-op when outcomeId is not found', () => {
+ const action: OutcomeAction = {
+ kind: 'OUTCOME_UPDATE',
+ outcomeId: 'nonexistent',
+ patch: { target: 99 },
+ };
+ const input = clone(HUB_WITH_OUTCOMES);
+ const result = applyAction(input, action);
+ expect(result.outcomes![0].target).toBe(10); // unchanged
+ });
+});
+
+// ---------------------------------------------------------------------------
+// OUTCOME_ARCHIVE
+// ---------------------------------------------------------------------------
+
+describe('OUTCOME_ARCHIVE', () => {
+ it('soft-marks deletedAt on the matching outcome', () => {
+ const action: OutcomeAction = {
+ kind: 'OUTCOME_ARCHIVE',
+ outcomeId: 'outcome-001',
+ };
+ const result = applyAction(clone(HUB_WITH_OUTCOMES), action);
+ const archived = result.outcomes!.find(o => o.id === 'outcome-001')!;
+ expect(archived.deletedAt).toBe(FIXED_NOW_MS);
+ });
+
+ it('is idempotent — already-archived outcome stays unchanged', () => {
+ const alreadyArchived: ProcessHub = {
+ ...BASE_HUB,
+ outcomes: [{ ...BASE_OUTCOME, deletedAt: 999_999 }],
+ };
+ const action: OutcomeAction = {
+ kind: 'OUTCOME_ARCHIVE',
+ outcomeId: 'outcome-001',
+ };
+ const result = applyAction(clone(alreadyArchived), action);
+ const outcome = result.outcomes!.find(o => o.id === 'outcome-001')!;
+ expect(outcome.deletedAt).toBe(999_999); // preserved — not overwritten
+ });
+
+ it('is a no-op when outcomeId is not found', () => {
+ const action: OutcomeAction = {
+ kind: 'OUTCOME_ARCHIVE',
+ outcomeId: 'nonexistent',
+ };
+ const input = clone(HUB_WITH_OUTCOMES);
+ const result = applyAction(input, action);
+ expect(result.outcomes![0].deletedAt).toBeNull(); // unchanged
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Non-hub-resident actions — return hub unchanged (deep-equal)
+//
+// Each entity type (Investigation, Finding, Question, CausalLink, SuspectedCause,
+// Evidence, EvidenceSource) has required fields beyond EntityBase that are not
+// relevant to the no-op behavior being tested here. We cast with `as unknown as
+// HubAction` to avoid building out full entity fixtures — the hub blob doesn't
+// use these fields anyway (they live in session-only Zustand stores).
+// ---------------------------------------------------------------------------
+
+describe('Non-hub-resident actions return hub unchanged', () => {
+ // Helper type alias for clarity
+ type A = Parameters[1];
+
+ const noopCases: Array<{ label: string; action: A }> = [
+ {
+ label: 'INVESTIGATION_CREATE',
+ action: {
+ kind: 'INVESTIGATION_CREATE',
+ hubId: 'hub-001',
+ investigation: { id: 'inv-001', createdAt: 0, deletedAt: null, name: 'I', updatedAt: 0 },
+ } as unknown as A,
+ },
+ {
+ label: 'INVESTIGATION_UPDATE_METADATA',
+ action: { kind: 'INVESTIGATION_UPDATE_METADATA', investigationId: 'inv-001', patch: {} } as A,
+ },
+ {
+ label: 'INVESTIGATION_ARCHIVE',
+ action: { kind: 'INVESTIGATION_ARCHIVE', investigationId: 'inv-001' } as A,
+ },
+ {
+ label: 'FINDING_ADD',
+ action: {
+ kind: 'FINDING_ADD',
+ investigationId: 'inv-001',
+ finding: { id: 'f-001', createdAt: 0, deletedAt: null },
+ } as unknown as A,
+ },
+ {
+ label: 'FINDING_UPDATE',
+ action: { kind: 'FINDING_UPDATE', findingId: 'f-001', patch: {} } as A,
+ },
+ {
+ label: 'FINDING_ARCHIVE',
+ action: { kind: 'FINDING_ARCHIVE', findingId: 'f-001' } as A,
+ },
+ {
+ label: 'QUESTION_ADD',
+ action: {
+ kind: 'QUESTION_ADD',
+ investigationId: 'inv-001',
+ question: { id: 'q-001', createdAt: 0, deletedAt: null },
+ } as unknown as A,
+ },
+ {
+ label: 'QUESTION_UPDATE',
+ action: { kind: 'QUESTION_UPDATE', questionId: 'q-001', patch: {} } as A,
+ },
+ {
+ label: 'QUESTION_ARCHIVE',
+ action: { kind: 'QUESTION_ARCHIVE', questionId: 'q-001' } as A,
+ },
+ {
+ label: 'CAUSAL_LINK_ADD',
+ action: {
+ kind: 'CAUSAL_LINK_ADD',
+ investigationId: 'inv-001',
+ link: { id: 'cl-001', createdAt: 0, deletedAt: null },
+ } as unknown as A,
+ },
+ {
+ label: 'CAUSAL_LINK_UPDATE',
+ action: { kind: 'CAUSAL_LINK_UPDATE', linkId: 'cl-001', patch: {} } as A,
+ },
+ {
+ label: 'CAUSAL_LINK_ARCHIVE',
+ action: { kind: 'CAUSAL_LINK_ARCHIVE', linkId: 'cl-001' } as A,
+ },
+ {
+ label: 'SUSPECTED_CAUSE_ADD',
+ action: {
+ kind: 'SUSPECTED_CAUSE_ADD',
+ investigationId: 'inv-001',
+ cause: { id: 'sc-001', createdAt: 0, deletedAt: null },
+ } as unknown as A,
+ },
+ {
+ label: 'SUSPECTED_CAUSE_UPDATE',
+ action: { kind: 'SUSPECTED_CAUSE_UPDATE', causeId: 'sc-001', patch: {} } as A,
+ },
+ {
+ label: 'SUSPECTED_CAUSE_ARCHIVE',
+ action: { kind: 'SUSPECTED_CAUSE_ARCHIVE', causeId: 'sc-001' } as A,
+ },
+ {
+ label: 'EVIDENCE_ADD_SNAPSHOT',
+ action: {
+ kind: 'EVIDENCE_ADD_SNAPSHOT',
+ hubId: 'hub-001',
+ snapshot: { id: 'es-001', createdAt: 0, deletedAt: null },
+ provenance: [],
+ } as unknown as A,
+ },
+ {
+ label: 'EVIDENCE_ARCHIVE_SNAPSHOT',
+ action: { kind: 'EVIDENCE_ARCHIVE_SNAPSHOT', snapshotId: 'es-001' } as A,
+ },
+ {
+ label: 'EVIDENCE_SOURCE_ADD',
+ action: {
+ kind: 'EVIDENCE_SOURCE_ADD',
+ hubId: 'hub-001',
+ source: { id: 'src-001', createdAt: 0, deletedAt: null },
+ } as unknown as A,
+ },
+ {
+ label: 'EVIDENCE_SOURCE_UPDATE_CURSOR',
+ action: {
+ kind: 'EVIDENCE_SOURCE_UPDATE_CURSOR',
+ sourceId: 'src-001',
+ cursor: {},
+ } as unknown as A,
+ },
+ {
+ label: 'EVIDENCE_SOURCE_REMOVE',
+ action: { kind: 'EVIDENCE_SOURCE_REMOVE', sourceId: 'src-001' } as A,
+ },
+ ];
+
+ for (const { label, action } of noopCases) {
+ it(`${label} — hub returned deep-equal to input`, () => {
+ const input = clone(HUB_WITH_OUTCOMES);
+ const result = applyAction(input, action);
+ expect(result).toEqual(input);
+ });
+ }
+});
+
+// ---------------------------------------------------------------------------
+// Canvas actions — no-ops (canvasStore is the canonical mutation surface)
+// ---------------------------------------------------------------------------
+
+describe('Canvas actions return hub unchanged', () => {
+ const canvasCases: Array<{ label: string; action: CanvasAction }> = [
+ {
+ label: 'PLACE_CHIP_ON_STEP',
+ action: { kind: 'PLACE_CHIP_ON_STEP', chipId: 'c1', stepId: 's1' },
+ },
+ { label: 'UNASSIGN_CHIP', action: { kind: 'UNASSIGN_CHIP', chipId: 'c1' } },
+ {
+ label: 'REORDER_CHIP_IN_STEP',
+ action: { kind: 'REORDER_CHIP_IN_STEP', chipId: 'c1', stepId: 's1', toIndex: 0 },
+ },
+ { label: 'ADD_STEP', action: { kind: 'ADD_STEP', stepName: 'New Step' } },
+ { label: 'REMOVE_STEP', action: { kind: 'REMOVE_STEP', stepId: 's1' } },
+ { label: 'RENAME_STEP', action: { kind: 'RENAME_STEP', stepId: 's1', newName: 'Renamed' } },
+ { label: 'CONNECT_STEPS', action: { kind: 'CONNECT_STEPS', fromStepId: 's1', toStepId: 's2' } },
+ {
+ label: 'DISCONNECT_STEPS',
+ action: { kind: 'DISCONNECT_STEPS', fromStepId: 's1', toStepId: 's2' },
+ },
+ {
+ label: 'GROUP_INTO_SUB_STEP',
+ action: { kind: 'GROUP_INTO_SUB_STEP', stepIds: ['s1', 's2'], parentStepId: 'p1' },
+ },
+ { label: 'UNGROUP_SUB_STEP', action: { kind: 'UNGROUP_SUB_STEP', stepId: 's1' } },
+ ];
+
+ for (const { label, action } of canvasCases) {
+ it(`${label} — hub returned deep-equal to input`, () => {
+ const input = clone(HUB_WITH_OUTCOMES);
+ const result = applyAction(input, action as Parameters[1]);
+ expect(result).toEqual(input);
+ });
+ }
+});
+
+// ---------------------------------------------------------------------------
+// Input immutability — hub is not mutated in place
+// ---------------------------------------------------------------------------
+
+describe('Input immutability', () => {
+ it('does not mutate the input hub (OUTCOME_ADD)', () => {
+ const action: OutcomeAction = {
+ kind: 'OUTCOME_ADD',
+ hubId: 'hub-001',
+ outcome: { ...BASE_OUTCOME, id: 'outcome-002', columnName: 'throughput' },
+ };
+ const input = clone({ ...BASE_HUB, outcomes: [] });
+ const inputCopy = clone(input);
+ applyAction(input, action);
+ expect(input).toEqual(inputCopy); // input unchanged
+ });
+
+ it('does not mutate the input hub (OUTCOME_ARCHIVE)', () => {
+ const action: OutcomeAction = { kind: 'OUTCOME_ARCHIVE', outcomeId: 'outcome-001' };
+ const input = clone(HUB_WITH_OUTCOMES);
+ const inputCopy = clone(input);
+ applyAction(input, action);
+ expect(input).toEqual(inputCopy); // input unchanged
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Cascade helpers (P3.3) — cascadeArchiveDescendantsInDraft
+//
+// PWA cascade paths:
+// hub → outcome OBSERVABLE: only PWA-blob-resident cascade target
+// investigation → … NO-OP: findings/questions/causalLinks/suspectedCauses
+// not on blob
+// evidenceSnapshot → … NO-OP: rowProvenance not on blob
+// evidenceSource → … NO-OP: evidenceSourceCursor not on blob
+// All leaf kinds (outcome, finding, question, etc.) → empty cascade (no-ops)
+// ---------------------------------------------------------------------------
+
+const OUTCOME_A: OutcomeSpec = { ...BASE_OUTCOME, id: 'outcome-a', deletedAt: null };
+const OUTCOME_B: OutcomeSpec = { ...BASE_OUTCOME, id: 'outcome-b', deletedAt: null };
+
+const HUB_TWO_OUTCOMES: ProcessHub = {
+ ...BASE_HUB,
+ outcomes: [clone(OUTCOME_A), clone(OUTCOME_B)],
+};
+
+describe('cascadeArchiveDescendantsInDraft — hub → outcome (observable cascade)', () => {
+ it('soft-marks all hub outcomes when parentKind is hub and parentId matches', () => {
+ const result = produce(clone(HUB_TWO_OUTCOMES), draft => {
+ cascadeArchiveDescendantsInDraft(draft, 'hub', 'hub-001', FIXED_NOW_MS);
+ });
+ expect(result.outcomes![0].deletedAt).toBe(FIXED_NOW_MS);
+ expect(result.outcomes![1].deletedAt).toBe(FIXED_NOW_MS);
+ });
+
+ it('skips outcomes that are already archived (idempotent)', () => {
+ const PRIOR_ARCHIVE_TS = 999_000;
+ const hubWithOneArchived: ProcessHub = {
+ ...BASE_HUB,
+ outcomes: [
+ { ...OUTCOME_A, deletedAt: PRIOR_ARCHIVE_TS }, // already archived
+ { ...OUTCOME_B, deletedAt: null }, // not yet archived
+ ],
+ };
+ const result = produce(clone(hubWithOneArchived), draft => {
+ cascadeArchiveDescendantsInDraft(draft, 'hub', 'hub-001', FIXED_NOW_MS);
+ });
+ // Already-archived outcome keeps original timestamp
+ expect(result.outcomes![0].deletedAt).toBe(PRIOR_ARCHIVE_TS);
+ // Unarchived outcome gets new timestamp
+ expect(result.outcomes![1].deletedAt).toBe(FIXED_NOW_MS);
+ });
+
+ it('idempotency — applying cascade twice leaves timestamps unchanged', () => {
+ const FIRST_ARCHIVE_TS = FIXED_NOW_MS;
+ // First pass
+ const afterFirst = produce(clone(HUB_TWO_OUTCOMES), draft => {
+ cascadeArchiveDescendantsInDraft(draft, 'hub', 'hub-001', FIRST_ARCHIVE_TS);
+ });
+ expect(afterFirst.outcomes![0].deletedAt).toBe(FIRST_ARCHIVE_TS);
+ expect(afterFirst.outcomes![1].deletedAt).toBe(FIRST_ARCHIVE_TS);
+
+ // Second pass with a different timestamp — already-archived outcomes are skipped
+ const SECOND_ARCHIVE_TS = FIXED_NOW_MS + 5_000;
+ const afterSecond = produce(afterFirst, draft => {
+ cascadeArchiveDescendantsInDraft(draft, 'hub', 'hub-001', SECOND_ARCHIVE_TS);
+ });
+ expect(afterSecond.outcomes![0].deletedAt).toBe(FIRST_ARCHIVE_TS); // unchanged
+ expect(afterSecond.outcomes![1].deletedAt).toBe(FIRST_ARCHIVE_TS); // unchanged
+ });
+
+ it('is a no-op when parentId does not match hub.id', () => {
+ const result = produce(clone(HUB_TWO_OUTCOMES), draft => {
+ cascadeArchiveDescendantsInDraft(draft, 'hub', 'different-hub-id', FIXED_NOW_MS);
+ });
+ expect(result.outcomes![0].deletedAt).toBeNull();
+ expect(result.outcomes![1].deletedAt).toBeNull();
+ });
+
+ it('is a no-op when hub has no outcomes', () => {
+ const result = produce(clone(BASE_HUB), draft => {
+ cascadeArchiveDescendantsInDraft(draft, 'hub', 'hub-001', FIXED_NOW_MS);
+ });
+ // No crash; outcomes still undefined/empty
+ expect(result.outcomes ?? []).toHaveLength(0);
+ });
+});
+
+describe('cascadeArchiveDescendantsInDraft — non-hub parents are no-ops on PWA blob', () => {
+ // For each of these parent kinds, the transitive cascade descendants are not
+ // resident on the PWA blob, so the hub should be returned deep-equal to input.
+
+ const noopParents = [
+ { parentKind: 'investigation' as const, parentId: 'inv-001' },
+ { parentKind: 'finding' as const, parentId: 'f-001' },
+ { parentKind: 'question' as const, parentId: 'q-001' },
+ { parentKind: 'causalLink' as const, parentId: 'cl-001' },
+ { parentKind: 'suspectedCause' as const, parentId: 'sc-001' },
+ { parentKind: 'evidenceSnapshot' as const, parentId: 'snap-001' },
+ { parentKind: 'evidenceSource' as const, parentId: 'src-001' },
+ { parentKind: 'outcome' as const, parentId: 'outcome-001' }, // leaf — cascadesTo = []
+ ] as const;
+
+ for (const { parentKind, parentId } of noopParents) {
+ it(`${parentKind} cascade leaves hub deep-equal to input`, () => {
+ const input = clone(HUB_TWO_OUTCOMES);
+ const result = produce(input, draft => {
+ cascadeArchiveDescendantsInDraft(draft, parentKind, parentId, FIXED_NOW_MS);
+ });
+ expect(result).toEqual(input);
+ });
+ }
+});
+
+describe('cascadeArchiveDescendantsInDraft — INVESTIGATION_ARCHIVE action is no-op via helper', () => {
+ // The archive helpers (archiveInvestigationInDraft etc.) call
+ // cascadeArchiveDescendantsInDraft internally. Confirm via action dispatch
+ // that the returned hub is deep-equal (the cascade walk is a no-op on PWA blob).
+ type A = Parameters[1];
+
+ const archiveCases: Array<{ label: string; action: A }> = [
+ {
+ label: 'INVESTIGATION_ARCHIVE',
+ action: { kind: 'INVESTIGATION_ARCHIVE', investigationId: 'inv-001' } as A,
+ },
+ {
+ label: 'FINDING_ARCHIVE',
+ action: { kind: 'FINDING_ARCHIVE', findingId: 'f-001' } as A,
+ },
+ {
+ label: 'QUESTION_ARCHIVE',
+ action: { kind: 'QUESTION_ARCHIVE', questionId: 'q-001' } as A,
+ },
+ {
+ label: 'CAUSAL_LINK_ARCHIVE',
+ action: { kind: 'CAUSAL_LINK_ARCHIVE', linkId: 'cl-001' } as A,
+ },
+ {
+ label: 'SUSPECTED_CAUSE_ARCHIVE',
+ action: { kind: 'SUSPECTED_CAUSE_ARCHIVE', causeId: 'sc-001' } as A,
+ },
+ {
+ label: 'EVIDENCE_ARCHIVE_SNAPSHOT',
+ action: { kind: 'EVIDENCE_ARCHIVE_SNAPSHOT', snapshotId: 'snap-001' } as A,
+ },
+ ];
+
+ for (const { label, action } of archiveCases) {
+ it(`${label} — hub outcomes remain unchanged (cascade is no-op on PWA blob)`, () => {
+ const input = clone(HUB_TWO_OUTCOMES);
+ const result = applyAction(input, action);
+ // outcomes must not be touched — cascade walk found no blob-resident descendants
+ expect(result.outcomes![0].deletedAt).toBeNull();
+ expect(result.outcomes![1].deletedAt).toBeNull();
+ // full hub deep-equals input
+ expect(result).toEqual(input);
+ });
+ }
+});
diff --git a/apps/pwa/src/persistence/applyAction.ts b/apps/pwa/src/persistence/applyAction.ts
new file mode 100644
index 000000000..07e62dd5c
--- /dev/null
+++ b/apps/pwa/src/persistence/applyAction.ts
@@ -0,0 +1,450 @@
+// apps/pwa/src/persistence/applyAction.ts
+//
+// Per-action Immer recipe dispatcher for the PWA hub blob.
+//
+// PWA PERSISTENCE MODEL (Hub-of-one blob):
+// The hub blob (ProcessHub) contains: name, processGoal, outcomes, primaryScopeDimensions,
+// canonicalProcessMap, reviewSignal, plus EntityBase fields. It does NOT contain
+// investigations, findings, questions, causalLinks, suspectedCauses, evidenceSnapshots,
+// or evidenceSources — those live in session-only Zustand stores today.
+//
+// CASCADE STRATEGY:
+// Cascade helpers use transitiveCascade() from @variscout/core/persistence to walk
+// descendant kinds. On the PWA blob, only hub→outcome produces observable mutations;
+// all other parent kinds are structurally correct but yield no mutations because their
+// arrays do not exist on the blob. F3 normalization will add Dexie queries for the rest.
+//
+// CANVAS STRATEGY (R15):
+// Canvas mutations live in canvasStore; the hub's canonicalProcessMap is overwritten
+// via HUB_PERSIST_SNAPSHOT when the user saves. All CANVAS_* cases are therefore no-ops
+// here. F3 may revisit if canvas state is normalized into its own Dexie table.
+//
+// NON-HUB-RESIDENT ACTIONS:
+// INVESTIGATION_*, FINDING_*, QUESTION_*, CAUSAL_LINK_*, SUSPECTED_CAUSE_*,
+// EVIDENCE_*, and EVIDENCE_SOURCE_* handlers exist for type-exhaustiveness (TypeScript
+// validates the switch is exhaustive) but their bodies are no-ops. The dispatch still
+// satisfies the HubRepository contract; the entities live in session-only stores.
+// F3 normalization will give these handlers real bodies.
+
+import { produce, type Draft } from 'immer';
+import type { HubAction } from '@variscout/core/actions';
+import type { ProcessHub } from '@variscout/core/processHub';
+import { transitiveCascade, type EntityKind } from '@variscout/core/persistence';
+
+// ---------------------------------------------------------------------------
+// Exhaustiveness helper
+// ---------------------------------------------------------------------------
+
+function assertNever(x: never): never {
+ throw new Error(`Unhandled action: ${JSON.stringify(x)}`);
+}
+
+// ---------------------------------------------------------------------------
+// Cascade helpers
+//
+// PWA blob holds only `outcomes` and `canonicalProcessMap`. Investigations,
+// findings, questions, causalLinks, suspectedCauses, evidenceSnapshots,
+// evidenceSources, rowProvenance, evidenceSourceCursors, and canvasState live
+// in session-only Zustand stores (or Dexie tables introduced in F3) — the walk
+// below is structurally correct but yields zero mutations on the PWA blob today
+// for all parent kinds except hub→outcome. F3 normalization will fill in the
+// remaining cases with real Dexie queries; the helper shape is already correct.
+// ---------------------------------------------------------------------------
+
+/**
+ * Soft-mark descendants of a single EntityKind on the draft hub.
+ *
+ * The switch below covers every EntityKind. The only PWA-blob-resident kind
+ * that can be cascade-targeted is `outcome` (via hub→outcome). All other kinds
+ * are documented no-ops for PWA; F3 will add Dexie queries for the rest.
+ *
+ * The exhaustive switch (with no `default`) means TypeScript will error if a
+ * new EntityKind is added to cascadeRules without updating this function.
+ */
+function archiveDescendantsOfKindInDraft(
+ draft: Draft,
+ kind: EntityKind,
+ parentKind: EntityKind,
+ parentId: string,
+ archivedAt: number
+): void {
+ switch (kind) {
+ case 'outcome': {
+ // hub.outcomes is the only cascade target resident on the PWA blob.
+ // Soft-mark all unarchived outcomes whose hubId matches the parent.
+ if (parentKind === 'hub' && draft.id === parentId) {
+ for (const outcome of draft.outcomes ?? []) {
+ if (outcome.deletedAt === null) outcome.deletedAt = archivedAt;
+ }
+ }
+ return;
+ }
+ case 'hub':
+ case 'investigation':
+ case 'finding':
+ case 'question':
+ case 'causalLink':
+ case 'suspectedCause':
+ case 'evidenceSnapshot':
+ case 'evidenceSource':
+ case 'rowProvenance':
+ case 'evidenceSourceCursor':
+ case 'canvasState':
+ // PWA blob does not persist these arrays today; F3 will fill in.
+ return;
+ }
+}
+
+/**
+ * Walks the transitive cascade rules for the given parent and soft-marks
+ * matching descendants on the draft hub.
+ *
+ * On PWA, only the hub→outcome cascade has observable mutations. All other
+ * parent kinds (investigation, evidenceSnapshot, evidenceSource, …) are
+ * structurally correct but yield zero mutations because their descendant
+ * arrays do not exist on the PWA blob. F3 normalizes.
+ *
+ * @internal Exported for unit-testing only. Do not call from app code.
+ */
+export function cascadeArchiveDescendantsInDraft(
+ draft: Draft,
+ parentKind: EntityKind,
+ parentId: string,
+ archivedAt: number
+): void {
+ const descendantKinds = transitiveCascade(parentKind);
+ for (const kind of descendantKinds) {
+ archiveDescendantsOfKindInDraft(draft, kind, parentKind, parentId, archivedAt);
+ }
+}
+
+/**
+ * Archive an investigation and its cascade descendants (finding, question,
+ * causalLink, suspectedCause) on the draft hub.
+ *
+ * PWA blob has no investigations array; the parent soft-mark and the cascade
+ * walk are both no-ops today. F3 normalizes.
+ */
+function archiveInvestigationInDraft(draft: Draft, investigationId: string): void {
+ // PWA blob has no investigations array; the parent soft-mark is a no-op today.
+ // The cascade walk also yields no mutations on the PWA blob. F3 normalizes.
+ const archivedAt = Date.now();
+ cascadeArchiveDescendantsInDraft(draft, 'investigation', investigationId, archivedAt);
+}
+
+/**
+ * Archive a finding and its cascade descendants on the draft hub.
+ *
+ * PWA blob has no findings array; both the parent soft-mark and cascade walk
+ * are no-ops today. F3 normalizes.
+ */
+function archiveFindingInDraft(draft: Draft, findingId: string): void {
+ // PWA blob has no findings array; the parent soft-mark is a no-op today.
+ // The cascade walk also yields no mutations on the PWA blob. F3 normalizes.
+ const archivedAt = Date.now();
+ cascadeArchiveDescendantsInDraft(draft, 'finding', findingId, archivedAt);
+}
+
+/**
+ * Archive a question and its cascade descendants on the draft hub.
+ *
+ * PWA blob has no questions array; both the parent soft-mark and cascade walk
+ * are no-ops today. F3 normalizes.
+ */
+function archiveQuestionInDraft(draft: Draft, questionId: string): void {
+ // PWA blob has no questions array; the parent soft-mark is a no-op today.
+ // The cascade walk also yields no mutations on the PWA blob. F3 normalizes.
+ const archivedAt = Date.now();
+ cascadeArchiveDescendantsInDraft(draft, 'question', questionId, archivedAt);
+}
+
+/**
+ * Archive a causal link and its cascade descendants on the draft hub.
+ *
+ * PWA blob has no causalLinks array; both the parent soft-mark and cascade walk
+ * are no-ops today. F3 normalizes.
+ */
+function archiveCausalLinkInDraft(draft: Draft, linkId: string): void {
+ // PWA blob has no causalLinks array; the parent soft-mark is a no-op today.
+ // The cascade walk also yields no mutations on the PWA blob. F3 normalizes.
+ const archivedAt = Date.now();
+ cascadeArchiveDescendantsInDraft(draft, 'causalLink', linkId, archivedAt);
+}
+
+/**
+ * Archive a suspected cause and its cascade descendants on the draft hub.
+ *
+ * PWA blob has no suspectedCauses array; both the parent soft-mark and cascade
+ * walk are no-ops today. F3 normalizes.
+ */
+function archiveSuspectedCauseInDraft(draft: Draft, causeId: string): void {
+ // PWA blob has no suspectedCauses array; the parent soft-mark is a no-op today.
+ // The cascade walk also yields no mutations on the PWA blob. F3 normalizes.
+ const archivedAt = Date.now();
+ cascadeArchiveDescendantsInDraft(draft, 'suspectedCause', causeId, archivedAt);
+}
+
+// ---------------------------------------------------------------------------
+// applyAction
+// ---------------------------------------------------------------------------
+
+/**
+ * Apply a single HubAction to a ProcessHub snapshot and return the next snapshot.
+ * Pure function — no side effects, no I/O.
+ *
+ * Hub-resident actions produce real Immer mutations.
+ * Non-hub-resident actions are no-ops (documented above).
+ * Canvas actions are no-ops (canvasStore is the canonical mutation surface).
+ * Exhaustiveness is enforced at the TypeScript level via assertNever().
+ */
+export function applyAction(hub: ProcessHub, action: HubAction): ProcessHub {
+ // HUB_PERSIST_SNAPSHOT is a full replacement — bypass produce for clarity.
+ if (action.kind === 'HUB_PERSIST_SNAPSHOT') {
+ return action.hub;
+ }
+
+ return produce(hub, draft => {
+ switch (action.kind) {
+ // -----------------------------------------------------------------------
+ // Hub meta — hub-resident real mutations
+ // -----------------------------------------------------------------------
+
+ case 'HUB_UPDATE_GOAL': {
+ if (action.hubId !== draft.id) break; // defensive: should never happen in hub-of-one
+ draft.processGoal = action.processGoal;
+ draft.updatedAt = Date.now();
+ break;
+ }
+
+ case 'HUB_UPDATE_PRIMARY_SCOPE_DIMENSIONS': {
+ if (action.hubId !== draft.id) break; // defensive: should never happen in hub-of-one
+ draft.primaryScopeDimensions = action.dimensions;
+ draft.updatedAt = Date.now();
+ break;
+ }
+
+ // -----------------------------------------------------------------------
+ // Outcomes — hub-resident real mutations
+ // -----------------------------------------------------------------------
+
+ case 'OUTCOME_ADD': {
+ if (action.hubId !== draft.id) {
+ // Loud failure: hub-of-one constraint — foreign outcomes must never arrive.
+ throw new Error(`OUTCOME_ADD hubId mismatch: expected ${draft.id}, got ${action.hubId}`);
+ }
+ if (!draft.outcomes) {
+ draft.outcomes = [];
+ }
+ draft.outcomes.push(action.outcome);
+ break;
+ }
+
+ case 'OUTCOME_UPDATE': {
+ const outcome = draft.outcomes?.find(o => o.id === action.outcomeId);
+ if (!outcome) break; // no-op per Immer recipe pattern
+ Object.assign(outcome, action.patch);
+ break;
+ }
+
+ case 'OUTCOME_ARCHIVE': {
+ const outcome = draft.outcomes?.find(o => o.id === action.outcomeId);
+ if (!outcome || outcome.deletedAt !== null) break; // idempotent
+ outcome.deletedAt = Date.now();
+ // outcome has no cascadesTo descendants (cascadeRules.outcome.cascadesTo === [])
+ break;
+ }
+
+ // -----------------------------------------------------------------------
+ // Investigations — PWA blob does not persist investigations today; F3 normalizes.
+ // -----------------------------------------------------------------------
+
+ case 'INVESTIGATION_CREATE': {
+ // PWA blob does not persist investigations today; F3 normalizes.
+ break;
+ }
+
+ case 'INVESTIGATION_UPDATE_METADATA': {
+ // PWA blob does not persist investigations today; F3 normalizes.
+ break;
+ }
+
+ case 'INVESTIGATION_ARCHIVE': {
+ // PWA blob does not persist investigations today; F3 normalizes.
+ archiveInvestigationInDraft(draft, action.investigationId);
+ break;
+ }
+
+ // -----------------------------------------------------------------------
+ // Findings — PWA blob does not persist findings today; F3 normalizes.
+ // -----------------------------------------------------------------------
+
+ case 'FINDING_ADD': {
+ // PWA blob does not persist findings today; F3 normalizes.
+ break;
+ }
+
+ case 'FINDING_UPDATE': {
+ // PWA blob does not persist findings today; F3 normalizes.
+ break;
+ }
+
+ case 'FINDING_ARCHIVE': {
+ // PWA blob does not persist findings today; F3 normalizes.
+ archiveFindingInDraft(draft, action.findingId);
+ break;
+ }
+
+ // -----------------------------------------------------------------------
+ // Questions — PWA blob does not persist questions today; F3 normalizes.
+ // -----------------------------------------------------------------------
+
+ case 'QUESTION_ADD': {
+ // PWA blob does not persist questions today; F3 normalizes.
+ break;
+ }
+
+ case 'QUESTION_UPDATE': {
+ // PWA blob does not persist questions today; F3 normalizes.
+ break;
+ }
+
+ case 'QUESTION_ARCHIVE': {
+ // PWA blob does not persist questions today; F3 normalizes.
+ archiveQuestionInDraft(draft, action.questionId);
+ break;
+ }
+
+ // -----------------------------------------------------------------------
+ // Causal links — PWA blob does not persist causalLinks today; F3 normalizes.
+ // -----------------------------------------------------------------------
+
+ case 'CAUSAL_LINK_ADD': {
+ // PWA blob does not persist causalLinks today; F3 normalizes.
+ break;
+ }
+
+ case 'CAUSAL_LINK_UPDATE': {
+ // PWA blob does not persist causalLinks today; F3 normalizes.
+ break;
+ }
+
+ case 'CAUSAL_LINK_ARCHIVE': {
+ // PWA blob does not persist causalLinks today; F3 normalizes.
+ archiveCausalLinkInDraft(draft, action.linkId);
+ break;
+ }
+
+ // -----------------------------------------------------------------------
+ // Suspected causes — PWA blob does not persist suspectedCauses today; F3 normalizes.
+ // -----------------------------------------------------------------------
+
+ case 'SUSPECTED_CAUSE_ADD': {
+ // PWA blob does not persist suspectedCauses today; F3 normalizes.
+ break;
+ }
+
+ case 'SUSPECTED_CAUSE_UPDATE': {
+ // PWA blob does not persist suspectedCauses today; F3 normalizes.
+ break;
+ }
+
+ case 'SUSPECTED_CAUSE_ARCHIVE': {
+ // PWA blob does not persist suspectedCauses today; F3 normalizes.
+ archiveSuspectedCauseInDraft(draft, action.causeId);
+ break;
+ }
+
+ // -----------------------------------------------------------------------
+ // Evidence snapshots — PWA blob does not persist evidenceSnapshots today; F3 normalizes.
+ // -----------------------------------------------------------------------
+
+ case 'EVIDENCE_ADD_SNAPSHOT': {
+ // PWA blob does not persist evidenceSnapshots today; F3 normalizes.
+ break;
+ }
+
+ case 'EVIDENCE_ARCHIVE_SNAPSHOT': {
+ // PWA blob does not persist evidenceSnapshots today; F3 normalizes.
+ break;
+ }
+
+ // -----------------------------------------------------------------------
+ // Evidence sources — PWA blob does not persist evidenceSources today; F3 normalizes.
+ // -----------------------------------------------------------------------
+
+ case 'EVIDENCE_SOURCE_ADD': {
+ // PWA blob does not persist evidenceSources today; F3 normalizes.
+ break;
+ }
+
+ case 'EVIDENCE_SOURCE_UPDATE_CURSOR': {
+ // PWA blob does not persist evidenceSourceCursors today; F3 normalizes.
+ break;
+ }
+
+ case 'EVIDENCE_SOURCE_REMOVE': {
+ // PWA blob does not persist evidenceSources today; F3 normalizes.
+ break;
+ }
+
+ // -----------------------------------------------------------------------
+ // Canvas actions — no-ops: canvasStore is the canonical mutation surface.
+ // Canvas mutations live in canvasStore; PWA persists via HUB_PERSIST_SNAPSHOT
+ // carrying the assembled canonicalProcessMap. F3 may revisit.
+ // -----------------------------------------------------------------------
+
+ case 'PLACE_CHIP_ON_STEP': {
+ // Canvas mutations live in canvasStore; PWA persists via HUB_PERSIST_SNAPSHOT.
+ break;
+ }
+
+ case 'UNASSIGN_CHIP': {
+ // Canvas mutations live in canvasStore; PWA persists via HUB_PERSIST_SNAPSHOT.
+ break;
+ }
+
+ case 'REORDER_CHIP_IN_STEP': {
+ // Canvas mutations live in canvasStore; PWA persists via HUB_PERSIST_SNAPSHOT.
+ break;
+ }
+
+ case 'ADD_STEP': {
+ // Canvas mutations live in canvasStore; PWA persists via HUB_PERSIST_SNAPSHOT.
+ break;
+ }
+
+ case 'REMOVE_STEP': {
+ // Canvas mutations live in canvasStore; PWA persists via HUB_PERSIST_SNAPSHOT.
+ break;
+ }
+
+ case 'RENAME_STEP': {
+ // Canvas mutations live in canvasStore; PWA persists via HUB_PERSIST_SNAPSHOT.
+ break;
+ }
+
+ case 'CONNECT_STEPS': {
+ // Canvas mutations live in canvasStore; PWA persists via HUB_PERSIST_SNAPSHOT.
+ break;
+ }
+
+ case 'DISCONNECT_STEPS': {
+ // Canvas mutations live in canvasStore; PWA persists via HUB_PERSIST_SNAPSHOT.
+ break;
+ }
+
+ case 'GROUP_INTO_SUB_STEP': {
+ // Canvas mutations live in canvasStore; PWA persists via HUB_PERSIST_SNAPSHOT.
+ break;
+ }
+
+ case 'UNGROUP_SUB_STEP': {
+ // Canvas mutations live in canvasStore; PWA persists via HUB_PERSIST_SNAPSHOT.
+ break;
+ }
+
+ default:
+ assertNever(action);
+ }
+ });
+}
diff --git a/apps/pwa/src/persistence/index.ts b/apps/pwa/src/persistence/index.ts
new file mode 100644
index 000000000..f4df17e25
--- /dev/null
+++ b/apps/pwa/src/persistence/index.ts
@@ -0,0 +1,5 @@
+// apps/pwa/src/persistence/index.ts
+// Barrel: re-exports the class, singleton, and applyAction stub.
+
+export { PwaHubRepository, pwaHubRepository } from './PwaHubRepository';
+export { applyAction } from './applyAction';
diff --git a/packages/stores/CLAUDE.md b/packages/stores/CLAUDE.md
index 99c3e93c5..98e13d6bd 100644
--- a/packages/stores/CLAUDE.md
+++ b/packages/stores/CLAUDE.md
@@ -12,10 +12,12 @@
## Invariants
- `sessionStore` auto-persists via idb-keyval middleware. Domain stores (project/investigation/improvement) persist at document-level via `useProjectActions`.
-- `wallLayoutStore` persists to a dedicated Dexie DB (`variscout-wall-layout`, distinct from `VaRiScoutAzure`) keyed by `projectId`. Call `rehydrateWallLayout(projectId)` on project open, `persistWallLayout(projectId)` debounced on mutations. PWA is session-only (hook is no-op when projectId is null).
+- Domain stores (project/investigation/improvement/canvas/session) stay persistence-free; the dispatch boundary lives at app/UI composition root via `pwaHubRepository.dispatch(action)` (PWA, `apps/pwa/src/persistence/`) and `azureHubRepository.dispatch(action)` (Azure, F2 PR3). `addHubComment` network IO in `investigationStore` is a deliberate exception (optimistic-update IO, not persistence) — see plan audit R14.
+- `canvasStore` exposes its own `dispatch(action: CanvasAction)` as the canvas-state mutation entry (audit R15); per-action methods stay as transitional wrappers for PR2 and are removed in PR3 cleanup.
+- `wallLayoutStore` persists to a dedicated Dexie DB (`variscout-wall-layout`, distinct from `VaRiScoutAzure`) keyed by `projectId`. Call `rehydrateWallLayout(projectId)` on project open, `persistWallLayout(projectId)` debounced on mutations. PWA is session-only (hook is no-op when projectId is null). Whitelisted from F2's "no `dexie` outside persistence" ESLint rule because it operates a separate DB for cross-app UI state — audit R12.
- Testing pattern: `beforeEach(() => useStore.setState(useStore.getInitialState()))` to reset between tests. Selectors tested as pure functions.
- Cross-store reads: `otherStore.getState()` inside a selector is allowed but should be mocked in tests.
-- Complete list of stores: `projectStore`, `investigationStore`, `improvementStore`, `sessionStore`, `wallLayoutStore`.
+- Complete list of stores: `projectStore`, `investigationStore`, `improvementStore`, `sessionStore`, `canvasStore`, `wallLayoutStore`.
## Test command
diff --git a/packages/stores/src/__tests__/canvasStore.test.ts b/packages/stores/src/__tests__/canvasStore.test.ts
index 5c6aa1bbe..9d21d09b7 100644
--- a/packages/stores/src/__tests__/canvasStore.test.ts
+++ b/packages/stores/src/__tests__/canvasStore.test.ts
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { useCanvasStore } from '../canvasStore';
+import type { CanvasAction } from '@variscout/core/actions';
function resetCanvasStore() {
useCanvasStore.setState(useCanvasStore.getInitialState());
@@ -449,3 +450,155 @@ describe('canvasStore history controls', () => {
expect(useCanvasStore.getState().redoDepth()).toBe(0);
});
});
+
+describe('canvasStore dispatch', () => {
+ it('PLACE_CHIP_ON_STEP routes through placeChipOnStep', () => {
+ useCanvasStore.getState().addStep('Step A');
+ const stepId = useCanvasStore.getState().canonicalMap.nodes[0]!.id;
+
+ const action: CanvasAction = { kind: 'PLACE_CHIP_ON_STEP', chipId: 'chip-1', stepId };
+ useCanvasStore.getState().dispatch(action);
+
+ expect(useCanvasStore.getState().canonicalMap.assignments?.['chip-1']).toBe(stepId);
+ });
+
+ it('UNASSIGN_CHIP routes through unassignChip', () => {
+ useCanvasStore.getState().addStep('Step A');
+ const stepId = useCanvasStore.getState().canonicalMap.nodes[0]!.id;
+ useCanvasStore.getState().placeChipOnStep('chip-1', stepId);
+
+ const action: CanvasAction = { kind: 'UNASSIGN_CHIP', chipId: 'chip-1' };
+ useCanvasStore.getState().dispatch(action);
+
+ expect(useCanvasStore.getState().canonicalMap.assignments?.['chip-1']).toBeUndefined();
+ });
+
+ it('REORDER_CHIP_IN_STEP routes through reorderChipInStep (stable no-op)', () => {
+ useCanvasStore.getState().addStep('Step A');
+ const stepId = useCanvasStore.getState().canonicalMap.nodes[0]!.id;
+ useCanvasStore.getState().placeChipOnStep('chip-1', stepId);
+ const version = useCanvasStore.getState().canonicalMapVersion;
+
+ const action: CanvasAction = {
+ kind: 'REORDER_CHIP_IN_STEP',
+ chipId: 'chip-1',
+ stepId,
+ toIndex: 2,
+ };
+ useCanvasStore.getState().dispatch(action);
+
+ // reorderChipInStep is a stable no-op — version must not change
+ expect(useCanvasStore.getState().canonicalMapVersion).toBe(version);
+ });
+
+ it('ADD_STEP routes through addStep and creates a new node', () => {
+ const action: CanvasAction = { kind: 'ADD_STEP', stepName: 'Weld' };
+ useCanvasStore.getState().dispatch(action);
+
+ expect(useCanvasStore.getState().canonicalMap.nodes).toHaveLength(1);
+ expect(useCanvasStore.getState().canonicalMap.nodes[0]?.name).toBe('Weld');
+ });
+
+ it('REMOVE_STEP routes through removeStep', () => {
+ useCanvasStore.getState().addStep('Remove Me');
+ const stepId = useCanvasStore.getState().canonicalMap.nodes[0]!.id;
+
+ const action: CanvasAction = { kind: 'REMOVE_STEP', stepId };
+ useCanvasStore.getState().dispatch(action);
+
+ expect(useCanvasStore.getState().canonicalMap.nodes).toHaveLength(0);
+ });
+
+ it('RENAME_STEP routes through renameStep', () => {
+ useCanvasStore.getState().addStep('Old Name');
+ const stepId = useCanvasStore.getState().canonicalMap.nodes[0]!.id;
+
+ const action: CanvasAction = { kind: 'RENAME_STEP', stepId, newName: 'New Name' };
+ useCanvasStore.getState().dispatch(action);
+
+ expect(useCanvasStore.getState().canonicalMap.nodes[0]?.name).toBe('New Name');
+ });
+
+ it('CONNECT_STEPS routes through connectSteps', () => {
+ useCanvasStore.getState().addStep('From');
+ useCanvasStore.getState().addStep('To');
+ const [fromNode, toNode] = useCanvasStore.getState().canonicalMap.nodes;
+ const fromStepId = fromNode!.id;
+ const toStepId = toNode!.id;
+
+ const action: CanvasAction = { kind: 'CONNECT_STEPS', fromStepId, toStepId };
+ useCanvasStore.getState().dispatch(action);
+
+ expect(useCanvasStore.getState().canonicalMap.arrows).toHaveLength(1);
+ expect(useCanvasStore.getState().canonicalMap.arrows?.[0]).toMatchObject({
+ fromStepId,
+ toStepId,
+ });
+ });
+
+ it('DISCONNECT_STEPS routes through disconnectSteps', () => {
+ useCanvasStore.getState().addStep('From');
+ useCanvasStore.getState().addStep('To');
+ const [fromNode, toNode] = useCanvasStore.getState().canonicalMap.nodes;
+ const fromStepId = fromNode!.id;
+ const toStepId = toNode!.id;
+ useCanvasStore.getState().connectSteps(fromStepId, toStepId);
+
+ const action: CanvasAction = { kind: 'DISCONNECT_STEPS', fromStepId, toStepId };
+ useCanvasStore.getState().dispatch(action);
+
+ expect(useCanvasStore.getState().canonicalMap.arrows).toHaveLength(0);
+ });
+
+ it('GROUP_INTO_SUB_STEP routes through groupIntoSubStep', () => {
+ useCanvasStore.getState().addStep('Parent');
+ useCanvasStore.getState().addStep('Child');
+ const [parentNode, childNode] = useCanvasStore.getState().canonicalMap.nodes;
+ const parentStepId = parentNode!.id;
+ const childStepId = childNode!.id;
+
+ const action: CanvasAction = {
+ kind: 'GROUP_INTO_SUB_STEP',
+ stepIds: [childStepId],
+ parentStepId,
+ };
+ useCanvasStore.getState().dispatch(action);
+
+ const childAfter = useCanvasStore
+ .getState()
+ .canonicalMap.nodes.find(node => node.id === childStepId);
+ expect(childAfter?.parentStepId).toBe(parentStepId);
+ });
+
+ it('UNGROUP_SUB_STEP routes through ungroupSubStep', () => {
+ useCanvasStore.getState().addStep('Parent');
+ useCanvasStore.getState().addStep('Child');
+ const [parentNode, childNode] = useCanvasStore.getState().canonicalMap.nodes;
+ const parentStepId = parentNode!.id;
+ const childStepId = childNode!.id;
+ useCanvasStore.getState().groupIntoSubStep([childStepId], parentStepId);
+
+ const action: CanvasAction = { kind: 'UNGROUP_SUB_STEP', stepId: childStepId };
+ useCanvasStore.getState().dispatch(action);
+
+ const childAfter = useCanvasStore
+ .getState()
+ .canonicalMap.nodes.find(node => node.id === childStepId);
+ expect(childAfter?.parentStepId).toBeNull();
+ });
+
+ it('dispatch produces the same state as the equivalent direct method call', () => {
+ // Dispatch ADD_STEP and compare to direct addStep result
+ useCanvasStore.getState().dispatch({ kind: 'ADD_STEP', stepName: 'Via Dispatch' });
+ const dispatchedNode = useCanvasStore.getState().canonicalMap.nodes[0];
+
+ resetCanvasStore();
+
+ useCanvasStore.getState().addStep('Via Dispatch');
+ const directNode = useCanvasStore.getState().canonicalMap.nodes[0];
+
+ // Node names match; ids differ due to sequential counters but shape is the same
+ expect(dispatchedNode?.name).toBe(directNode?.name);
+ expect(dispatchedNode?.order).toBe(directNode?.order);
+ });
+});
diff --git a/packages/stores/src/canvasStore.ts b/packages/stores/src/canvasStore.ts
index d77f3e5d5..817eb7e86 100644
--- a/packages/stores/src/canvasStore.ts
+++ b/packages/stores/src/canvasStore.ts
@@ -1,7 +1,8 @@
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
-import type { ProcessHub } from '@variscout/core';
+import { assertNever, type ProcessHub } from '@variscout/core';
+import type { CanvasAction } from '@variscout/core/actions';
import { createEmptyMap, type ProcessMap, type ProcessMapNode } from '@variscout/core/frame';
const HISTORY_CAP = 50;
@@ -26,6 +27,14 @@ export interface CanvasHistoryControls {
export interface CanvasStoreActions {
hydrateCanvasDocument: (snapshot: CanvasHydrationSnapshot) => void;
+ /**
+ * Action-shape entry point. Dispatches the canvas action to the corresponding
+ * method-per-action handler. Per audit R15: per-action methods stay as transitional
+ * wrappers in PR2; PR3 cleanup removes them once consumers migrate to `dispatch`.
+ * Only handles `CanvasAction` kinds — `createStepFromChip` and `hydrateCanvasDocument`
+ * remain method-only because they're not in the canonical CanvasAction union.
+ */
+ dispatch: (action: CanvasAction) => void;
placeChipOnStep: (chipId: string, stepId: string) => void;
unassignChip: (chipId: string) => void;
reorderChipInStep: (chipId: string, stepId: string, toIndex: number) => void;
@@ -198,6 +207,43 @@ export const useCanvasStore = create()(
);
},
+ dispatch: action => {
+ switch (action.kind) {
+ case 'PLACE_CHIP_ON_STEP':
+ get().placeChipOnStep(action.chipId, action.stepId);
+ return;
+ case 'UNASSIGN_CHIP':
+ get().unassignChip(action.chipId);
+ return;
+ case 'REORDER_CHIP_IN_STEP':
+ get().reorderChipInStep(action.chipId, action.stepId, action.toIndex);
+ return;
+ case 'ADD_STEP':
+ get().addStep(action.stepName, action.position);
+ return;
+ case 'REMOVE_STEP':
+ get().removeStep(action.stepId);
+ return;
+ case 'RENAME_STEP':
+ get().renameStep(action.stepId, action.newName);
+ return;
+ case 'CONNECT_STEPS':
+ get().connectSteps(action.fromStepId, action.toStepId);
+ return;
+ case 'DISCONNECT_STEPS':
+ get().disconnectSteps(action.fromStepId, action.toStepId);
+ return;
+ case 'GROUP_INTO_SUB_STEP':
+ get().groupIntoSubStep(action.stepIds, action.parentStepId);
+ return;
+ case 'UNGROUP_SUB_STEP':
+ get().ungroupSubStep(action.stepId);
+ return;
+ default:
+ assertNever(action);
+ }
+ },
+
undo: () => {
const entry = get().undoStack.at(-1);
if (!entry) return;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index efef6207f..e649e50b4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -268,6 +268,9 @@ importers:
html-to-image:
specifier: ^1.11.13
version: 1.11.13
+ immer:
+ specifier: ^11.1.4
+ version: 11.1.4
lucide-react:
specifier: ^1.14.0
version: 1.14.0(react@19.2.5)