feat(process): introduce evidence-safe capture v4

This commit is contained in:
morluto 2026-07-13 18:03:41 +08:00
parent d0e21f9231
commit ee4d284597
22 changed files with 793 additions and 85 deletions

View File

@ -99,6 +99,8 @@ export const startCommandShimReplay = async (
sequence: events.length,
at_ms: Math.max(0, Date.now() - started),
command: invocation.command,
route_index:
routeIndex !== undefined && routeIndex >= 0 ? routeIndex : null,
arguments: invocation.arguments,
working_directory: invocation.working_directory,
outcome,

View File

@ -11,6 +11,10 @@ import {
} from "../domain/evidenceBundle.js";
import { EvidenceFileError, EvidenceIntegrityError } from "../domain/errors.js";
import { err, ok, type Result } from "../domain/result.js";
import {
LEGACY_PROCESS_CAPTURE_MESSAGE,
parseProcessCapture,
} from "../domain/processCapture.js";
type EvidenceReadFailure = EvidenceFileError | EvidenceIntegrityError;
type EvidenceWriteFailure = EvidenceFileError | EvidenceIntegrityError;
@ -42,7 +46,14 @@ export const readEvidenceBundle = async (
const limits = validateJsonLimits(decoded, policy);
if (!limits.ok) return limits;
try {
return ok(parseEvidenceBundle(decoded));
const bundle = parseEvidenceBundle(decoded);
for (const record of bundle.records) {
if (record.predicate_type === "rea.process-capture/v3")
throw new TypeError(LEGACY_PROCESS_CAPTURE_MESSAGE);
if (record.predicate_type === "rea.process-capture/v4")
parseProcessCapture(record.normalized_result);
}
return ok(bundle);
} catch (cause: unknown) {
return err(
new EvidenceIntegrityError("Evidence bundle validation failed", {

View File

@ -5,8 +5,9 @@ import { createEvidence, parseEvidence } from "../domain/evidence.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import {
compareProcessCaptures,
LEGACY_PROCESS_CAPTURE_MESSAGE,
parseProcessScenario,
processCaptureSchema,
parseProcessCapture,
} from "../domain/processCapture.js";
import { captureProcessScenario } from "./ProcessHarness.js";
import {
@ -38,7 +39,7 @@ export const compareProcessEvidenceFiles = async (
const right = parseCaptureEvidence(await readJson(rightPath));
const comparison = compareProcessCaptures(left.capture, right.capture);
return createEvidence(undefined, PROCESS_PROVIDER, {
predicateType: "rea.process-comparison/v2",
predicateType: "rea.process-comparison/v3",
operation: "compare_process_captures",
parameters: {
left_evidence_id: left.id,
@ -58,14 +59,17 @@ const parseCaptureEvidence = (input: unknown) => {
const evidence = parseEvidence(input);
if (
evidence.operation !== "capture_process_scenario" ||
evidence.predicate_type !== "rea.process-capture/v3" ||
evidence.predicate_type !== "rea.process-capture/v4" ||
evidence.provider.id !== PROCESS_PROVIDER.id ||
evidence.provider.version !== PROCESS_PROVIDER.version
)
throw new TypeError("Expected REA Process Capture v3 Evidence");
) {
if (evidence.predicate_type === "rea.process-capture/v3")
throw new TypeError(LEGACY_PROCESS_CAPTURE_MESSAGE);
throw new TypeError("Expected REA Process Capture v4 Evidence");
}
return {
id: evidence.evidence_id,
capture: processCaptureSchema.parse(evidence.normalized_result),
capture: parseProcessCapture(evidence.normalized_result),
};
};

View File

@ -5,12 +5,13 @@ import type {
ProcessCapture,
ProcessScenario,
} from "../domain/processCapture.js";
import { parseProcessCapture } from "../domain/processCapture.js";
/** Provider identity for controlled Process Capture v3 evidence. */
/** Provider identity for controlled Process Capture v4 evidence. */
export const PROCESS_PROVIDER = {
id: "rea-process",
name: "REA deterministic process harness",
version: "2",
version: "3",
} as const;
/** Project one process scenario into secret-free Evidence parameters. */
@ -26,16 +27,17 @@ const processEvidenceParameters = (
normalization: scenario.normalization,
});
/** Create one canonical observed Process Capture v3 Evidence record. */
/** Create one canonical observed Process Capture v4 Evidence record. */
export const createProcessCaptureEvidence = (
scenario: ProcessScenario,
capture: ProcessCapture,
) =>
createEvidence(undefined, PROCESS_PROVIDER, {
predicateType: "rea.process-capture/v3",
) => {
const validatedCapture = parseProcessCapture(capture);
return createEvidence(undefined, PROCESS_PROVIDER, {
predicateType: "rea.process-capture/v4",
operation: "capture_process_scenario",
parameters: processEvidenceParameters(scenario),
result: jsonValueSchema.parse(capture),
result: jsonValueSchema.parse(validatedCapture),
confidence: "observed",
authority: "controlled-replay",
environment: {
@ -44,5 +46,6 @@ export const createProcessCaptureEvidence = (
architecture: process.arch,
isolation: "process",
},
limitations: capture.limitations,
limitations: validatedCapture.limitations,
});
};

View File

@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import { createReadStream } from "node:fs";
import { mkdtemp, realpath, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
@ -12,11 +13,21 @@ import type {
ProcessScenario,
TerminalFrame,
} from "../domain/processCapture.js";
import { authorizeProcessScenario } from "../domain/processCapture.js";
import {
authorizeProcessScenario,
digestProcessCommitment,
processComparisonContract,
processScenarioCommitment,
validateProcessCapture,
} from "../domain/processCapture.js";
import { err, ok, type Result } from "../domain/result.js";
import { AnalysisError } from "../domain/errors.js";
import { PRODUCT_IDENTITY } from "../identity.js";
import { startLoopbackReplay, type LoopbackReplay } from "./LoopbackReplay.js";
import { cleanupOwnedProcessGroup } from "./ProcessOwnership.js";
import {
cleanupOwnedProcessGroup,
observeOwnedProcessGroup,
} from "./ProcessOwnership.js";
import { startProcessSampler } from "./ProcessSampling.js";
import { snapshotRoots, type SnapshotResult } from "./FilesystemSnapshot.js";
import {
@ -33,6 +44,7 @@ import {
normalizeProcessText,
redactProtocolEvents,
} from "./ProcessNormalization.js";
import { PROCESS_PROVIDER } from "./ProcessEvidence.js";
const isWithin = (candidate: string, root: string): boolean =>
candidate === root ||
@ -249,19 +261,31 @@ interface CaptureResultOptions {
readonly interactions: readonly InteractionEvent[];
readonly checkpoints: readonly FilesystemCheckpoint[];
readonly shimEvents: ProcessCapture["shim_events"];
readonly settlement: ProcessCapture["settlement"];
readonly manifest: ProcessCapture["manifest"];
}
const buildCaptureResult = (options: CaptureResultOptions): ProcessCapture => ({
schema_version: 3,
schema_version: 4,
manifest: options.manifest,
normalization: options.scenario.normalization,
frames: options.frames,
rendered_frames: options.renderedFrames,
rendered_frames: [...options.renderedFrames]
.sort(
(left, right) =>
left.at_ms - right.at_ms || left.sequence - right.sequence,
)
.map((frame, sequence) => ({ ...frame, sequence })),
interaction_events: options.interactions,
exit: {
code: options.exit.exitCode < 0 ? null : options.exit.exitCode,
code:
options.exit.reason === "exited" && options.exit.exitCode >= 0
? options.exit.exitCode
: null,
signal: options.exit.signal ?? null,
reason: options.exit.reason,
},
settlement: options.settlement,
process_samples: normalizeProcessSamples(
options.samples,
options.scenario,
@ -304,6 +328,78 @@ const buildCaptureResult = (options: CaptureResultOptions): ProcessCapture => ({
},
});
const hashFile = async (path: string): Promise<string> => {
const hash = createHash("sha256");
for await (const chunk of createReadStream(path)) hash.update(chunk);
return hash.digest("hex");
};
const createRunManifest = async (
scenario: ProcessScenario,
startedAt: Date,
completedAt: Date,
): Promise<ProcessCapture["manifest"]> => {
const executableSha256 = await hashFile(scenario.executable);
const scenarioCommitment = processScenarioCommitment(
scenario,
executableSha256,
);
const comparisonContract = processComparisonContract(scenario);
return {
rea_version: PRODUCT_IDENTITY.packageVersion,
provider_version: PROCESS_PROVIDER.version,
platform: process.platform,
architecture: process.arch,
pty_backend: "node-pty",
started_at: startedAt.toISOString(),
completed_at: completedAt.toISOString(),
scenario: scenarioCommitment,
comparison_contract: comparisonContract,
shim_plan: scenario.command_shims,
replay_plan: scenario.replay,
full_scenario_sha256: digestProcessCommitment(scenarioCommitment),
comparison_contract_sha256: digestProcessCommitment(comparisonContract),
executable_sha256: executableSha256,
normalization_sha256: digestProcessCommitment(scenario.normalization),
shim_plan_sha256: digestProcessCommitment(scenario.command_shims),
replay_plan_sha256: digestProcessCommitment(scenario.replay),
};
};
const observeSettlement = async (
runId: string,
processGroupIds: readonly number[],
settleMs: number,
): Promise<Omit<ProcessCapture["settlement"], "cleanup_outcome">> => {
if (process.platform === "win32")
return { state: "unverifiable", elapsed_ms: 0 };
const started = Date.now();
let consecutiveEmpty = 0;
let deadlineReached = false;
while (!deadlineReached) {
const observations = await Promise.all(
[...new Set(processGroupIds)].map((processGroupId) =>
observeOwnedProcessGroup({
runId,
leaderPid: processGroupId,
processGroupId,
}),
),
);
if (observations.some(({ state }) => state === "unverifiable"))
return { state: "unverifiable", elapsed_ms: Date.now() - started };
if (observations.every(({ state }) => state === "empty")) {
consecutiveEmpty += 1;
if (consecutiveEmpty >= 2)
return { state: "quiesced", elapsed_ms: Date.now() - started };
} else consecutiveEmpty = 0;
deadlineReached = Date.now() - started >= settleMs;
if (deadlineReached) break;
await new Promise<void>((resolve) => setTimeout(resolve, 50));
}
return { state: "alive_at_deadline", elapsed_ms: Date.now() - started };
};
const awaitTerminalExit = async ({
terminal,
scenario,
@ -523,6 +619,7 @@ const runProcessScenario = async (
let checkpoints: ProcessCheckpoints | undefined;
let shimReplay: CommandShimReplay | undefined;
let started = 0;
let startedAt = new Date(0);
let lastOutput = 0;
const timers = new Set<NodeJS.Timeout>();
let capture: ProcessCapture | undefined;
@ -530,11 +627,16 @@ const runProcessScenario = async (
let framesTruncated = (): boolean => false;
let stopSampler = async () => ({ partial: false });
let samplingPartial = false;
let settlement: Omit<ProcessCapture["settlement"], "cleanup_outcome"> = {
state: "unverifiable",
elapsed_ms: 0,
};
const interactions: InteractionEvent[] = [];
const dispatchedEventIndexes = new Set<number>();
try {
replay = await startLoopbackReplay(scenario);
startedAt = new Date();
started = Date.now();
lastOutput = started;
renderer = new TerminalRenderer({
@ -601,8 +703,15 @@ const runProcessScenario = async (
checkpoints.trigger("root_exit");
if (exitReason === "cancelled")
throw new ProcessCaptureError("process capture was cancelled");
await new Promise((resolveSettle) =>
setTimeout(resolveSettle, scenario.settle_ms),
settlement = await observeSettlement(
runId,
[
terminal.pid,
...samples.flatMap(({ process_group_id }) =>
process_group_id === null ? [] : [process_group_id],
),
],
scenario.settle_ms,
);
checkpoints.trigger("settled");
samplingPartial = (await stopSampler()).partial;
@ -610,6 +719,7 @@ const runProcessScenario = async (
const after = await snapshotRoots(scenario, signal);
const renderedFrames = await renderer.frames();
const filesystemCheckpoints = await checkpoints.finish(after);
const manifest = await createRunManifest(scenario, startedAt, new Date());
truncated ||=
after.truncated ||
replay.truncated ||
@ -649,6 +759,8 @@ const runProcessScenario = async (
terminal?.pid ?? -1,
),
})),
settlement: { ...settlement, cleanup_outcome: "not_required" },
manifest,
});
} catch (cause: unknown) {
terminal?.kill("SIGKILL");
@ -668,6 +780,25 @@ const runProcessScenario = async (
process_group_id === null ? [] : [process_group_id],
),
});
if (capture !== undefined) {
capture = {
...capture,
settlement: {
...capture.settlement,
cleanup_outcome:
capture.settlement.state === "quiesced"
? "not_required"
: cleanupFailure === undefined
? "cleaned"
: "failed",
},
};
const issues = validateProcessCapture(capture);
if (issues.length > 0)
executionFailure = new ProcessCaptureError(
`process capture validation failed: ${issues[0]!.path}: ${issues[0]!.message}`,
);
}
return resolveProcessResult(capture, executionFailure, cleanupFailure);
};

View File

@ -34,6 +34,12 @@ export type ProcessCleanupResult =
| { readonly cleaned: true; readonly signaled: boolean }
| { readonly cleaned: false; readonly reason: string };
/** Token-verified liveness result used by post-root-exit settlement. */
export type ProcessGroupObservation =
| { readonly state: "empty" }
| { readonly state: "alive" }
| { readonly state: "unverifiable"; readonly reason: string };
const parseEnvironment = (value: string): Readonly<Record<string, string>> =>
Object.fromEntries(
value
@ -146,6 +152,41 @@ export const cleanupOwnedProcessGroup = async (
return { cleaned: true, signaled: true };
};
/** Observe one group without signaling it, failing closed on identity doubt. */
export const observeOwnedProcessGroup = async (
ownership: OwnedProcessGroup,
host: ProcessOwnershipHost = systemHost,
): Promise<ProcessGroupObservation> => {
let members: readonly ProcessGroupMember[];
try {
members = await host.listMembers(ownership.processGroupId);
} catch {
return {
state: "unverifiable",
reason: "process group could not be inspected",
};
}
if (members.length === 0) return { state: "empty" };
for (const member of members) {
try {
if (
(await host.environment(member.pid)).REA_PROCESS_RUN_ID !==
ownership.runId
)
return {
state: "unverifiable",
reason: "process ownership did not match",
};
} catch {
return {
state: "unverifiable",
reason: "process ownership could not be revalidated",
};
}
}
return { state: "alive" };
};
const commandMatches = (actual: string, expected: string): boolean => {
const normalizedActual = actual.trim();
const normalizedExpected = expected.trim();

View File

@ -7,13 +7,13 @@ import {
import { logCliCommand } from "./cliLogging.js";
import type { Logger } from "./logger.js";
/** Register Process Capture v3 one-shot commands through shared application services. */
/** Register Process Capture v4 one-shot commands through shared application services. */
export const registerProcessCommands = (
cli: ReturnType<typeof Cli.create>,
logger: Logger,
): void => {
cli.command("capture-process", {
description: "Capture one approved Process Capture v3 JSON scenario",
description: "Capture one approved Process Capture v4 JSON scenario",
args: z.object({ scenario: z.string().describe("Scenario JSON path") }),
run: ({ args }) =>
logCliCommand(logger, "capture-process", () =>
@ -21,7 +21,7 @@ export const registerProcessCommands = (
),
});
cli.command("compare-process-captures", {
description: "Compare two Process Capture v3 Evidence JSON files",
description: "Compare two Process Capture v4 Evidence JSON files",
args: z.object({
left: z.string().describe("Left capture Evidence JSON path"),
right: z.string().describe("Right capture Evidence JSON path"),

View File

@ -45,12 +45,12 @@ export const FUNCTION_COMPARISON_EVIDENCE = createEvidence(
const PROCESS_PROVIDER = {
id: "rea-process",
name: "REA deterministic process harness",
version: "2",
version: "3",
} as const;
const capture = processCaptureSchema.parse(EMPTY_PROCESS_CAPTURE_EXAMPLE);
const captureEvidence = (scenario: string) =>
createEvidence(undefined, PROCESS_PROVIDER, {
predicateType: "rea.process-capture/v3",
predicateType: "rea.process-capture/v4",
operation: "capture_process_scenario",
parameters: { scenario },
result: jsonValueSchema.parse(capture),
@ -69,7 +69,7 @@ export const PROCESS_COMPARISON_EVIDENCE = createEvidence(
undefined,
PROCESS_PROVIDER,
{
predicateType: "rea.process-comparison/v2",
predicateType: "rea.process-comparison/v3",
operation: "compare_process_captures",
parameters: {
left_evidence_id: PROCESS_CAPTURE_REFERENCE.evidence_id,

View File

@ -1,21 +1,61 @@
import type { JsonValue } from "../domain/jsonValue.js";
import { digestProcessCommitment } from "../domain/processCapture.js";
const normalization = {
paths: true,
pids: true,
ports: true,
time_bucket_ms: 10,
patterns: [],
};
const scenario = { executable_sha256: "0".repeat(64) };
const comparisonContract = {};
const shimPlan: JsonValue[] = [];
const replayPlan = {};
/** Minimal valid process capture used only in public contract examples. */
export const EMPTY_PROCESS_CAPTURE_EXAMPLE = {
schema_version: 3,
normalization: {
paths: true,
pids: true,
ports: true,
time_bucket_ms: 10,
patterns: [],
schema_version: 4,
manifest: {
rea_version: "1.1.0",
provider_version: "3",
platform: "fixture",
architecture: "fixture",
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: [],
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: [],
files_before: [],

View File

@ -432,17 +432,18 @@ export const SESSION_TOOL_CONTRACTS = [
),
session(
"capture_process_scenario",
"Run one bounded process under a PTY using operator-approved executable and working roots. Requires approved: true; unknown_registry_approved: true separately records capture residuals. Captures raw and xterm-rendered terminal frames, scripted interactions, lifecycle filesystem checkpoints, process ownership, declarative command shims, and loopback replay. Disabled unless operator policy enables it; not a security sandbox.",
"Run one bounded process under a PTY using operator-approved executable and working roots. Produces Process Capture v4; legacy v3 captures cannot be upgraded and must be recaptured with this tool. Requires approved: true; unknown_registry_approved: true separately records capture residuals. Captures raw and xterm-rendered terminal frames, scripted interactions, lifecycle filesystem checkpoints, process ownership, declarative command shims, and loopback replay. Disabled unless operator policy enables it; not a security sandbox.",
processScenarioSchema,
),
session(
"compare_process_captures",
"Compare two Process Capture v3 observations across terminal, interaction, exit, process, filesystem, command-shim, HTTP, and WebSocket evidence, returning the first bounded divergence. Missing or truncated observations are never treated as equivalent.",
"Compare two compatible Process Capture v4 observations across terminal, interaction, exit, settlement, process, filesystem, command-shim, HTTP, and WebSocket evidence, returning the first bounded divergence. Process Capture v3 is unsupported and must be recaptured with capture_process_scenario. Missing or truncated observations are never treated as equivalent.",
z.object({
left_evidence_id: z.string().regex(/^ev_[a-f0-9]{64}$/u),
left: processCaptureSchema,
right_evidence_id: z.string().regex(/^ev_[a-f0-9]{64}$/u),
right: processCaptureSchema,
max_capture_age_ms: z.number().int().nonnegative().optional(),
unknown_registry_approved: z
.literal(true)
.optional()

View File

@ -64,10 +64,10 @@ type RuntimeStatus = z.infer<typeof processCaptureComparisonSchema>["status"];
const EXPECTED_PROVIDERS = {
compare_process_captures: {
predicate: "rea.process-comparison/v2",
predicate: "rea.process-comparison/v3",
id: "rea-process",
name: "REA deterministic process harness",
version: "2",
version: "3",
},
compare_artifacts: {
predicate: "rea.artifact-comparison/v1",

View File

@ -1,4 +1,6 @@
import { createHash } from "node:crypto";
import { resolve } from "node:path";
import canonicalize from "canonicalize";
import { z } from "zod";
const positiveBudget = z.number().int().positive();
@ -36,7 +38,57 @@ const outputChunkSchema = z.object({
data: z.string().max(1_000_000),
});
/** Exact boundary schema for bounded dynamic process scenarios. */
/** Actionable refusal shared by CLI, MCP, and Evidence import boundaries. */
export const LEGACY_PROCESS_CAPTURE_MESSAGE =
"Process Capture v3 is unsupported. Re-run the scenario with this REA version using capture_process_scenario to produce Process Capture v4; captures cannot be upgraded because v4 requires new manifest and settlement evidence.";
const canonicalSha256 = (value: unknown): string => {
const serialized = canonicalize(value);
if (serialized === undefined)
throw new TypeError("Process capture commitment is not canonical JSON");
return createHash("sha256").update(serialized).digest("hex");
};
/** Build the secret-safe scenario projection committed by Process Capture v4. */
export const processScenarioCommitment = (
scenario: ProcessScenario,
executableSha256?: string,
): Readonly<Record<string, unknown>> => ({
...scenario,
environment: Object.fromEntries(
Object.entries(scenario.environment).map(([name, value]) => [
name,
scenario.secret_aliases.includes(name) ? "<redacted-secret>" : value,
]),
),
unknown_registry_approved: scenario.unknown_registry_approved === true,
executable_sha256: executableSha256 ?? null,
});
/** Project observation settings shared by authority and reconstruction. */
export const processComparisonContract = (
scenario: ProcessScenario,
): Readonly<Record<string, unknown>> => ({
working_directory: scenario.working_directory,
inherit_environment: scenario.inherit_environment,
secret_aliases: scenario.secret_aliases,
network_access: scenario.network_access,
filesystem_roots: scenario.filesystem_roots,
terminal: scenario.terminal,
checkpoints: scenario.checkpoints,
events: scenario.events,
timeout_ms: scenario.timeout_ms,
idle_timeout_ms: scenario.idle_timeout_ms,
settle_ms: scenario.settle_ms,
limits: scenario.limits,
normalization: scenario.normalization,
command_shims: scenario.command_shims,
replay: scenario.replay,
});
/** Compute a canonical SHA-256 commitment independent of object key order. */
export const digestProcessCommitment = canonicalSha256;
/**
* Boundary schema for one explicitly approved, bounded process experiment.
* Defaults are part of the evidence contract and must remain deterministic.
@ -313,7 +365,6 @@ export const processScenarioSchema = z
});
});
/** A parsed, bounded dynamic process observation scenario. */
/** Parsed instructions and resource bounds for one process experiment. */
export type ProcessScenario = z.infer<typeof processScenarioSchema>;
@ -321,7 +372,6 @@ export type ProcessScenario = z.infer<typeof processScenarioSchema>;
export const parseProcessScenario = (input: unknown): ProcessScenario =>
processScenarioSchema.parse(input);
/** Operator-owned policy for process capture. */
/** Operator-owned ceiling applied in addition to per-scenario approval. */
export interface ProcessExecutionPolicy {
readonly enabled: boolean;
@ -331,7 +381,6 @@ export interface ProcessExecutionPolicy {
readonly allowExternalNetwork: boolean;
}
/** A safe, caller-visible process-policy decision. */
/** Explicit authorization result; denial reasons are safe to show callers. */
export type ProcessPolicyDecision =
| { readonly allowed: true }
@ -395,7 +444,6 @@ export const authorizeProcessScenario = (
return { allowed: true };
};
/** One bounded terminal observation. */
/** Normalized raw PTY chunk, preserving transport-level output differences. */
export interface TerminalFrame {
readonly sequence: number;
@ -403,7 +451,6 @@ export interface TerminalFrame {
readonly data: string;
}
/** One rendered terminal state after xterm has parsed a PTY chunk or resize. */
/** Serialized terminal state after interpreting control and resize sequences. */
export interface RenderedTerminalFrame {
readonly sequence: number;
@ -417,7 +464,6 @@ export interface RenderedTerminalFrame {
readonly serialized_state: string;
}
/** One attempted scripted interaction with the PTY. */
/** Scheduled input, resize, or signal with its observed dispatch outcome. */
export interface InteractionEvent {
readonly sequence: number;
@ -455,7 +501,6 @@ export interface ProcessSample {
readonly session_id: number | null;
}
/** A named filesystem observation captured during a process lifecycle. */
/**
* Named filesystem state whose effects are relative to the prior checkpoint.
*/
@ -467,12 +512,12 @@ export interface FilesystemCheckpoint {
readonly truncated: boolean;
}
/** One invocation observed by the declarative command-shim replay adapter. */
/** Recorded deterministic dependency invocation and route-match outcome. */
export interface ShimEvent {
readonly sequence: number;
readonly at_ms: number;
readonly command: string;
readonly route_index: number | null;
readonly arguments: readonly string[];
readonly working_directory: string;
readonly outcome: "matched" | "unmatched" | "exhausted";
@ -494,15 +539,33 @@ export interface ProtocolEvent {
| "disconnected";
}
/** A bounded process observation with explicit incompleteness metadata. */
/**
* Process Capture v3 observation set.
* Process Capture v4 observation set.
*
* `truncated` and `residual_unknowns` are semantic evidence: consumers must not
* infer equivalence from matching bounded observations when either is present.
*/
export interface ProcessCapture {
readonly schema_version: 3;
readonly schema_version: 4;
readonly manifest: {
readonly rea_version: string;
readonly provider_version: string;
readonly platform: string;
readonly architecture: string;
readonly pty_backend: "node-pty";
readonly started_at: string;
readonly completed_at: string;
readonly scenario: Readonly<Record<string, unknown>>;
readonly comparison_contract: Readonly<Record<string, unknown>>;
readonly shim_plan: readonly unknown[];
readonly replay_plan: Readonly<Record<string, unknown>>;
readonly full_scenario_sha256: string;
readonly comparison_contract_sha256: string;
readonly executable_sha256: string;
readonly normalization_sha256: string;
readonly shim_plan_sha256: string;
readonly replay_plan_sha256: string;
};
readonly normalization: z.infer<typeof normalizationSchema>;
readonly frames: readonly TerminalFrame[];
readonly rendered_frames: readonly RenderedTerminalFrame[];
@ -512,6 +575,11 @@ export interface ProcessCapture {
readonly signal: number | null;
readonly reason: "exited" | "timeout" | "idle_timeout";
};
readonly settlement: {
readonly state: "quiesced" | "alive_at_deadline" | "unverifiable";
readonly elapsed_ms: number;
readonly cleanup_outcome: "not_required" | "cleaned" | "failed";
};
readonly process_samples: readonly ProcessSample[];
readonly filesystem_checkpoints: readonly FilesystemCheckpoint[];
readonly shim_events: readonly ShimEvent[];
@ -558,7 +626,28 @@ const fileEffectSchema = z.object({
/** Exact serialized shape of a bounded process capture. */
export const processCaptureSchema: z.ZodType<ProcessCapture> = z.object({
schema_version: z.literal(3),
schema_version: z.literal(4, {
error: LEGACY_PROCESS_CAPTURE_MESSAGE,
}),
manifest: z.object({
rea_version: z.string().min(1),
provider_version: z.string().min(1),
platform: z.string().min(1),
architecture: z.string().min(1),
pty_backend: z.literal("node-pty"),
started_at: z.iso.datetime(),
completed_at: z.iso.datetime(),
scenario: z.record(z.string(), z.unknown()),
comparison_contract: z.record(z.string(), z.unknown()),
shim_plan: z.array(z.unknown()),
replay_plan: z.record(z.string(), z.unknown()),
full_scenario_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
comparison_contract_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
executable_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
normalization_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
shim_plan_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
replay_plan_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
}),
normalization: normalizationSchema,
frames: z.array(
z.object({
@ -595,6 +684,11 @@ export const processCaptureSchema: z.ZodType<ProcessCapture> = z.object({
signal: z.number().int().nullable(),
reason: z.enum(["exited", "timeout", "idle_timeout"]),
}),
settlement: z.object({
state: z.enum(["quiesced", "alive_at_deadline", "unverifiable"]),
elapsed_ms: z.number().int().nonnegative(),
cleanup_outcome: z.enum(["not_required", "cleaned", "failed"]),
}),
process_samples: z.array(
z.object({
at_ms: z.number().int().nonnegative(),
@ -619,6 +713,7 @@ export const processCaptureSchema: z.ZodType<ProcessCapture> = z.object({
sequence: z.number().int().nonnegative(),
at_ms: z.number().int().nonnegative(),
command: z.string(),
route_index: z.number().int().nonnegative().nullable(),
arguments: z.array(z.string()),
working_directory: z.string(),
outcome: z.enum(["matched", "unmatched", "exhausted"]),
@ -666,6 +761,173 @@ export const processCaptureSchema: z.ZodType<ProcessCapture> = z.object({
}),
});
/** One pure semantic validation failure in an otherwise shaped v4 capture. */
export interface ProcessCaptureValidationIssue {
readonly path: string;
readonly message: string;
}
const orderedBy = <Value>(
values: readonly Value[],
project: (value: Value) => number,
): boolean =>
values.every(
(value, index) =>
index === 0 || project(value) >= project(values[index - 1]!),
);
const orderedTimestamps = (
values: readonly { readonly at_ms: number }[],
): boolean => orderedBy(values, ({ at_ms }) => at_ms);
/** Recompute v4 commitments and cross-field invariants without side effects. */
export const validateProcessCapture = (
capture: ProcessCapture,
): readonly ProcessCaptureValidationIssue[] => {
const issues: ProcessCaptureValidationIssue[] = [];
const require = (condition: boolean, path: string, message: string): void => {
if (!condition) issues.push({ path, message });
};
const { manifest } = capture;
require(manifest.scenario.executable_sha256 ===
manifest.executable_sha256, "manifest.executable_sha256", "executable commitment does not match the scenario projection");
const commitments = [
["full_scenario_sha256", manifest.scenario],
["comparison_contract_sha256", manifest.comparison_contract],
["normalization_sha256", capture.normalization],
["shim_plan_sha256", manifest.shim_plan],
["replay_plan_sha256", manifest.replay_plan],
] as const;
for (const [field, value] of commitments)
require(manifest[field] ===
canonicalSha256(
value,
), `manifest.${field}`, "commitment does not match its canonical value");
require(Date.parse(manifest.started_at) <=
Date.parse(
manifest.completed_at,
), "manifest.completed_at", "completion precedes start");
const sequences = [
["frames", capture.frames],
["rendered_frames", capture.rendered_frames],
["interaction_events", capture.interaction_events],
["shim_events", capture.shim_events],
["protocol_events", capture.protocol_events],
] as const;
for (const [name, values] of sequences) {
require(values.every(
({ sequence }, index) => sequence === index,
), name, "sequence values must be contiguous from zero");
}
for (const [name, values] of [
["frames", capture.frames],
["rendered_frames", capture.rendered_frames],
["process_samples", capture.process_samples],
["filesystem_checkpoints", capture.filesystem_checkpoints],
["shim_events", capture.shim_events],
["protocol_events", capture.protocol_events],
] as const)
require(orderedTimestamps(values), name, "timestamps must be ordered");
const checkpointNames = capture.filesystem_checkpoints.map(
({ name }) => name,
);
require(new Set(checkpointNames).size ===
checkpointNames.length, "filesystem_checkpoints", "checkpoint names must be unique");
require(checkpointNames[0] ===
"before", "filesystem_checkpoints", "first checkpoint must be before");
require(checkpointNames.at(-1) ===
"after_settlement", "filesystem_checkpoints", "last checkpoint must be after_settlement");
require(!capture.filesystem_checkpoints.some(({ truncated }) => truncated) ||
capture.truncated, "truncated", "checkpoint truncation must propagate to the capture");
require(capture.exit.reason === "exited" ||
capture.exit.code ===
null, "exit", "deadline termination cannot declare a normal exit code");
require(capture.settlement.state !== "quiesced" ||
capture.settlement.cleanup_outcome ===
"not_required", "settlement.cleanup_outcome", "quiesced settlement cannot require cleanup");
require(capture.settlement.state === "quiesced" ||
capture.settlement.cleanup_outcome !==
"not_required", "settlement.cleanup_outcome", "non-quiesced settlement must report cleanup");
const shimPlans = manifest.shim_plan.flatMap((shim) =>
typeof shim === "object" &&
shim !== null &&
"name" in shim &&
"routes" in shim &&
typeof shim.name === "string" &&
Array.isArray(shim.routes)
? shim.routes.map((route, routeIndex) => ({
command: shim.name as string,
route,
routeIndex,
}))
: [],
);
for (const event of capture.shim_events) {
const declaredRoute = shimPlans.find(
({ command, routeIndex }) =>
command === event.command && routeIndex === event.route_index,
);
require(event.outcome === "unmatched"
? event.route_index === null
: declaredRoute !==
undefined, "shim_events", "shim event has no declared route");
}
for (const plan of shimPlans) {
const maximum =
typeof plan.route === "object" &&
plan.route !== null &&
"max_calls" in plan.route &&
typeof plan.route.max_calls === "number"
? plan.route.max_calls
: 0;
require(capture.shim_events.filter(
(event) =>
event.command === plan.command &&
event.route_index === plan.routeIndex &&
event.outcome === "matched",
).length <=
maximum, "shim_events", "matched shim events exceed the declared route max_calls");
}
const replayHttp =
"http" in manifest.replay_plan && Array.isArray(manifest.replay_plan.http)
? manifest.replay_plan.http
: [];
for (const event of capture.protocol_events)
if (
event.protocol === "http" &&
event.direction === "request" &&
event.outcome === "matched"
)
require(replayHttp.some(
(route) =>
typeof route === "object" &&
route !== null &&
"method" in route &&
route.method === event.method &&
"path" in route &&
route.path === event.path,
), "protocol_events", "matched HTTP event has no declared replay route");
return issues;
};
/** Parse unknown input as v4 and reject invalid commitments or semantics. */
export const parseProcessCapture = (input: unknown): ProcessCapture => {
if (
typeof input === "object" &&
input !== null &&
"schema_version" in input &&
input.schema_version === 3
)
throw new TypeError(LEGACY_PROCESS_CAPTURE_MESSAGE);
const capture = processCaptureSchema.parse(input);
const issues = validateProcessCapture(capture);
if (issues.length > 0)
throw new TypeError(
`Invalid Process Capture v4: ${issues.map(({ path, message }) => `${path}: ${message}`).join("; ")}`,
);
return capture;
};
export {
compareProcessCaptures,
comparisonStatusSchema,

View File

@ -1,6 +1,7 @@
import { z } from "zod";
import type { ProcessCapture } from "./processCapture.js";
import { validateProcessCapture } from "./processCapture.js";
/** Comparison classification that never equates incomplete evidence. */
export const comparisonStatusSchema = z.enum([
@ -198,7 +199,7 @@ const chooseFirstDivergence = (
/** Compare bounded captures without equating missing or incompatible evidence. */
/**
* Compare two Process Capture v3 observations without treating absence as proof.
* Compare two Process Capture v4 observations without treating absence as proof.
*
* Observed differences outrank residual unknowns. A capture may therefore be
* definitively changed in one dimension while completeness remains unknown.
@ -206,7 +207,36 @@ const chooseFirstDivergence = (
export const compareProcessCaptures = (
left: ProcessCapture,
right: ProcessCapture,
options: {
readonly maxCaptureAgeMs?: number;
readonly now?: () => number;
} = {},
): ProcessCaptureComparison => {
const invalid = [
...validateProcessCapture(left),
...validateProcessCapture(right),
];
if (invalid.length > 0)
throw new TypeError(`Invalid Process Capture v4: ${invalid[0]!.path}`);
if (
left.schema_version !== right.schema_version ||
left.manifest.comparison_contract_sha256 !==
right.manifest.comparison_contract_sha256
)
throw new TypeError(
"Process captures have incompatible comparison contracts",
);
if (options.maxCaptureAgeMs !== undefined) {
const maxCaptureAgeMs = options.maxCaptureAgeMs;
const now = (options.now ?? Date.now)();
if (
[left, right].some(
({ manifest }) =>
now - Date.parse(manifest.completed_at) > maxCaptureAgeMs,
)
)
throw new TypeError("Process capture exceeds max_capture_age_ms");
}
if (left.truncated || right.truncated) {
return {
status: "truncated",
@ -247,7 +277,8 @@ export const compareProcessCaptures = (
const exit =
hasUnknown(left, "exit") || hasUnknown(right, "exit")
? "unknown"
: JSON.stringify(left.exit) === JSON.stringify(right.exit)
: JSON.stringify({ exit: left.exit, settlement: left.settlement }) ===
JSON.stringify({ exit: right.exit, settlement: right.settlement })
? "unchanged"
: "changed";
const filesystem = classify(
@ -279,7 +310,11 @@ export const compareProcessCaptures = (
left.interaction_events,
right.interaction_events,
),
firstCollectionDivergence("exit", [left.exit], [right.exit]),
firstCollectionDivergence(
"exit",
[{ ...left.exit, settlement: left.settlement }],
[{ ...right.exit, settlement: right.settlement }],
),
firstCollectionDivergence(
"filesystem",
filesystemObservations(left),

View File

@ -44,7 +44,7 @@ const MAX_CANONICAL_BYTES = 32 * 1024 * 1024;
const providers = {
behavioral: {
operation: "compare_process_captures",
predicate: "rea.process-comparison/v2",
predicate: "rea.process-comparison/v3",
id: "rea-process",
name: "REA deterministic process harness",
},
@ -199,7 +199,7 @@ const validateSourceKinds = (
): void => {
const expected =
claim.kind === "behavioral"
? ["capture_process_scenario", "rea.process-capture/v3"]
? ["capture_process_scenario", "rea.process-capture/v4"]
: claim.kind === "structural-function"
? ["analyze_function", "rea.analysis/v2"]
: ["inventory_artifact", "rea.analysis/v2"];
@ -230,7 +230,7 @@ const validateComparisonIdentity = (claim: Claim, evidence: Evidence): void => {
evidence.predicate_type !== expected.predicate ||
evidence.provider.id !== expected.id ||
evidence.provider.name !== expected.name ||
evidence.provider.version !== (claim.kind === "behavioral" ? "2" : "1") ||
evidence.provider.version !== (claim.kind === "behavioral" ? "3" : "1") ||
evidence.confidence !== "derived" ||
evidence.authority !== "analyst-inference" ||
evidence.subject !== null

View File

@ -158,7 +158,7 @@ const EXPECTED = {
"REA function comparison",
],
compare_process_captures: [
"rea.process-comparison/v2",
"rea.process-comparison/v3",
"rea-process",
"REA deterministic process harness",
],
@ -403,7 +403,7 @@ const assertComparisonIdentity = (evidence: Evidence): void => {
evidence.provider.id !== expected[1] ||
evidence.provider.name !== expected[2] ||
evidence.provider.version !==
(evidence.operation === "compare_process_captures" ? "2" : "1") ||
(evidence.operation === "compare_process_captures" ? "3" : "1") ||
evidence.confidence !== "derived" ||
evidence.authority !== "analyst-inference" ||
evidence.subject !== null ||

View File

@ -6,6 +6,7 @@
export const PRODUCT_IDENTITY = {
displayName: "REA",
packageName: "rea-agents",
packageVersion: "1.1.0",
cliBinary: "rea",
mcpCommand: "npx -y rea-agents mcp",
mcpServerKey: "rea",

View File

@ -11,6 +11,7 @@ import type { RecordUnknownInput } from "../domain/residualUnknown.js";
import {
compareProcessCaptures,
processCaptureSchema,
validateProcessCapture,
} from "../domain/processCapture.js";
import { recordDerivedEvidence } from "./recordDerivedEvidence.js";
import { err } from "../domain/result.js";
@ -23,6 +24,7 @@ export const registerProcessComparisonTool = (
server: McpServer,
session: BinarySessionPort,
contract: (typeof SESSION_TOOL_CONTRACTS)[6],
now: () => number = Date.now,
): void => {
server.registerTool(
contract.name,
@ -34,14 +36,34 @@ export const registerProcessComparisonTool = (
left: processCaptureSchema,
right_evidence_id: z.string(),
right: processCaptureSchema,
max_capture_age_ms: z.number().int().nonnegative().optional(),
unknown_registry_approved: z.literal(true).optional(),
})
.parse(input);
const validated = validateCaptureSources(session, parsed);
if (!validated.ok) return toCallToolResult(validated, contract);
const comparison = compareProcessCaptures(parsed.left, parsed.right);
let comparison: ReturnType<typeof compareProcessCaptures>;
try {
comparison = compareProcessCaptures(parsed.left, parsed.right, {
...(parsed.max_capture_age_ms === undefined
? {}
: { maxCaptureAgeMs: parsed.max_capture_age_ms }),
now,
});
} catch (cause: unknown) {
return toCallToolResult(
err(
new EvidenceIntegrityError(
cause instanceof Error
? cause.message
: "Invalid Process Capture v4",
),
),
contract,
);
}
const evidence = createEvidence(undefined, PROCESS_PROVIDER, {
predicateType: "rea.process-comparison/v2",
predicateType: "rea.process-comparison/v3",
operation: contract.name,
parameters: {
left_evidence_id: parsed.left_evidence_id,
@ -85,13 +107,15 @@ const validateCaptureSources = (
if (
evidence === undefined ||
evidence.operation !== "capture_process_scenario" ||
evidence.predicate_type !== "rea.process-capture/v3" ||
evidence.predicate_type !== "rea.process-capture/v4" ||
evidence.provider.id !== PROCESS_PROVIDER.id ||
evidence.provider.name !== PROCESS_PROVIDER.name ||
evidence.provider.version !== PROCESS_PROVIDER.version ||
evidence.confidence !== "observed" ||
evidence.authority !== "controlled-replay" ||
!result.success ||
(result.success && validateProcessCapture(result.data).length > 0) ||
validateProcessCapture(capture).length > 0 ||
canonicalJson(result.data) !== canonicalJson(capture)
)
return err(

View File

@ -33,11 +33,11 @@ const comparison = (
name: process
? "REA deterministic process harness"
: "REA artifact comparison",
version: operation === "compare_process_captures" ? "2" : "1",
version: operation === "compare_process_captures" ? "3" : "1",
},
{
predicateType: process
? "rea.process-comparison/v2"
? "rea.process-comparison/v3"
: "rea.artifact-comparison/v1",
operation,
parameters: {},

View File

@ -283,7 +283,7 @@ describe("full MCP integration with multi-tool sequences", () => {
});
const captureEvidence = (capture: typeof left) =>
createEvidence(undefined, PROCESS_PROVIDER, {
predicateType: "rea.process-capture/v3",
predicateType: "rea.process-capture/v4",
operation: "capture_process_scenario",
parameters: {},
result: jsonValueSchema.parse(capture),

View File

@ -16,7 +16,12 @@ import { snapshotRoots } from "../src/application/FilesystemSnapshot.js";
import {
authorizeProcessScenario,
compareProcessCaptures,
digestProcessCommitment,
LEGACY_PROCESS_CAPTURE_MESSAGE,
parseProcessCapture,
parseProcessScenario,
processCaptureSchema,
type ProcessCapture,
type ProcessExecutionPolicy,
} from "../src/domain/processCapture.js";
@ -25,6 +30,75 @@ const processFixture = fileURLToPath(
);
const execFileAsync = promisify(execFile);
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: [],
files_before: [],
files_after: [],
filesystem_effects: [],
truncated: false,
limitations: [],
residual_unknowns: [],
cleanup: {
owned_process_group: "verified",
temporary_root: "removed",
},
};
};
describe("process capture domain", () => {
const base = {
approved: true as const,
@ -107,7 +181,9 @@ describe("process capture domain", () => {
it("never considers truncated captures equivalent", () => {
const capture = {
schema_version: 3 as const,
schema_version: 4 as const,
manifest: emptyCapture().manifest,
settlement: emptyCapture().settlement,
normalization: {
paths: true,
pids: true,
@ -120,7 +196,7 @@ describe("process capture domain", () => {
interaction_events: [],
exit: { code: 0, signal: null, reason: "exited" as const },
process_samples: [],
filesystem_checkpoints: [],
filesystem_checkpoints: emptyCapture().filesystem_checkpoints,
shim_events: [],
protocol_events: [],
files_before: [],
@ -137,9 +213,72 @@ describe("process capture domain", () => {
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("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");
});
it("classifies missing observations as unknown and one-sided evidence as added", () => {
const base = {
schema_version: 3 as const,
schema_version: 4 as const,
manifest: emptyCapture().manifest,
settlement: emptyCapture().settlement,
normalization: {
paths: true,
pids: true,
@ -152,7 +291,7 @@ describe("process capture domain", () => {
interaction_events: [],
exit: { code: 0, signal: null, reason: "exited" as const },
process_samples: [],
filesystem_checkpoints: [],
filesystem_checkpoints: emptyCapture().filesystem_checkpoints,
shim_events: [],
protocol_events: [],
files_before: [],
@ -196,7 +335,9 @@ describe("process capture domain", () => {
it("compares raw terminal chunks even when rendered states agree", () => {
const capture = {
schema_version: 3 as const,
schema_version: 4 as const,
manifest: emptyCapture().manifest,
settlement: emptyCapture().settlement,
normalization: {
paths: true,
pids: true,
@ -209,7 +350,7 @@ describe("process capture domain", () => {
interaction_events: [],
exit: { code: 0, signal: null, reason: "exited" as const },
process_samples: [],
filesystem_checkpoints: [],
filesystem_checkpoints: emptyCapture().filesystem_checkpoints,
shim_events: [],
protocol_events: [],
files_before: [],
@ -237,7 +378,9 @@ describe("process capture domain", () => {
it("keeps filesystem evidence unknown when stable snapshots match", () => {
const capture = {
schema_version: 3 as const,
schema_version: 4 as const,
manifest: emptyCapture().manifest,
settlement: emptyCapture().settlement,
normalization: {
paths: true,
pids: true,
@ -250,7 +393,7 @@ describe("process capture domain", () => {
interaction_events: [],
exit: { code: 0, signal: null, reason: "exited" as const },
process_samples: [],
filesystem_checkpoints: [],
filesystem_checkpoints: emptyCapture().filesystem_checkpoints,
shim_events: [],
protocol_events: [],
files_before: [],
@ -275,6 +418,7 @@ describe("process capture domain", () => {
const transient = compareProcessCaptures(complete, {
...complete,
filesystem_checkpoints: [
{ name: "before", at_ms: 0, files: [], effects: [], truncated: false },
{
name: "during_run",
at_ms: 10,
@ -282,6 +426,13 @@ describe("process capture domain", () => {
effects: [],
truncated: false,
},
{
name: "after_settlement",
at_ms: 50,
files: [],
effects: [],
truncated: false,
},
],
});
expect(transient.filesystem).toBe("changed");
@ -290,7 +441,9 @@ describe("process capture domain", () => {
it("detects changes in normalized process sample metadata", () => {
const capture = {
schema_version: 3 as const,
schema_version: 4 as const,
manifest: emptyCapture().manifest,
settlement: emptyCapture().settlement,
normalization: {
paths: true,
pids: true,
@ -312,7 +465,7 @@ describe("process capture domain", () => {
command: "worker",
},
],
filesystem_checkpoints: [],
filesystem_checkpoints: emptyCapture().filesystem_checkpoints,
shim_events: [],
protocol_events: [],
files_before: [],
@ -618,7 +771,7 @@ describe("process capture adapter", () => {
);
expect(result.ok).toBe(true);
if (!result.ok) throw result.error;
expect(result.value.schema_version).toBe(3);
expect(result.value.schema_version).toBe(4);
expect(result.value.rendered_frames.at(-1)?.lines.join("\n")).toContain(
"probe:codex 1.2.3",
);

View File

@ -33,7 +33,7 @@ const source = (
{
predicateType:
authority === "controlled-replay"
? "rea.process-capture/v3"
? "rea.process-capture/v4"
: "rea.analysis/v2",
operation:
authority === "controlled-replay"
@ -89,10 +89,10 @@ const processComparison = (
{
id: "rea-process",
name: "REA deterministic process harness",
version: "2",
version: "3",
},
{
predicateType: "rea.process-comparison/v2",
predicateType: "rea.process-comparison/v3",
operation: "compare_process_captures",
parameters: {
left_evidence_id: left.evidence_id,

View File

@ -18,7 +18,7 @@ const capture = (digit: string): Evidence =>
undefined,
{ id: "fixture", name: "Fixture", version: "1" },
{
predicateType: "rea.process-capture/v3",
predicateType: "rea.process-capture/v4",
operation: "capture_process_scenario",
parameters: {},
result: { digit },
@ -68,10 +68,10 @@ const processComparison = (
{
id: "rea-process",
name: "REA deterministic process harness",
version: "2",
version: "3",
},
{
predicateType: "rea.process-comparison/v2",
predicateType: "rea.process-comparison/v3",
operation: "compare_process_captures",
parameters: {
left_evidence_id: leftCapture.evidence_id,