import { expect, it } from "vitest"; import { createProcessCaptureJournal } from "../../../src/application/ProcessCaptureJournal.js"; import { compareProcessCaptures, digestProcessCommitment, LEGACY_PROCESS_CAPTURE_MESSAGE, parseProcessCapture, processCaptureSchema, validateProcessCapture, type ProcessCapture, type ProcessCaptureEventJournalEntry, } from "../../../src/domain/processCapture.js"; const emptyCapture = (): ProcessCapture => { const normalization = { paths: true, pids: true, ports: true, time_bucket_ms: 10, patterns: [], }; const scenario = { executable_sha256: "0".repeat(64) }; const comparisonContract = {}; const shimPlan: readonly unknown[] = []; const replayPlan = {}; return { schema_version: 4, manifest: { rea_version: "test", provider_version: "3", platform: process.platform, architecture: process.arch, pty_backend: "node-pty", started_at: "2026-01-01T00:00:00.000Z", completed_at: "2026-01-01T00:00:00.001Z", scenario, comparison_contract: comparisonContract, shim_plan: shimPlan, replay_plan: replayPlan, full_scenario_sha256: digestProcessCommitment(scenario), comparison_contract_sha256: digestProcessCommitment(comparisonContract), executable_sha256: "0".repeat(64), normalization_sha256: digestProcessCommitment(normalization), shim_plan_sha256: digestProcessCommitment(shimPlan), replay_plan_sha256: digestProcessCommitment(replayPlan), }, normalization, frames: [], rendered_frames: [], interaction_events: [], exit: { code: 0, signal: null, reason: "exited" }, settlement: { state: "quiesced", elapsed_ms: 50, cleanup_outcome: "not_required", }, process_samples: [], filesystem_checkpoints: [ { name: "before", at_ms: 0, files: [], effects: [], truncated: false }, { name: "after_settlement", at_ms: 50, files: [], effects: [], truncated: false, }, ], shim_events: [], protocol_events: [], replay_transitions: [], reactive_run: null, event_journal: [], files_before: [], files_after: [], filesystem_effects: [], truncated: false, limitations: [], residual_unknowns: [], cleanup: { owned_process_group: "verified", temporary_root: "removed", }, }; }; it("never considers truncated captures equivalent", () => { const capture = { schema_version: 4 as const, manifest: emptyCapture().manifest, settlement: emptyCapture().settlement, normalization: { paths: true, pids: true, ports: true, time_bucket_ms: 10, patterns: [], }, frames: [], rendered_frames: [], interaction_events: [], exit: { code: 0, signal: null, reason: "exited" as const }, process_samples: [], filesystem_checkpoints: emptyCapture().filesystem_checkpoints, shim_events: [], protocol_events: [], replay_transitions: [], reactive_run: null, files_before: [], files_after: [], filesystem_effects: [], truncated: true, limitations: [], residual_unknowns: [], cleanup: { owned_process_group: "verified" as const, temporary_root: "removed" as const, }, }; expect(compareProcessCaptures(capture, capture).status).toBe("truncated"); }); it("rejects altered v4 commitments and accepts canonical key reordering", () => { const capture = emptyCapture(); expect(parseProcessCapture(capture)).toEqual(capture); expect(digestProcessCommitment({ second: 2, first: 1 })).toBe( digestProcessCommitment({ first: 1, second: 2 }), ); expect(() => parseProcessCapture({ ...capture, manifest: { ...capture.manifest, normalization_sha256: "f".repeat(64), }, }), ).toThrow("normalization_sha256"); expect(() => parseProcessCapture({ ...capture, manifest: { ...capture.manifest, executable_sha256: "f".repeat(64), }, }), ).toThrow("executable_sha256"); }); it("accepts old captures without a journal and validates complete journals", () => { const journal = createProcessCaptureJournal(); const observed: ProcessCaptureEventJournalEntry[] = []; const unsubscribe = journal.subscribe((entry) => observed.push(entry)); journal.recordEvent("lifecycle", 0); const settlementEntry = journal.record("lifecycle", 1); unsubscribe(); journal.recordEvent("frames", 0); expect(journal.entries).toEqual([ { capture_order: 0, collection: "lifecycle", index: 0 }, { capture_order: 1, collection: "lifecycle", index: 1 }, { capture_order: 2, collection: "frames", index: 0 }, ]); expect(observed).toEqual(journal.entries.slice(0, 2)); expect(settlementEntry).toEqual(journal.entries[1]); const nestedJournal = createProcessCaptureJournal(); const notifications: string[] = []; nestedJournal.subscribe((entry) => { notifications.push(`a:${String(entry.capture_order)}`); if (entry.capture_order === 0) nestedJournal.record("frames", 1); }); nestedJournal.subscribe((entry) => notifications.push(`b:${String(entry.capture_order)}`), ); nestedJournal.record("frames", 0); expect(notifications).toEqual(["a:0", "b:0", "a:1", "b:1"]); const capture = emptyCapture(); const { event_journal: _eventJournal, ...oldCapture } = capture; expect(parseProcessCapture(oldCapture).event_journal).toEqual([]); const eventJournal = [ { capture_order: 0, collection: "filesystem_checkpoints", index: 0 }, { capture_order: 1, collection: "lifecycle", index: 0 }, { capture_order: 2, collection: "lifecycle", index: 1 }, { capture_order: 3, collection: "filesystem_checkpoints", index: 1 }, ] as const; expect( validateProcessCapture({ ...capture, event_journal: eventJournal }), ).toEqual([]); for (const [candidate, message] of [ [ eventJournal.map((entry, index) => index === 1 ? { ...entry, capture_order: 2 } : entry, ), "contiguous", ], [ eventJournal.map((entry, index) => index === 3 ? { ...entry, collection: "lifecycle" as const, index: 1 } : entry, ), "unique", ], [eventJournal.slice(0, 3), "every captured observation"], [ eventJournal.map((entry, index) => index === 3 ? { ...entry, index: 2 } : entry, ), "outside", ], ] as const) { expect( validateProcessCapture({ ...capture, event_journal: candidate }), ).toEqual( expect.arrayContaining([ expect.objectContaining({ message: expect.stringContaining(message), }), ]), ); } }); it.each([ ["machine limit", 1, 5, 10], ["transition use limit", 5, 1, 10], ["state visit limit", 5, 5, 1], ])( "rejects replay journals that exceed the declared %s", (_name, maxTransitions, maxUses, maxVisits) => { const replayPlan = { machine: { initial_state: "active", states: [ { name: "active", max_visits: maxVisits }, { name: "complete", terminal: true }, ], transitions: [ { id: "again", from: "active", to: "active", trigger: { protocol: "websocket_message", path: "/ws", body: "again", }, actions: [{ type: "websocket_send", data: "again" }], max_uses: maxUses, }, { id: "finish", from: "active", to: "complete", trigger: { protocol: "websocket_message", path: "/ws", body: "finish", }, actions: [{ type: "websocket_send", data: "done" }], max_uses: 1, }, ], max_transitions: maxTransitions, }, }; const capture = emptyCapture(); const candidate: ProcessCapture = { ...capture, manifest: { ...capture.manifest, replay_plan: replayPlan, replay_plan_sha256: digestProcessCommitment(replayPlan), }, replay_transitions: [0, 1].map((sequence) => ({ sequence, at_ms: sequence, transition_id: "again", state_before: "active", state_after: "active", sensitive_aliases: [], })), }; expect(validateProcessCapture(candidate)).toEqual( expect.arrayContaining([ expect.objectContaining({ message: expect.stringContaining("limit"), }), ]), ); }, ); it("tells agents and users to recapture unsupported v3 evidence", () => { const legacy = { ...emptyCapture(), schema_version: 3 }; expect(() => parseProcessCapture(legacy)).toThrow( LEGACY_PROCESS_CAPTURE_MESSAGE, ); const parsed = processCaptureSchema.safeParse(legacy); expect(parsed.success).toBe(false); if (parsed.success) throw new Error("expected legacy capture rejection"); expect(parsed.error.issues[0]?.message).toBe(LEGACY_PROCESS_CAPTURE_MESSAGE); }); it("requires compatible contracts and enforces capture age through a clock seam", () => { const capture = emptyCapture(); expect(() => compareProcessCaptures(capture, { ...capture, manifest: { ...capture.manifest, comparison_contract: { changed: true }, comparison_contract_sha256: digestProcessCommitment({ changed: true, }), }, }), ).toThrow("incompatible comparison contracts"); expect(() => compareProcessCaptures(capture, capture, { maxCaptureAgeMs: 1, now: () => Date.parse("2026-01-01T00:00:01.000Z"), }), ).toThrow("max_capture_age_ms"); });