import { digestProcessCommitment } from "./processScenario.js"; import { hasCaptureTruncation } from "./processCaptureCoverage.js"; import type { ProcessCaptureFinalization, UnverifiedProcessCapture, } from "./processCapture.js"; /** One pure semantic validation failure in a shaped process capture. */ export interface ProcessCaptureValidationIssue { readonly path: string; readonly message: string; } type RequireInvariant = ( condition: boolean, path: string, message: string, ) => void; const orderedTimestamps = (values: readonly number[]): boolean => values.every((value, index) => { const previous = values[index - 1]; return previous === undefined || value >= previous; }); const validateCommitments = ( capture: UnverifiedProcessCapture, require: RequireInvariant, ): void => { const { manifest } = capture; require(manifest.scenario.executable_sha256 === manifest.selected_executable_sha256, "manifest.selected_executable_sha256", "selected executable commitment does not match the scenario projection"); require(manifest.executable_identity.state === "path_metadata_unchanged" ? manifest.executable_sha256 === manifest.selected_executable_sha256 && manifest.executable_identity.reason === null : manifest.executable_sha256 === null && manifest.executable_identity.reason !== null, "manifest.executable_identity", "launch identity and executable digest do not agree"); for (const [field, value] of [ ["full_scenario_sha256", manifest.scenario], ["comparison_contract_sha256", manifest.comparison_contract], ["normalization_sha256", capture.normalization], ] as const) require(manifest[field] === digestProcessCommitment( value, ), `manifest.${field}`, "commitment does not match its canonical value"); }; const validateOrdering = ( capture: UnverifiedProcessCapture, require: RequireInvariant, ): void => { for (const [name, values] of [ ["frames", capture.frames], ["rendered_frames", capture.rendered_frames], ["interaction_events", capture.interaction_events], ] as const) 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], ] as const) require(orderedTimestamps( values.map(({ at_ms }) => at_ms), ), name, "timestamps must be ordered"); // Interactions are dispatched in sequence, so their recorded dispatch // times cannot decrease; comparison ranks a changed interaction by it. require(orderedTimestamps( capture.interaction_events.map(({ dispatched_at_ms }) => dispatched_at_ms), ), "interaction_events", "dispatch timestamps must be ordered"); }; const validateEventJournal = ( capture: UnverifiedProcessCapture, require: RequireInvariant, ): void => { const journal = capture.event_journal; if (journal.length === 0) return; const sizes = { frames: capture.frames.length, rendered_frames: capture.rendered_frames.length, interaction_events: capture.interaction_events.length, lifecycle: 2, process_samples: capture.process_samples.length, filesystem_checkpoints: capture.filesystem_checkpoints.length, }; const references = new Set(); for (const [position, entry] of journal.entries()) { require(entry.capture_order === position, `event_journal.${String(position)}.capture_order`, "capture order must be contiguous from zero"); require(entry.index < sizes[ entry.collection ], `event_journal.${String(position)}.index`, "journal reference is outside its capture collection"); const reference = `${entry.collection}:${String(entry.index)}`; require(!references.has( reference, ), `event_journal.${String(position)}`, "journal references must be unique"); references.add(reference); } const expectedSize = Object.values(sizes).reduce( (total, size) => total + size, 0, ); require(journal.length === expectedSize, "event_journal", "nonempty journal must reference every captured observation"); }; /** * The committed comparison contract carries `finalization_ms` exactly when the * committed scenario does, with the same value. Independent of the exit, so a * partial capture with an unavailable exit is still checked. */ export const finalizationManifestIssue = ( committedScenario?: Readonly>, committedComparison?: Readonly>, ): string | undefined => committedScenario !== undefined && committedComparison !== undefined && committedScenario["finalization_ms"] !== committedComparison["finalization_ms"] ? "comparison_contract finalization_ms must match the committed scenario" : undefined; type FinalizationAttempts = Pick< ProcessCaptureFinalization, "requested_ms" | "elapsed_ms" | "exit_code" > & { readonly signals: readonly Pick< ProcessCaptureFinalization["signals"][number], "signal" | "sent_at_ms" >[]; }; /** Ensure incomplete exit and sibling finalization observations preserve one fact. */ export const finalizationAgreementIssue = ( exitFinalization: ProcessCaptureFinalization | undefined, observedFinalization: ProcessCaptureFinalization | undefined, ): string | undefined => exitFinalization !== undefined && observedFinalization !== undefined && JSON.stringify(exitFinalization) !== JSON.stringify(observedFinalization) ? "exit finalization and finalization observation must agree" : undefined; /** * Cross-field rule for `exit.finalization`, shared by complete and partial * captures. Returns the violated rule, or undefined when the record is * consistent. */ export const finalizationConsistencyIssue = ( exit: | { readonly reason: string; readonly signal?: number | null | undefined; readonly finalization?: FinalizationAttempts | undefined; } | undefined, committedScenario?: Readonly>, committedComparison?: Readonly>, options?: { readonly finalization?: FinalizationAttempts; readonly allowNullElapsedMs?: boolean; }, ): string | undefined => { const manifestIssue = finalizationManifestIssue( committedScenario, committedComparison, ); if (manifestIssue !== undefined) return manifestIssue; const finalization = exit?.finalization ?? options?.finalization; if (finalization === undefined) { // A deadline that fired under a committed interval must have attempted // finalization; cancellation before the deadline legitimately has none. const committed = committedScenario?.["finalization_ms"]; return (exit?.reason === "timeout" || exit?.reason === "idle_timeout") && typeof committed === "number" && committed > 0 ? "a deadline exit under a committed finalization_ms requires finalization evidence" : undefined; } if (exit?.reason === "exited") return "finalization requires a deadline exit reason"; const first = finalization.signals[0]; if (first?.signal !== "SIGTERM") return "finalization signals must start with SIGTERM"; const killIndexes = finalization.signals.flatMap(({ signal }, index) => signal === "SIGKILL" ? [index] : [], ); if ( killIndexes.length > 1 || (killIndexes[0] !== undefined && killIndexes[0] !== finalization.signals.length - 1) ) return "finalization may contain at most one final SIGKILL"; if ( !finalization.signals.every(({ sent_at_ms }, index) => { const previous = finalization.signals[index - 1]; return previous === undefined || sent_at_ms >= previous.sent_at_ms; }) ) return "finalization signal attempt times must not decrease"; if ( finalization.signals.filter(({ signal }) => signal === "SIGTERM").length !== 1 ) return "finalization must attempt SIGTERM exactly once"; const kill = finalization.signals.find(({ signal }) => signal === "SIGKILL"); if ( kill !== undefined && kill.sent_at_ms < finalization.requested_ms && exit !== undefined && exit.reason !== "cancelled" ) return "finalization SIGKILL cannot precede the requested interval"; if (finalization.elapsed_ms === null && options?.allowNullElapsedMs !== true) return "finalization requires an observed elapsed_ms"; if ( finalization.exit_code !== null && exit?.signal !== undefined && exit.signal !== null && exit.signal !== 0 ) return "finalization exit_code requires an exit not caused by a signal"; if ( finalization.elapsed_ms !== null && finalization.elapsed_ms < (finalization.signals.at(-1)?.sent_at_ms ?? first.sent_at_ms) ) return "finalization elapsed_ms cannot precede the last signal attempt"; if ( committedScenario !== undefined && committedScenario["finalization_ms"] !== finalization.requested_ms ) return "finalization must match the committed finalization_ms"; return undefined; }; const validateLifecycle = ( capture: UnverifiedProcessCapture, require: RequireInvariant, ): void => { require(capture.filesystem_checkpoints.map(({ name }) => name).join(",") === "before,after_settlement", "filesystem_checkpoints", "capture must include initial and final filesystem snapshots"); require(orderedTimestamps( capture.filesystem_checkpoints.map(({ at_ms }) => at_ms), ), "filesystem_checkpoints", "filesystem snapshot times must be ordered"); require(!capture.filesystem_checkpoints.some(({ truncated }) => truncated) || capture.truncated, "truncated", "filesystem snapshot truncation must propagate to the capture"); require(capture.exit.reason === "exited" || capture.exit.code === null, "exit", "deadline termination cannot declare a normal exit code"); const finalizationIssue = finalizationConsistencyIssue( capture.exit, capture.manifest.scenario, capture.manifest.comparison_contract, ); require(finalizationIssue === undefined, "exit", finalizationIssue ?? ""); }; const validateCoverage = ( capture: UnverifiedProcessCapture, require: RequireInvariant, ): void => { const details = capture.truncation_details; require(details.process.retained_samples === capture.process_samples.length && details.process.retained_samples <= details.process.sample_limit && details.process.sampling_partial === (details.process.sample_limit_reached || details.process.sampling_failures > 0) && (details.process.sampling_failures === 0) === (details.process.first_sampling_failure === null), "truncation_details.process", "sampling coverage must agree with retained samples and observed failures"); require(capture.truncated === hasCaptureTruncation( details, ), "truncation_details", "aggregate truncation must match producer coverage"); for (const [name, retention, count, bytes] of [ [ "raw_terminal", details.raw_terminal, capture.frames.length, capture.frames.reduce( (total, frame) => total + Buffer.byteLength(frame.raw_data ?? frame.data), 0, ), ], [ "rendered_terminal", details.rendered_terminal, capture.rendered_frames.length, capture.rendered_frames.reduce( (total, frame) => frame.lines.reduce( (sum, line) => sum + Buffer.byteLength(line), total + Buffer.byteLength(frame.serialized_state), ), 0, ), ], ] as const) { // Whole frames are retained or omitted, so retained bytes are exactly the // retained frames' bytes, and nothing is unretained when no frame was omitted. require(retention.retained_frames === count && retention.observed_frames >= count && retention.retained_bytes === bytes && retention.observed_bytes >= retention.retained_bytes && (retention.observed_frames > count || retention.observed_bytes === retention.retained_bytes) && retention.retained_bytes <= retention.budget_bytes, `truncation_details.${name}`, "retained observations and budget accounting do not agree"); } for (const [name, coverage, files, checkpoint] of [ [ "filesystem_before", details.filesystem_before, capture.files_before, capture.filesystem_checkpoints[0], ], [ "filesystem_after", details.filesystem_after, capture.files_after, capture.filesystem_checkpoints[1], ], ] as const) { const unhashedFiles = new Map( files .filter((file) => file.type === "file" && file.sha256 === null) .map((file) => [file.path, file]), ); require(coverage.hash_omissions.length === unhashedFiles.size && new Set(coverage.hash_omissions.map(({ path }) => path)).size === unhashedFiles.size && coverage.hash_omissions.every( (omission) => unhashedFiles.get(omission.path)?.size === omission.size_bytes && omission.remaining_budget_bytes <= coverage.hash_budget_bytes && (omission.reason === "file_unavailable" ? omission.system_code !== null : omission.system_code === null), ), `truncation_details.${name}.hash_omissions`, "every retained regular file without a digest must have one matching omission reason"); require(coverage.hashed_bytes === files .filter((file) => file.type === "file" && file.sha256 !== null) .reduce((sum, file) => sum + file.size, 0) && coverage.hashed_bytes <= coverage.hash_budget_bytes && files.length <= coverage.files_limit, `truncation_details.${name}`, "filesystem budget accounting does not match retained observations"); require(coverage.enumeration_truncated === coverage.enumeration_reasons.length > 0 && checkpoint?.truncated === (coverage.enumeration_truncated || coverage.hash_omissions.length > 0), `truncation_details.${name}`, "filesystem checkpoint truncation must match enumeration and digest coverage"); } }; /** Recompute commitments and cross-field invariants without side effects. */ export const collectProcessCaptureIssues = ( capture: UnverifiedProcessCapture, ): readonly ProcessCaptureValidationIssue[] => { const issues: ProcessCaptureValidationIssue[] = []; const require: RequireInvariant = (condition, path, message) => { if (!condition) issues.push({ path, message }); }; validateCommitments(capture, require); validateOrdering(capture, require); validateEventJournal(capture, require); validateLifecycle(capture, require); validateCoverage(capture, require); return issues; };