refactor: remove cross-version investigation workflow

This commit is contained in:
morluto 2026-09-29 08:06:43 +08:00
parent 0d96275b68
commit 81996df7a5
42 changed files with 66 additions and 3750 deletions

View File

@ -266,7 +266,6 @@ Uninstall preserves Hopper, Node.js, evidence, captures, external evidence roots
| Ask an agent to investigate an app and build a feature | Install the skill, then talk to your agent |
| Inspect or decompile one part of an app from the Terminal | `rea analyze` or `rea decompile` |
| Validate, canonicalize, or compare Evidence bundles | `rea evidence-import`, `rea evidence-export`, or `rea compare` |
| Run or resume a persistent two-version artifact analysis | `rea investigate-versions` |
| Map a local JavaScript/Electron application without executing it | `rea analyze PATH --approved` or `rea analyze-javascript-application` |
| Reuse immutable analysis results without relaunching a provider | Pass `--snapshot /approved/path/analysis.json` to a deep-analysis command |
| Import source as historical reference | `rea import-reference-source` |
@ -279,7 +278,6 @@ export REA_EVIDENCE_ROOTS_JSON='["/absolute/path/to/evidence"]'
rea evidence-import /absolute/path/to/evidence/bundle.json
rea evidence-export /absolute/path/to/evidence/bundle.json /absolute/path/to/evidence/canonical.json
rea compare /absolute/path/to/evidence/left.json /absolute/path/to/evidence/right.json
rea investigate-versions /absolute/path/to/releases/v1 /absolute/path/to/releases/v2 /absolute/path/to/evidence/releases.json --yes --workspace-name releases
rea analyze /absolute/path/to/releases/app.asar --approved --json
rea analyze-javascript-application /absolute/path/to/releases/app.asar --approved --json
```
@ -289,16 +287,6 @@ static JavaScript application provider when neither `--provider` nor
`--snapshot` is supplied. The dedicated command remains available for explicit
format and traversal controls. Both routes read the selected local input path.
`investigate-versions` inventories both versions, checkpoints their observed
Evidence, derives an artifact comparison, and records a changed-behavior report.
workspace files remain independently restricted by `REA_EVIDENCE_ROOTS_JSON`.
The workspace uses deterministic content identities and monotonic CAS-linked
revisions, so the same request resumes an interrupted run or reuses a completed
run without replacing earlier investigations. It currently compares static
artifact structure only; it does not execute either version, and its report
keeps every difference labeled as a behavior candidate. See
[Persistent investigation workspaces](docs/investigation-workspaces.md).
Historical source import requires a separate allowlist and never treats source as current behavioral authority:
```bash
@ -584,7 +572,6 @@ npx -y rea-agents@latest function /Applications/Notes.app 0x1000
npx -y rea-agents@latest xrefs /Applications/Notes.app 0x1000
npx -y rea-agents@latest trace /Applications/Notes.app "offline"
npx -y rea-agents@latest compare /absolute/path/to/left-evidence.json /absolute/path/to/right-evidence.json
npx -y rea-agents@latest investigate-versions /path/to/v1 /path/to/v2 /absolute/path/to/evidence/releases.json --yes
npx -y rea-agents@latest capabilities
npx -y rea-agents@latest providers
```

View File

@ -328,11 +328,11 @@ entries from the previous binding. Auxiliary operations are cacheable only if
their provider defines an equivalent explicit profile commitment; otherwise
they remain Evidence but not snapshot entries.
Snapshot v1 is not silently upgraded. Its `loader_args` cannot prove which
engine version, defaults, or analyzer settings produced the entries. Importing
v1 returns a typed incompatibility with a recapture instruction. Its embedded
Evidence bundle may still be imported separately through the existing explicit
Evidence import flow; it is never promoted into a v2 analysis cache.
Snapshot v1 cannot be parsed as the current snapshot format. Its `loader_args`
cannot prove which engine version, defaults, or analyzer settings produced the
entries, so snapshots must be recaptured before they can be used as a cache.
An embedded Evidence bundle can still be imported separately through the
Evidence import flow; it is never promoted into an analysis cache.
### 8. Add profile commitments to new deep Evidence without rejecting legacy Evidence v2
@ -457,7 +457,7 @@ snapshot's provider.
| `open_binary` and CLI structured `loaderArgs` output | Retained as a deprecated Hopper-only compatibility projection for 1.x; the analysis profile is authoritative |
| `BinaryTarget.loaderArgs` | Removed; Hopper derives them inside its adapter and commits normalized semantics in the profile |
| Legacy `.hop` input | Classified generically as an analysis database and accepted only by Hopper |
| Snapshot v1 | Rejected for cache replay with explicit recapture guidance; embedded Evidence may be imported separately |
| Snapshot v1 | Not accepted as the current snapshot format; recapture before cache replay |
| Existing Evidence v2 without a profile | Accepted unchanged; profile compatibility is unknown |
| New deep-analysis Evidence v2 | Must include `analysis_profile`; its semantic ID commits the field |
| Existing status fields | Retained for 1.x with additive binding and candidate fields |
@ -483,7 +483,6 @@ with a separately documented major contract change.
| Same target is reopened without a selector | Existing binding remains unchanged |
| Same bytes are opened with a different profile | Real provider switch and a separate snapshot partition |
| Snapshot provider or profile differs from the binding | Typed `EvidenceIntegrityError` with `profile_mismatch`; nothing is imported |
| Snapshot v1 is supplied | Typed incompatibility with recapture or explicit Evidence-import guidance |
| Artifact-only target has no deep candidate | Target opens unbound; disjoint artifact capabilities remain usable |
| Environment prefers Ghidra for an artifact-only target | Deep selection is `not_applicable`; auxiliary operations remain usable |
| Concrete deep provider is requested for artifact-only data | Typed `target_unsupported`; the request is not silently ignored |
@ -502,7 +501,7 @@ Positive consequences:
Costs and tradeoffs:
- Multi-provider hosts must choose when more than one candidate is usable.
- Snapshot v1 cannot safely serve as a cache after this migration.
- Snapshots must be recaptured when their provider or profile binding changes.
- Providers must expose deterministic availability, support, version, and
profile resolution before they can be admitted.
- Status and error contracts gain additive structured metadata.

View File

@ -1,120 +0,0 @@
# Persistent investigation workspaces
REA can run a deterministic artifact investigation across two versions and
persist its Evidence beyond a CLI process or MCP session. The CLI and MCP use
the same application workflow.
## CLI
First allow the directory that will own the workspace, then explicitly approve
the write:
```bash
export REA_EVIDENCE_ROOTS_JSON='["/absolute/path/to/evidence"]'
rea investigate-versions \
/absolute/path/to/version-1 \
/absolute/path/to/version-2 \
/absolute/path/to/evidence/releases.json \
--workspace-name releases \
--yes
```
The two versions may be regular artifacts or directories supported by the
artifact graph provider. Workspace files remain restricted by
`REA_EVIDENCE_ROOTS_JSON`; input paths are used directly by the local process.
Use `--expected-revision N` when an external coordinator needs an explicit
compare-and-swap guard. Traversal, byte, page, and comparison controls are
available as CLI options.
After a completed run returns its stable run ID, `--replay-run-id RUN_ID`
selects that exact result without reading or rescanning either version input.
The workspace still requires read authority. REA rejects a selector that is
missing, incomplete, or inconsistent with the requested paths and controls.
## MCP
`find_changed_behavior` retains its existing aggregation mode. Its additive
`investigation_run` mode starts or resumes the same persistent workflow:
```json
{
"investigation_run": {
"approved": true,
"workspace_path": "/absolute/path/to/evidence/releases.json",
"workspace_name": "releases",
"left_path": "/absolute/path/to/version-1",
"right_path": "/absolute/path/to/version-2",
"options": {
"max_entries": 10000,
"max_total_bytes": 1073741824,
"max_entry_bytes": 268435456,
"max_compression_ratio": 1000,
"max_depth": 20,
"max_path_bytes": 4096,
"page_size": 500,
"change_limit": 500
}
}
}
```
Exactly one of `comparisons` or `investigation_run` is accepted. The MCP tool
inventory remains unchanged.
## Checkpoints and reuse
One run has four ordered stages:
1. Inventory the left version.
2. Inventory the right version.
3. Compare the complete artifact Evidence page sets.
4. Derive the changed-behavior report.
The inventory checkpoint is written first, followed by the comparison and final
report checkpoints. A process interrupted after a checkpoint can resume from
the last complete stage. A completed run is reused when both graph commitments
and every bounded option are identical.
By default, REA rescans the version inputs so changed content creates a new
run. For an explicit cache-only replay, add the returned `run_id` as
`investigation_run.replay_run_id`. REA validates the workspace revision and
name, the complete run, its exact paths and controls, and every linked Evidence
record before it skips input-read authorization.
The run ID is a SHA-256 commitment to both root and graph digests plus the
normalized options. Local paths and timestamps do not affect that identity.
Changing either artifact or a budget creates a new run in the same workspace;
it does not overwrite the earlier run.
## Integrity and concurrency
A workspace contains a canonical Evidence v2 bundle plus run manifests. Each
workspace revision has a deterministic digest and points to the prior revision
digest. Parsing verifies:
- the workspace, run, Evidence, graph, and revision identities;
- canonical ordering and unique run IDs;
- every run-to-Evidence reference;
- stage and completion-state consistency.
Writes are canonical JSON, atomic, fsynced, and mode `0600`. REA rejects
workspace paths outside `REA_EVIDENCE_ROOTS_JSON`, symlink destinations,
oversized or deeply nested JSON, stale expected revisions, and concurrent lock
holders. Lock or CAS conflicts fail without mutating the workspace; callers may
retry the same idempotent request.
## Truth boundary
The automatic run currently observes shipped artifact structure. Artifact
changes are static behavior candidates, not runtime observations or causal
claims. Without controlled process comparison Evidence, `behavior_status`
remains `unknown` even when static differences are present. Provider
limitations, incomplete comparison pages, and unsupported container expansion
remain explicit in the final Evidence.
Automatic Hopper function matching, process replay, protocols, UI comparison,
and reconstruction verification are not yet stages of this run. Existing
manual comparison Evidence can still be aggregated through the original
`find_changed_behavior` mode.

View File

@ -131,12 +131,7 @@ all three conditions:
2. `integrity_policy=record-and-continue`;
3. explicit per-call `integrity_continue_approved=true`.
Contradictory bytes are quarantined from nested expansion, recorded with declared
and observed hashes, trust, provenance, path, and unpacked state, and bounded by
`max_integrity_mismatches`. Verified siblings continue. Comparisons classify the
result as a contradiction and reconstruction cannot treat it as unchanged.
`investigate-versions` accepts the same policy through
`--integrity-policy record-and-continue --integrity-continue-approved` and
`--max-integrity-mismatches`. Its Evidence pages and CAS-linked workspace retain
contradictions, so a completed run can resume without rescanning or weakening
trust labels.
Contradictory bytes are quarantined from nested expansion and recorded with
declared and observed hashes, trust, provenance, path, and unpacked state.
Verified siblings continue. Comparisons classify the result as a contradiction
and reconstruction cannot treat it as unchanged.

View File

@ -429,7 +429,7 @@
}
],
"cli": {
"primary_count": 68,
"primary_count": 67,
"commands": [
"analyze",
"analyze-javascript-application",
@ -474,7 +474,6 @@
"inspect-web-page",
"instructions",
"inventory-artifact",
"investigate-versions",
"list-architectures",
"list-browser-targets",
"list-electron-targets",
@ -509,14 +508,14 @@
},
"runtime_catalog": {
"counts": {
"cli_commands": 68,
"cli_commands": 67,
"mcp_tools": 115,
"mcp_prompts": 6
},
"digests": {
"tools_sha256": "4722f1a2fe1d16d493e4a1f51450376e2cd0c617b9659d292a61908d8bb05fcf",
"tools_sha256": "c0b0319310502e55dd3279361c5a65615a12f25c0e97fb0779b79b7950cecfe0",
"prompts_sha256": "77c76df62f92484a43cebf5ca110bdf562c38fbee2531d293eeb51d6e712d459",
"combined_sha256": "94f7cd1ac629a6bbffa77ba3b3d3f2e208e15df821bc0b14db4f81692d6f8eaa",
"combined_sha256": "85d3e5545b3e01eb22b67a1794fdcb737654a1abbea803e1f2445815ee4f17ff",
"providers_sha256": "7446b51788734ebf6f4f702e3a5a8c9f0b61967490c245f30bd37b2060827e6b"
}
}

View File

@ -64,7 +64,6 @@ const SOURCE_PATHS = {
processCapture: "dist/domain/processCapture.js",
analysisSnapshot: "dist/domain/analysisSnapshot.js",
artifactGraph: "dist/domain/artifactGraph.js",
investigationWorkspace: "dist/domain/investigationWorkspace.js",
browserObservation: "dist/domain/browserObservation.js",
browserScenario: "dist/domain/browserScenario.js",
browserScenarioCapture: "dist/domain/browserScenarioCapture.js",

View File

@ -1,72 +0,0 @@
import { execFile } from "node:child_process";
import { readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js";
const exec = promisify(execFile);
/** Verify the packed CLI's persistent investigation and idempotent reuse. */
export async function verifyPackagedInvestigation(input) {
const artifactArchiveV2 = join(input.workspace, "artifact-v2.zip");
const writer = new ZipWriter(new Uint8ArrayWriter());
await writer.add("app/main.js", new TextReader("main(2);"));
await writer.add("app/new.js", new TextReader("feature();"));
await writeFile(artifactArchiveV2, await writer.close());
const workspacePath = join(input.evidenceRoot, "versions.json");
const args = [
"investigate-versions",
input.artifactArchive,
artifactArchiveV2,
workspacePath,
"--workspace-name",
"package-versions",
"--yes",
"--json",
];
const investigated = await runJson(input.cli, args, input.environment);
const reused = await runJson(input.cli, args, input.environment);
let persisted = JSON.parse(await readFile(workspacePath, "utf8"));
const runId = investigated.normalized_result?.investigation_run?.run_id;
if (runId === undefined)
throw new Error("packaged investigation did not return a run ID");
await Promise.all([
rm(input.artifactArchive, { force: true }),
rm(artifactArchiveV2, { force: true }),
]);
const replayed = await runJson(
input.cli,
[...args.slice(0, -1), "--replay-run-id", runId, "--json"],
input.environment,
);
persisted = JSON.parse(await readFile(workspacePath, "utf8"));
if (
investigated.operation !== "find_changed_behavior" ||
investigated.normalized_result?.behavior_status !== "unknown" ||
replayed.evidence_id !== investigated.evidence_id ||
reused.evidence_id !== investigated.evidence_id ||
persisted.revision !== 3 ||
persisted.runs?.[0]?.status !== "complete"
)
throw new Error("packaged persistent investigation CLI failed");
return {
arguments: {
approved: true,
workspace_path: workspacePath,
workspace_name: "package-versions",
expected_workspace_revision: persisted.revision,
replay_run_id: runId,
left_path: input.artifactArchive,
right_path: artifactArchiveV2,
},
evidenceId: investigated.evidence_id,
workspacePath,
revision: persisted.revision,
};
}
async function runJson(command, args, environment) {
const output = await exec(command, args, { env: environment });
return JSON.parse(output.stdout);
}

View File

@ -1,4 +1,3 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
@ -17,28 +16,6 @@ const verifyMcpToolsAndPrompts = async (client, mcpOptions) => {
await prompts.verifyPromptCompletion(client, mcpOptions, false);
};
const verifyMcpReplay = async (client, mcpOptions, investigationReplay) => {
const replay = await client.callTool(
{
name: "find_changed_behavior",
arguments: { investigation_run: investigationReplay.arguments },
},
mcpOptions,
);
const replayEvidence = json(prompts.mcpText(replay));
const replayWorkspace = json(
await readFile(investigationReplay.workspacePath, "utf8"),
);
if (
replay.isError === true ||
replayEvidence.evidence_id !== investigationReplay.evidenceId ||
replayWorkspace.revision !== investigationReplay.revision
)
throw new Error(
"packaged MCP did not replay with workspace-only authority",
);
};
const verifyMcpTargetFree = async (client, mcpOptions) => {
const status = await client.callTool(
{ name: "binary_session", arguments: {} },
@ -179,12 +156,7 @@ const verifyMcpEvidenceBundle = async (client, mcpOptions, evidenceRoot) => {
};
/** Connect to the packaged MCP server and exercise the target-free catalog. */
export async function verifyPackageMcp({
cli,
environment,
evidenceRoot,
investigationReplay,
}) {
export async function verifyPackageMcp({ cli, environment, evidenceRoot }) {
const diagnosed = json(
(
await execute(cli, ["mcp", "doctor", "--json"], {
@ -216,7 +188,6 @@ export async function verifyPackageMcp({
await client.connect(transport);
const mcpOptions = { timeout: 15_000 };
await verifyMcpToolsAndPrompts(client, mcpOptions);
await verifyMcpReplay(client, mcpOptions, investigationReplay);
await verifyMcpTargetFree(client, mcpOptions);
await verifyMcpUnknownProvider(client, mcpOptions);
await verifyMcpBinaryLifecycle(client, mcpOptions);

View File

@ -6,7 +6,6 @@ import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js";
import { PRODUCT_IDENTITY } from "../dist/identity.js";
import { MCP_STARTUP_POLICY } from "../dist/mcpStartupPolicy.js";
import * as prompts from "./verify-package-prompts.mjs";
import { verifyPackagedInvestigation } from "./verify-package-investigation.mjs";
import { verifyPackageArtifactAndElectron } from "./verify-package-artifact.mjs";
import { verifyPackageCapabilitiesAndSearch } from "./verify-package-capabilities.mjs";
import { verifyPackageDiscovery } from "./verify-package-discovery.mjs";
@ -70,7 +69,7 @@ try {
cli,
environment: environmentData.environment,
});
const { artifactArchive } = await verifyPackageArtifactAndElectron({
await verifyPackageArtifactAndElectron({
cli,
workspace,
environment: environmentData.environment,
@ -80,13 +79,6 @@ try {
workspace,
environment: environmentData.environment,
});
const investigationReplay = await verifyPackagedInvestigation({
cli,
workspace,
evidenceRoot,
artifactArchive,
environment: environmentData.environment,
});
await verifyUnknownProvider({
cli,
environment: environmentData.environment,
@ -123,7 +115,6 @@ try {
cli,
environment: environmentData.environment,
evidenceRoot,
investigationReplay,
});
process.stdout.write(
`${JSON.stringify({ verifier_run: await completeVerifierRun(verifierRun), cli: true, analysisCli: true, artifactCli: true, managedCli: true, managedReconstructionCli: true, managedNativeVerificationCli: true, managedRuntimePlanCli: true, managedApplicationGraphCli: true, evidenceCli: true, incurMcpCommand: PRODUCT_IDENTITY.mcpCommand, lifecycleScriptsRequired: false, doctor: "platform-appropriate", setup: supportedSetupHost ? "planned-then-idempotent" : "unsupported-host-rejected", setupPlanReadOnly: supportedSetupHost, existingHopperPreserved: supportedSetupHost, clients: supportedSetupHost ? 3 : 0, backupReadback: supportedSetupHost, failureRecovery: supportedSetupHost, configSymlinkLifecycle: supportedSetupHost, skill: supportedSetupHost, skillReferences: supportedSetupHost, mcpTools: TOOL_CONTRACTS.length, mcpPrompts: prompts.names.length, promptCompletion: true, promptCompletionLifecycle: true, evidenceMcp: true, targetFree: true, targetLifecycle: true, boundedRegexBridge: true, mcpStartup, mcpModuleLoading })}\n`,

View File

@ -159,19 +159,10 @@ type CapabilityAvailability =
readonly availabilityCode?: ProviderRejectionCode;
};
interface CapabilityLimits {
readonly maxResults: number | null;
readonly maxPayloadBytes: number | null;
readonly timeoutMs: number | null;
}
export type CapabilityDescriptor = CapabilityAvailability & {
readonly provider: ProviderIdentity;
readonly operation: Exclude<AnalysisOperation, "health">;
readonly pagination: "none" | "offset" | "cursor";
readonly exhaustive: boolean;
readonly effects: CapabilityEffects;
readonly limits: CapabilityLimits;
readonly limitations: readonly string[];
};

View File

@ -18,7 +18,6 @@ import type {
UnknownStatus,
UpdateUnknownInput,
} from "../domain/residualUnknown.js";
import type { InvestigationWorkspace } from "../domain/investigationWorkspace.js";
import type { ReconstructionCoverageWorkspace } from "../domain/reconstructionCoverage.js";
import type {
AnalysisOperation,
@ -58,14 +57,6 @@ export interface BinarySessionPort extends AnalysisOperationPort {
importAnalysisSnapshot(
snapshot: AnalysisSnapshot,
): Result<number, AnalysisError>;
retainInvestigationWorkspace(
workspace: InvestigationWorkspace,
): "added" | "duplicate";
investigationWorkspace(
workspaceId: string,
revision: number,
): InvestigationWorkspace | undefined;
investigationWorkspaces(): readonly InvestigationWorkspace[];
retainReconstructionCoverageWorkspace(
workspace: ReconstructionCoverageWorkspace,
): "added" | "duplicate";

View File

@ -10,10 +10,6 @@ import {
type AnalysisError,
type UnknownRegistryError,
} from "../domain/errors.js";
import {
parseInvestigationWorkspace,
type InvestigationWorkspace,
} from "../domain/investigationWorkspace.js";
import {
parseReconstructionCoverageWorkspace,
type ReconstructionCoverageWorkspace,
@ -42,14 +38,13 @@ export interface ActiveAnalysisBinding {
readonly profile: AnalysisProfileCommitment | null;
}
/** Owns session evidence, snapshots, workspaces, and residual unknowns. */
/** Owns session evidence, snapshots, and residual unknowns. */
export abstract class BinarySessionRecords {
readonly #evidence = new EvidenceLedger({
maxRecords: 10_000,
maxBytes: 64 * 1024 * 1024,
});
readonly #snapshot = new AnalysisSnapshotCache();
readonly #investigationWorkspaces = new Map<string, InvestigationWorkspace>();
readonly #coverageWorkspaces = new Map<
string,
ReconstructionCoverageWorkspace
@ -216,30 +211,6 @@ export abstract class BinarySessionRecords {
}
}
retainInvestigationWorkspace(
workspace: InvestigationWorkspace,
): "added" | "duplicate" {
const parsed = parseInvestigationWorkspace(workspace);
const key = workspaceKey(parsed.workspace_id, parsed.revision);
if (this.#investigationWorkspaces.has(key)) return "duplicate";
this.#investigationWorkspaces.set(key, parsed);
return "added";
}
investigationWorkspace(
workspaceId: string,
revision: number,
): InvestigationWorkspace | undefined {
const workspace = this.#investigationWorkspaces.get(
workspaceKey(workspaceId, revision),
);
return workspace === undefined ? undefined : structuredClone(workspace);
}
investigationWorkspaces(): readonly InvestigationWorkspace[] {
return sortedWorkspaces(this.#investigationWorkspaces.values());
}
retainReconstructionCoverageWorkspace(
workspace: ReconstructionCoverageWorkspace,
): "added" | "duplicate" {

View File

@ -193,8 +193,6 @@ const capabilityStatus = (
available: descriptor.available,
reason: descriptor.reason,
availability_code: descriptor.availabilityCode ?? null,
pagination: descriptor.pagination,
exhaustive: descriptor.exhaustive,
effects: {
mutates_artifact: descriptor.effects.mutatesArtifact,
launches_process: descriptor.effects.launchesProcess,
@ -204,10 +202,5 @@ const capabilityStatus = (
changes_permissions: descriptor.effects.changesPermissions,
requires_root: descriptor.effects.requiresRoot,
},
limits: {
max_results: descriptor.limits.maxResults,
max_payload_bytes: descriptor.limits.maxPayloadBytes,
timeout_ms: descriptor.limits.timeoutMs,
},
limitations: [...descriptor.limitations],
});

View File

@ -34,8 +34,6 @@ const status = (
: (capability.availability_code ?? null),
input_contract_version: 1,
output_contract_version: 1,
pagination: "none" as const,
exhaustive: true,
effects: {
mutates_artifact: false,
launches_process: false,
@ -45,11 +43,6 @@ const status = (
changes_permissions: false,
requires_root: false,
},
limits: {
max_results: null,
max_payload_bytes: null,
timeout_ms: null,
},
limitations: [],
})),
});

View File

@ -1,219 +0,0 @@
import type { ArtifactLimits } from "../artifacts/ArtifactReader.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { createEvidence, type Evidence } from "../domain/evidence.js";
import {
AnalysisCancelledError,
ArtifactOperationError,
type AnalysisError,
} from "../domain/errors.js";
import type {
CrossVersionInvestigationInput,
InvestigationRunOptions,
InvestigationRunTarget,
} from "../domain/investigationWorkspace.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { err, ok, type Result } from "../domain/result.js";
import {
paginateArtifactInventory,
type ArtifactIntegrityPolicy,
type ArtifactInventorySnapshot,
} from "./ArtifactInventory.js";
import { scanAuthorizedArtifactInventory } from "./AuthorizedArtifactInventory.js";
import { ARTIFACT_GRAPH_PROVIDER } from "./InvestigationProviders.js";
import { resolveArtifactIntegrityPolicy } from "./ArtifactInventory/policy.js";
export const AUTOMATIC_RUN_LIMITATION =
"Automatic cross-version runs compare shipped artifact structure only; they do not execute either version.";
export interface VersionSnapshots {
readonly left: ArtifactInventorySnapshot;
readonly right: ArtifactInventorySnapshot;
}
export interface InventoryEvidencePages {
readonly left: readonly Evidence[];
readonly right: readonly Evidence[];
}
/** Injectable artifact scan boundary used by paired version inventory. */
export type VersionInventoryScanner = typeof scanAuthorizedArtifactInventory;
/** Inputs shared by the two scans in one version comparison. */
export interface VersionInventoryScanOptions {
readonly leftPath: string;
readonly rightPath: string;
readonly limits: ArtifactLimits;
readonly signal?: AbortSignal;
readonly integrity: ArtifactIntegrityPolicy;
}
/** Start both independent version scans while preserving left/right identity. */
export const scanVersionInventories = async (
options: VersionInventoryScanOptions,
scanner: VersionInventoryScanner = scanAuthorizedArtifactInventory,
): Promise<VersionSnapshots> => {
const [left, right] = await Promise.all([
scanner(options.leftPath, options.limits, {
signal: options.signal,
integrity: options.integrity,
}),
scanner(options.rightPath, options.limits, {
signal: options.signal,
integrity: options.integrity,
}),
]);
return { left, right };
};
/** Authorize and scan both inputs once under the same bounded traversal policy. */
export const scanVersions = async (
input: CrossVersionInvestigationInput,
signal?: AbortSignal,
integrityContinueEnabled = false,
): Promise<Result<VersionSnapshots, AnalysisError>> => {
try {
if (signal?.aborted === true)
return err(new AnalysisCancelledError("find_changed_behavior"));
const limits = artifactLimits(input.options);
const integrity = resolveArtifactIntegrityPolicy(
input.integrity_policy === "fail"
? { mode: "fail" }
: {
mode: input.integrity_policy,
maxMismatches: input.max_integrity_mismatches,
},
integrityContinueEnabled,
);
return ok(
await scanVersionInventories({
leftPath: input.left_path,
rightPath: input.right_path,
limits,
...(signal === undefined ? {} : { signal }),
integrity,
}),
);
} catch (cause: unknown) {
if (cause instanceof ArtifactReaderFailure)
return err(
new ArtifactOperationError(
"inventory_artifact",
cause.reason,
cause.details,
),
);
return err(new ArtifactOperationError("inventory_artifact", "io"));
}
};
/** Project complete snapshots into bounded, comparable Evidence page sets. */
export const createInventoryEvidencePages = (
input: CrossVersionInvestigationInput,
snapshots: VersionSnapshots,
): Result<InventoryEvidencePages, AnalysisError> => {
const left = inventoryPages(input.left_path, snapshots.left, input);
const right = inventoryPages(input.right_path, snapshots.right, input);
if (left.length > 100 || right.length > 100)
return err(new ArtifactOperationError("inventory_artifact", "limit"));
return ok({ left, right });
};
/** Reduce a snapshot to the content commitments used by run identity. */
export const targetFor = (
snapshot: ArtifactInventorySnapshot,
): InvestigationRunTarget => ({
root_sha256: snapshot.manifest.root_sha256,
graph_sha256: snapshot.manifest.graph_sha256,
manifest_id: snapshot.manifest.manifest_id,
format: snapshot.manifest.root_format,
});
/** Preserve scan and execution limitations in stable order. */
export const runLimitations = (snapshots: VersionSnapshots): string[] =>
[
...new Set([
...snapshots.left.limitations.map((item) => `Left: ${item}`),
...snapshots.right.limitations.map((item) => `Right: ${item}`),
AUTOMATIC_RUN_LIMITATION,
]),
].sort((left, right) => left.localeCompare(right));
const inventoryPages = (
path: string,
snapshot: ArtifactInventorySnapshot,
input: CrossVersionInvestigationInput,
): Evidence[] => {
const options = input.options;
const count = Math.max(
1,
Math.ceil(snapshot.nodes.length / options.page_size),
Math.ceil(snapshot.occurrences.length / options.page_size),
Math.ceil(snapshot.edges.length / options.page_size),
);
return Array.from({ length: count }, (_, index) =>
inventoryPage(path, snapshot, input, index * options.page_size),
);
};
const inventoryPage = (
path: string,
snapshot: ArtifactInventorySnapshot,
input: CrossVersionInvestigationInput,
offset: number,
): Evidence => {
const options = input.options;
const result = paginateArtifactInventory(snapshot, {
nodeOffset: offset,
nodeLimit: options.page_size,
occurrenceOffset: offset,
occurrenceLimit: options.page_size,
edgeOffset: offset,
edgeLimit: options.page_size,
});
return createEvidence(
{
path,
sha256: snapshot.manifest.root_sha256,
format: snapshot.manifest.root_format,
},
ARTIFACT_GRAPH_PROVIDER,
{
operation: "inventory_artifact",
parameters: {
node_offset: offset,
node_limit: options.page_size,
occurrence_offset: offset,
occurrence_limit: options.page_size,
edge_offset: offset,
edge_limit: options.page_size,
max_entries: options.max_entries,
max_total_bytes: options.max_total_bytes,
max_entry_bytes: options.max_entry_bytes,
max_compression_ratio: options.max_compression_ratio,
max_depth: options.max_depth,
max_path_bytes: options.max_path_bytes,
integrity_policy: input.integrity_policy,
integrity_continue_approved: input.integrity_continue_approved,
max_integrity_mismatches: input.max_integrity_mismatches,
},
result: jsonValueSchema.parse(result),
rawResult: null,
limitations: result.limitations,
locations: result.occurrences.items.map(
({ logical_path: logicalPath }) => ({
kind: "artifact-path" as const,
path: logicalPath,
}),
),
},
);
};
const artifactLimits = (options: InvestigationRunOptions): ArtifactLimits => ({
maxEntries: options.max_entries,
maxTotalBytes: options.max_total_bytes,
maxEntryBytes: options.max_entry_bytes,
maxCompressionRatio: options.max_compression_ratio,
maxDepth: options.max_depth,
maxPathBytes: options.max_path_bytes,
});

View File

@ -1,498 +0,0 @@
import { type Evidence } from "../domain/evidence.js";
import {
createEvidenceBundle,
type EvidenceFilePolicy,
} from "../domain/evidenceBundle.js";
import {
AnalysisCancelledError,
AnalysisInputError,
EvidenceIntegrityError,
InvestigationWorkspaceError,
type AnalysisError,
} from "../domain/errors.js";
import {
createLegacyInvestigationRunIdentity,
createInvestigationRunIdentity,
createInvestigationWorkspace,
crossVersionInvestigationInputSchema,
investigationRunSchema,
reviseInvestigationWorkspace,
type CrossVersionInvestigationInput,
type InvestigationRun,
type InvestigationWorkspace,
} from "../domain/investigationWorkspace.js";
import { err, ok, type Result } from "../domain/result.js";
import type { BinarySessionPort } from "./BinarySession.js";
import type { ProgressReporter } from "./ProgressReporter.js";
import {
createInventoryEvidencePages,
runLimitations,
scanVersions,
targetFor,
type InventoryEvidencePages,
type VersionSnapshots,
} from "./CrossVersionInventory.js";
import {
readInvestigationWorkspace,
writeInvestigationWorkspace,
} from "./InvestigationWorkspaceStore.js";
import {
createArtifactComparisonEvidence,
createChangedBehaviorEvidence,
} from "./CrossVersionInvestigationEvidence.js";
import { selectCompletedInvestigationReplay } from "./CrossVersionInvestigationReplay.js";
import {
investigationEvidenceIds as evidenceIds,
mergeInvestigationEvidence as mergeEvidence,
recordsForInvestigationRun as recordsForRun,
replaceInvestigationRun as replaceRun,
} from "./CrossVersionInvestigationCollections.js";
export interface CrossVersionInvestigationOutcome {
readonly evidence: Evidence;
readonly workspace: InvestigationWorkspace;
readonly reused: boolean;
}
/** Runtime authority and cancellation capabilities for one investigation. */
export interface CrossVersionInvestigationExecution {
readonly session?: BinarySessionPort;
readonly signal?: AbortSignal;
readonly progress?: ProgressReporter;
readonly integrityContinueEnabled?: boolean;
readonly authorizeWorkspaceWrite?: () => Promise<Result<null, AnalysisError>>;
}
/** Run or resume a deterministic cross-version artifact investigation. */
export const runCrossVersionInvestigation = async (
input: unknown,
policy: EvidenceFilePolicy,
execution: CrossVersionInvestigationExecution,
): Promise<Result<CrossVersionInvestigationOutcome, AnalysisError>> => {
const parsed = crossVersionInvestigationInputSchema.safeParse(input);
if (!parsed.success)
return err(
new AnalysisInputError("find_changed_behavior", {
cause: parsed.error,
}),
);
return runCrossVersionInvestigationValidated(parsed.data, policy, execution);
};
/** Run input already parsed by a trusted adapter boundary. */
export const runCrossVersionInvestigationValidated = async (
input: CrossVersionInvestigationInput,
policy: EvidenceFilePolicy,
execution: CrossVersionInvestigationExecution,
): Promise<Result<CrossVersionInvestigationOutcome, AnalysisError>> => {
const initial = await readInvestigationWorkspace(
input.workspace_path,
policy,
);
if (!initial.ok) return initial;
const preflight = validateWorkspaceRequest(initial.value, input);
if (!preflight.ok) return preflight;
if (isCancelled(execution.signal))
return err(new AnalysisCancelledError("find_changed_behavior"));
if (input.replay_run_id !== undefined) {
const replay = selectCompletedInvestigationReplay(initial.value, input);
if (!replay.ok) return replay;
return completeOutcome(
replay.value.workspace,
replay.value.run,
true,
execution.session,
);
}
await execution.progress?.report({
phase: "scan_versions",
completed: 0,
total: 4,
message: "Scanning both version inputs",
});
const snapshots = await scanVersions(
input,
execution.signal,
execution.integrityContinueEnabled,
);
if (!snapshots.ok) return snapshots;
await execution.progress?.report({
phase: "scan_versions",
completed: 1,
total: 4,
message: "Version inputs scanned",
});
return continueInvestigation({
input,
initial: initial.value,
snapshots: snapshots.value,
policy,
session: execution.session,
signal: execution.signal,
progress: execution.progress,
authorizeWorkspaceWrite: execution.authorizeWorkspaceWrite,
});
};
interface RunState {
readonly workspace: InvestigationWorkspace;
readonly run: InvestigationRun;
}
const continueInvestigation = async (context: {
readonly input: CrossVersionInvestigationInput;
readonly initial: InvestigationWorkspace | null;
readonly snapshots: VersionSnapshots;
readonly policy: EvidenceFilePolicy;
readonly session: BinarySessionPort | undefined;
readonly signal: AbortSignal | undefined;
readonly progress: ProgressReporter | undefined;
readonly authorizeWorkspaceWrite:
| (() => Promise<Result<null, AnalysisError>>)
| undefined;
}): Promise<Result<CrossVersionInvestigationOutcome, AnalysisError>> => {
const left = targetFor(context.snapshots.left);
const right = targetFor(context.snapshots.right);
const identity = createInvestigationRunIdentity({
left,
right,
options: context.input.options,
integrity_policy: context.input.integrity_policy,
integrity_continue_approved: context.input.integrity_continue_approved,
max_integrity_mismatches: context.input.max_integrity_mismatches,
});
const legacyIdentity = createLegacyInvestigationRunIdentity({
left,
right,
options: context.input.options,
});
const existing = context.initial?.runs.find(
(run) =>
run.run_id === identity.runId ||
(context.input.integrity_policy === "fail" &&
!context.input.integrity_continue_approved &&
context.input.max_integrity_mismatches === 10 &&
"legacy_request_identity" in run &&
run.max_integrity_mismatches === 10 &&
run.run_id === legacyIdentity.runId),
);
if (context.initial !== null && existing?.status === "complete")
return completeOutcome(context.initial, existing, true, context.session);
if (context.authorizeWorkspaceWrite !== undefined) {
const authorized = await context.authorizeWorkspaceWrite();
if (!authorized.ok) return authorized;
}
const pages = createInventoryEvidencePages(context.input, context.snapshots);
if (!pages.ok) return pages;
if (isCancelled(context.signal))
return err(new AnalysisCancelledError("find_changed_behavior"));
const inventoried = await ensureInventoryCheckpoint({
input: context.input,
current: context.initial,
existing,
pages: pages.value,
snapshots: context.snapshots,
policy: context.policy,
});
if (!inventoried.ok) return inventoried;
await context.progress?.report({
phase: "inventory_versions",
completed: 2,
total: 4,
message: "Both artifact inventories checkpointed",
});
if (isCancelled(context.signal))
return err(new AnalysisCancelledError("find_changed_behavior"));
const compared = await ensureComparisonCheckpoint(
context.input,
inventoried.value,
context.policy,
);
if (!compared.ok) return compared;
await context.progress?.report({
phase: "compare_artifacts",
completed: 3,
total: 4,
message: "Artifact comparison checkpointed",
});
if (isCancelled(context.signal))
return err(new AnalysisCancelledError("find_changed_behavior"));
const finalized = await finalizeInvestigation(
context.input,
compared.value,
context.policy,
context.session,
);
await context.progress?.report({
phase: "changed_behavior",
completed: 4,
total: 4,
message: finalized.ok ? "Investigation completed" : "Investigation failed",
terminal: true,
});
return finalized;
};
const isCancelled = (signal: AbortSignal | undefined): boolean =>
signal?.aborted === true;
const ensureInventoryCheckpoint = async (input: {
readonly input: CrossVersionInvestigationInput;
readonly current: InvestigationWorkspace | null;
readonly existing: InvestigationRun | undefined;
readonly pages: InventoryEvidencePages;
readonly snapshots: VersionSnapshots;
readonly policy: EvidenceFilePolicy;
}): Promise<Result<RunState, AnalysisError>> => {
if (input.existing !== undefined) {
const consistent = validateInventoryCheckpoint(input.existing, input.pages);
if (!consistent.ok) return consistent;
return input.current === null
? err(new EvidenceIntegrityError("Investigation workspace is missing"))
: ok({ workspace: input.current, run: input.existing });
}
const left = targetFor(input.snapshots.left);
const right = targetFor(input.snapshots.right);
const identity = createInvestigationRunIdentity({
left,
right,
options: input.input.options,
integrity_policy: input.input.integrity_policy,
integrity_continue_approved: input.input.integrity_continue_approved,
max_integrity_mismatches: input.input.max_integrity_mismatches,
});
const run = investigationRunSchema.parse({
schema_version: 1,
run_id: identity.runId,
request_sha256: identity.requestSha256,
left,
right,
options: input.input.options,
integrity_policy: input.input.integrity_policy,
integrity_continue_approved: input.input.integrity_continue_approved,
max_integrity_mismatches: input.input.max_integrity_mismatches,
status: "running",
completed_stages: ["inventory_left", "inventory_right"],
left_inventory_evidence_ids: evidenceIds(input.pages.left),
right_inventory_evidence_ids: evidenceIds(input.pages.right),
comparison_evidence_id: null,
result_evidence_id: null,
limitations: runLimitations(input.snapshots),
});
const saved = await checkpoint({
path: input.input.workspace_path,
name: input.input.workspace_name,
current: input.current,
records: [...input.pages.left, ...input.pages.right],
run,
policy: input.policy,
});
return saved.ok ? ok({ workspace: saved.value, run }) : saved;
};
const ensureComparisonCheckpoint = async (
input: CrossVersionInvestigationInput,
state: RunState,
policy: EvidenceFilePolicy,
): Promise<Result<RunState, AnalysisError>> => {
if (state.run.comparison_evidence_id !== null) return ok(state);
const inventory = inventoryEvidence(state.workspace, state.run);
if (!inventory.ok) return inventory;
const evidence = createArtifactComparisonEvidence(
inventory.value,
input.options.change_limit,
);
const run = investigationRunSchema.parse({
...state.run,
completed_stages: [
"inventory_left",
"inventory_right",
"compare_artifacts",
],
comparison_evidence_id: evidence.evidence_id,
});
const saved = await checkpoint({
path: input.workspace_path,
name: input.workspace_name,
current: state.workspace,
records: [evidence],
run,
policy,
});
return saved.ok ? ok({ workspace: saved.value, run }) : saved;
};
const finalizeInvestigation = async (
input: CrossVersionInvestigationInput,
state: RunState,
policy: EvidenceFilePolicy,
session?: BinarySessionPort,
): Promise<Result<CrossVersionInvestigationOutcome, AnalysisError>> => {
const comparison = comparisonEvidenceFor(state.workspace, state.run);
if (!comparison.ok) return comparison;
const evidence = createChangedBehaviorEvidence(
state.workspace,
state.run,
comparison.value,
);
const run = investigationRunSchema.parse({
...state.run,
status: "complete",
completed_stages: [
"inventory_left",
"inventory_right",
"compare_artifacts",
"find_changed_behavior",
],
result_evidence_id: evidence.evidence_id,
});
const saved = await checkpoint({
path: input.workspace_path,
name: input.workspace_name,
current: state.workspace,
records: [evidence],
run,
policy,
});
return saved.ok ? completeOutcome(saved.value, run, false, session) : saved;
};
const comparisonEvidenceFor = (
workspace: InvestigationWorkspace,
run: InvestigationRun,
): Result<Evidence, EvidenceIntegrityError> => {
const evidence = workspace.bundle.records.find(
({ evidence_id: id }) => id === run.comparison_evidence_id,
);
return evidence === undefined
? err(
new EvidenceIntegrityError(
"Investigation comparison checkpoint is missing Evidence",
),
)
: ok(evidence);
};
const checkpoint = async (input: {
readonly path: string;
readonly name: string;
readonly current: InvestigationWorkspace | null;
readonly records: readonly Evidence[];
readonly run: InvestigationRun;
readonly policy: EvidenceFilePolicy;
}): Promise<Result<InvestigationWorkspace, AnalysisError>> => {
const records = mergeEvidence(
input.current?.bundle.records ?? [],
input.records,
);
const bundle = createEvidenceBundle(
records,
input.current?.bundle.unknowns ?? [],
);
const runs = replaceRun(input.current?.runs ?? [], input.run);
const next =
input.current === null
? createInvestigationWorkspace(input.name, bundle, runs)
: reviseInvestigationWorkspace(input.current, bundle, runs);
const written = await writeInvestigationWorkspace(
next,
input.path,
input.current?.revision ?? null,
input.policy,
);
return written.ok ? ok(next) : written;
};
const completeOutcome = (
workspace: InvestigationWorkspace,
run: InvestigationRun,
reused: boolean,
session?: BinarySessionPort,
): Result<CrossVersionInvestigationOutcome, AnalysisError> => {
if (run.result_evidence_id === null)
return err(
new EvidenceIntegrityError("Completed investigation has no result"),
);
const evidence = workspace.bundle.records.find(
({ evidence_id: id }) => id === run.result_evidence_id,
);
if (evidence === undefined)
return err(
new EvidenceIntegrityError(
"Completed investigation result Evidence is missing",
),
);
if (session !== undefined) {
const records = recordsForRun(workspace, run);
const imported = session.importEvidenceBundle(
createEvidenceBundle(records),
);
if (!imported.ok) return imported;
}
return ok({ evidence, workspace, reused });
};
const validateWorkspaceRequest = (
workspace: InvestigationWorkspace | null,
input: CrossVersionInvestigationInput,
): Result<null, InvestigationWorkspaceError> => {
if (workspace !== null && workspace.name !== input.workspace_name)
return err(new InvestigationWorkspaceError("update", "name-conflict"));
if (
input.expected_workspace_revision !== undefined &&
workspace?.revision !== input.expected_workspace_revision
)
return err(new InvestigationWorkspaceError("update", "revision-conflict"));
return ok(null);
};
const validateInventoryCheckpoint = (
run: InvestigationRun,
pages: {
readonly left: readonly Evidence[];
readonly right: readonly Evidence[];
},
): Result<null, EvidenceIntegrityError> =>
JSON.stringify(run.left_inventory_evidence_ids) ===
JSON.stringify(evidenceIds(pages.left)) &&
JSON.stringify(run.right_inventory_evidence_ids) ===
JSON.stringify(evidenceIds(pages.right))
? ok(null)
: err(
new EvidenceIntegrityError(
"Investigation inventory checkpoint disagrees with current content",
),
);
const inventoryEvidence = (
workspace: InvestigationWorkspace | null,
run: InvestigationRun,
): Result<
{ readonly left: readonly Evidence[]; readonly right: readonly Evidence[] },
EvidenceIntegrityError
> => {
if (workspace === null)
return err(
new EvidenceIntegrityError("Investigation workspace is missing"),
);
const byId = new Map(
workspace.bundle.records.map((record) => [record.evidence_id, record]),
);
const left = run.left_inventory_evidence_ids.flatMap((id) => {
const evidence = byId.get(id);
return evidence === undefined ? [] : [evidence];
});
const right = run.right_inventory_evidence_ids.flatMap((id) => {
const evidence = byId.get(id);
return evidence === undefined ? [] : [evidence];
});
return left.length === run.left_inventory_evidence_ids.length &&
right.length === run.right_inventory_evidence_ids.length
? ok({ left, right })
: err(
new EvidenceIntegrityError(
"Investigation inventory checkpoint is incomplete",
),
);
};

View File

@ -1,45 +0,0 @@
import type { Evidence } from "../domain/evidence.js";
import type {
InvestigationRun,
InvestigationWorkspace,
} from "../domain/investigationWorkspace.js";
export const recordsForInvestigationRun = (
workspace: InvestigationWorkspace,
run: InvestigationRun,
): Evidence[] => {
const ids = new Set([
...run.left_inventory_evidence_ids,
...run.right_inventory_evidence_ids,
...(run.comparison_evidence_id === null
? []
: [run.comparison_evidence_id]),
...(run.result_evidence_id === null ? [] : [run.result_evidence_id]),
]);
return workspace.bundle.records.filter(({ evidence_id: id }) => ids.has(id));
};
export const mergeInvestigationEvidence = (
current: readonly Evidence[],
additions: readonly Evidence[],
): Evidence[] => {
const records = new Map(
current.map((record) => [record.evidence_id, record]),
);
for (const record of additions)
if (!records.has(record.evidence_id))
records.set(record.evidence_id, record);
return [...records.values()];
};
export const replaceInvestigationRun = (
runs: readonly InvestigationRun[],
replacement: InvestigationRun,
): InvestigationRun[] => [
...runs.filter(({ run_id: id }) => id !== replacement.run_id),
replacement,
];
export const investigationEvidenceIds = (
records: readonly Evidence[],
): string[] => records.map(({ evidence_id: id }) => id);

View File

@ -1,91 +0,0 @@
import { compareArtifacts } from "../domain/artifactComparison.js";
import { findChangedBehavior } from "../domain/changedBehavior.js";
import { createEvidence, type Evidence } from "../domain/evidence.js";
import {
investigationRunSummarySchema,
type InvestigationRun,
type InvestigationWorkspace,
} from "../domain/investigationWorkspace.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { AUTOMATIC_RUN_LIMITATION } from "./CrossVersionInventory.js";
import {
ARTIFACT_COMPARISON_PROVIDER,
CHANGED_BEHAVIOR_PROVIDER,
} from "./InvestigationProviders.js";
/** Derive deterministic artifact-comparison Evidence from inventory pages. */
export const createArtifactComparisonEvidence = (
inventory: {
readonly left: readonly Evidence[];
readonly right: readonly Evidence[];
},
limit: number,
): Evidence => {
const comparison = compareArtifacts(
inventory.left,
inventory.right,
0,
limit,
);
const leftIds = evidenceIds(inventory.left);
const rightIds = evidenceIds(inventory.right);
return createEvidence(undefined, ARTIFACT_COMPARISON_PROVIDER, {
predicateType: "rea.artifact-comparison/v1",
operation: "compare_artifacts",
parameters: {
left_evidence_ids: leftIds,
right_evidence_ids: rightIds,
offset: 0,
limit,
},
result: jsonValueSchema.parse(comparison),
confidence: "derived",
authority: "analyst-inference",
limitations: comparison.limitations,
evidenceLinks: [...leftIds, ...rightIds],
});
};
/** Derive the deterministic final report for one comparison checkpoint. */
export const createChangedBehaviorEvidence = (
workspace: InvestigationWorkspace,
run: InvestigationRun,
comparison: Evidence,
): Evidence => {
const limit = Math.min(run.options.change_limit, 100);
const changed = findChangedBehavior([comparison], 0, limit);
const summary = investigationRunSummarySchema.parse({
schema_version: 1,
workspace_id: workspace.workspace_id,
run_id: run.run_id,
left_manifest_id: run.left.manifest_id,
right_manifest_id: run.right.manifest_id,
inventory_evidence_count:
run.left_inventory_evidence_ids.length +
run.right_inventory_evidence_ids.length,
comparison_evidence_id: comparison.evidence_id,
limitations: run.limitations,
});
return createEvidence(undefined, CHANGED_BEHAVIOR_PROVIDER, {
predicateType: "rea.changed-behavior/v1",
operation: "find_changed_behavior",
parameters: {
workspace_id: workspace.workspace_id,
run_id: run.run_id,
comparison_evidence_ids: [comparison.evidence_id],
offset: 0,
limit,
},
result: jsonValueSchema.parse({
...changed,
investigation_run: summary,
}),
confidence: "derived",
authority: "analyst-inference",
limitations: [...changed.limitations, AUTOMATIC_RUN_LIMITATION],
evidenceLinks: changed.evidence_links,
});
};
const evidenceIds = (records: readonly Evidence[]): string[] =>
records.map(({ evidence_id: id }) => id);

View File

@ -1,341 +0,0 @@
import { isDeepStrictEqual } from "node:util";
import {
artifactInventoryResultSchema,
type ArtifactInventoryResult,
} from "../domain/artifactGraph.js";
import { parseArtifactInventoryEvidence } from "../domain/artifactInventoryEvidence.js";
import type { Evidence } from "../domain/evidence.js";
import {
EvidenceIntegrityError,
InvestigationWorkspaceError,
} from "../domain/errors.js";
import type {
CrossVersionInvestigationInput,
InvestigationRun,
InvestigationRunTarget,
InvestigationWorkspace,
} from "../domain/investigationWorkspace.js";
import { err, ok, type Result } from "../domain/result.js";
import {
createArtifactComparisonEvidence,
createChangedBehaviorEvidence,
} from "./CrossVersionInvestigationEvidence.js";
import { AUTOMATIC_RUN_LIMITATION } from "./CrossVersionInventory.js";
import { ARTIFACT_GRAPH_PROVIDER } from "./InvestigationProviders.js";
/** Validated workspace state returned by an explicit cache-only selector. */
export interface CompletedInvestigationReplay {
readonly workspace: InvestigationWorkspace;
readonly run: InvestigationRun;
}
interface ValidatedInventory {
readonly records: readonly Evidence[];
readonly result: ReturnType<typeof parseArtifactInventoryEvidence>;
}
/** Select and verify one caller-identified complete run without input access. */
export const selectCompletedInvestigationReplay = (
workspace: InvestigationWorkspace | null,
input: CrossVersionInvestigationInput,
): Result<
CompletedInvestigationReplay,
InvestigationWorkspaceError | EvidenceIntegrityError
> => {
const run = workspace?.runs.find(
({ run_id: runId }) => runId === input.replay_run_id,
);
if (
workspace === null ||
run === undefined ||
run.status !== "complete" ||
!controlsMatch(run, input)
)
return replayConflict();
const records = new Map(
workspace.bundle.records.map((record) => [record.evidence_id, record]),
);
const left = inventoryRecords(records, run.left_inventory_evidence_ids);
const right = inventoryRecords(records, run.right_inventory_evidence_ids);
if (!left.ok) return left;
if (!right.ok) return right;
if (
!pathsMatch(left.value, input.left_path) ||
!pathsMatch(right.value, input.right_path)
)
return replayConflict();
const validated = validateLinkedEvidence(
workspace,
run,
left.value,
right.value,
);
return validated.ok ? ok({ workspace, run }) : validated;
};
const controlsMatch = (
run: InvestigationRun,
input: CrossVersionInvestigationInput,
): boolean =>
isDeepStrictEqual(run.options, input.options) &&
run.integrity_policy === input.integrity_policy &&
run.integrity_continue_approved === input.integrity_continue_approved &&
run.max_integrity_mismatches === input.max_integrity_mismatches;
const inventoryRecords = (
records: ReadonlyMap<string, Evidence>,
evidenceIds: readonly string[],
): Result<readonly Evidence[], EvidenceIntegrityError> => {
if (new Set(evidenceIds).size !== evidenceIds.length)
return replayIntegrity("Investigation replay repeats inventory Evidence");
const selected = evidenceIds.flatMap((evidenceId) => {
const evidence = records.get(evidenceId);
return evidence === undefined ? [] : [evidence];
});
return selected.length === evidenceIds.length
? ok(selected)
: replayIntegrity("Investigation replay is missing inventory Evidence");
};
const pathsMatch = (records: readonly Evidence[], path: string): boolean =>
records.every(({ subject }) => subject?.local_path === path);
const validateLinkedEvidence = (
workspace: InvestigationWorkspace,
run: InvestigationRun,
leftRecords: readonly Evidence[],
rightRecords: readonly Evidence[],
): Result<null, EvidenceIntegrityError> => {
try {
const left = validateInventory(leftRecords, run.left, run);
const right = validateInventory(rightRecords, run.right, run);
if (!left.ok) return left;
if (!right.ok) return right;
const expectedLimitations = runLimitations(left.value, right.value);
if (!isDeepStrictEqual(run.limitations, expectedLimitations))
return replayIntegrity(
"Investigation replay limitations disagree with inventory Evidence",
);
const comparison = createArtifactComparisonEvidence(
{ left: left.value.records, right: right.value.records },
run.options.change_limit,
);
const storedComparison = evidenceFor(workspace, run.comparison_evidence_id);
if (
storedComparison === undefined ||
!isDeepStrictEqual(storedComparison, comparison)
)
return replayIntegrity(
"Investigation replay comparison Evidence is inconsistent",
);
const result = createChangedBehaviorEvidence(workspace, run, comparison);
const storedResult = evidenceFor(workspace, run.result_evidence_id);
return storedResult !== undefined && isDeepStrictEqual(storedResult, result)
? ok(null)
: replayIntegrity("Investigation replay result Evidence is inconsistent");
} catch (cause: unknown) {
return replayIntegrity(
"Investigation replay linked Evidence is invalid",
cause,
);
}
};
const validateInventory = (
records: readonly Evidence[],
target: InvestigationRunTarget,
run: InvestigationRun,
): Result<ValidatedInventory, EvidenceIntegrityError> => {
const pages = records.map((record) =>
artifactInventoryResultSchema.parse(record.normalized_result),
);
const expectedPageCount = Math.max(
1,
Math.ceil((pages[0]?.manifest.node_count ?? 0) / run.options.page_size),
Math.ceil(
(pages[0]?.manifest.occurrence_count ?? 0) / run.options.page_size,
),
Math.ceil((pages[0]?.manifest.edge_count ?? 0) / run.options.page_size),
);
if (records.length !== expectedPageCount)
return replayIntegrity(
"Investigation replay inventory Evidence pagination is inconsistent",
);
if (!sameSnapshotMetadata(pages))
return replayIntegrity(
"Investigation replay inventory pages disagree on snapshot metadata",
);
for (const [index, record] of records.entries()) {
const page = pages[index];
if (page === undefined || !inventoryRecordMatches(record, page, run, index))
return replayIntegrity(
"Investigation replay inventory Evidence is inconsistent",
);
}
const result = parseArtifactInventoryEvidence(records);
if (!result.inventory.complete || !targetMatches(result.inventory, target))
return replayIntegrity(
"Investigation replay inventory commitment is inconsistent",
);
return ok({ records, result });
};
const inventoryRecordMatches = (
record: Evidence,
page: ArtifactInventoryResult,
run: InvestigationRun,
index: number,
): boolean => {
const offset = index * run.options.page_size;
const parameters = {
node_offset: offset,
node_limit: run.options.page_size,
occurrence_offset: offset,
occurrence_limit: run.options.page_size,
edge_offset: offset,
edge_limit: run.options.page_size,
max_entries: run.options.max_entries,
max_total_bytes: run.options.max_total_bytes,
max_entry_bytes: run.options.max_entry_bytes,
max_compression_ratio: run.options.max_compression_ratio,
max_depth: run.options.max_depth,
max_path_bytes: run.options.max_path_bytes,
integrity_policy: run.integrity_policy,
integrity_continue_approved: run.integrity_continue_approved,
max_integrity_mismatches: run.max_integrity_mismatches,
};
const locations = page.occurrences.items.map(({ logical_path: path }) => ({
kind: "artifact-path" as const,
path,
}));
return (
record.subject !== null &&
record.subject.name === record.subject.local_path.split("/").at(-1) &&
record.subject.digest.sha256 === page.manifest.root_sha256 &&
record.subject.format === page.manifest.root_format &&
record.subject.architecture === null &&
isDeepStrictEqual(record.provider, ARTIFACT_GRAPH_PROVIDER) &&
record.predicate_type === "rea.analysis/v2" &&
record.operation === "inventory_artifact" &&
isDeepStrictEqual(record.parameters, parameters) &&
record.raw_result === null &&
record.confidence === "observed" &&
record.authority === "shipped-artifact" &&
record.environment === null &&
isDeepStrictEqual(record.limitations, page.limitations) &&
isDeepStrictEqual(record.locations, locations) &&
record.evidence_links.length === 0 &&
isDeepStrictEqual(page.limits, traversalLimits(run)) &&
pageMatches(
page.nodes,
offset,
run.options.page_size,
page.manifest.node_count,
) &&
pageMatches(
page.occurrences,
offset,
run.options.page_size,
page.manifest.occurrence_count,
) &&
pageMatches(
page.edges,
offset,
run.options.page_size,
page.manifest.edge_count,
)
);
};
const sameSnapshotMetadata = (
pages: readonly ArtifactInventoryResult[],
): boolean => {
const first = pages[0];
return (
first !== undefined &&
pages.every(
(page) =>
isDeepStrictEqual(page.manifest, first.manifest) &&
isDeepStrictEqual(page.limits, first.limits) &&
isDeepStrictEqual(page.provenance, first.provenance) &&
isDeepStrictEqual(
page.integrity_contradictions,
first.integrity_contradictions,
) &&
isDeepStrictEqual(page.limitations, first.limitations),
)
);
};
const pageMatches = (
page: {
readonly items: readonly unknown[];
readonly offset: number;
readonly limit: number;
readonly total: number;
readonly next_offset: number | null;
},
offset: number,
limit: number,
total: number,
): boolean =>
page.offset === offset &&
page.limit === limit &&
page.total === total &&
page.items.length === Math.max(0, Math.min(limit, page.total - offset)) &&
page.next_offset === (offset + limit < page.total ? offset + limit : null);
const traversalLimits = (run: InvestigationRun) => ({
max_entries: run.options.max_entries,
max_total_bytes: run.options.max_total_bytes,
max_entry_bytes: run.options.max_entry_bytes,
max_compression_ratio: run.options.max_compression_ratio,
max_depth: run.options.max_depth,
max_path_bytes: run.options.max_path_bytes,
});
const targetMatches = (
inventory: ReturnType<typeof parseArtifactInventoryEvidence>["inventory"],
target: InvestigationRunTarget,
): boolean =>
inventory.manifest.root_sha256 === target.root_sha256 &&
inventory.manifest.graph_sha256 === target.graph_sha256 &&
inventory.manifest.manifest_id === target.manifest_id &&
inventory.manifest.root_format === target.format;
const runLimitations = (
left: ValidatedInventory,
right: ValidatedInventory,
): string[] =>
[
...new Set([
...left.result.inventory.limitations.map((item) => `Left: ${item}`),
...right.result.inventory.limitations.map((item) => `Right: ${item}`),
AUTOMATIC_RUN_LIMITATION,
]),
].sort((first, second) => first.localeCompare(second));
const evidenceFor = (
workspace: InvestigationWorkspace,
evidenceId: string | null,
): Evidence | undefined =>
evidenceId === null
? undefined
: workspace.bundle.records.find(
({ evidence_id: current }) => current === evidenceId,
);
const replayConflict = (): Result<never, InvestigationWorkspaceError> =>
err(new InvestigationWorkspaceError("read", "revision-conflict"));
const replayIntegrity = (
message: string,
cause?: unknown,
): Result<never, EvidenceIntegrityError> =>
err(
new EvidenceIntegrityError(
message,
cause === undefined ? undefined : { cause },
),
);

View File

@ -39,7 +39,6 @@ const grantSchema = z.object({
});
const storeSchema = z.object({
schema_version: z.literal(1),
project_id: z.string().regex(/^project_[a-f0-9]{64}$/u),
project_root: z.string(),
grants: z.array(grantSchema).max(1_000),
@ -133,7 +132,6 @@ export const writeProjectPermissionStore = async (
const project = await identifyPermissionProject(projectRoot);
if (!project.ok) return project;
const candidate = storeSchema.safeParse({
schema_version: 1,
project_id: project.value.id,
project_root: project.value.root,
grants,

View File

@ -1,4 +1,4 @@
import { InvestigationWorkspaceError } from "../domain/errors.js";
import { WorkspaceStorageError } from "../domain/errors.js";
import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js";
import {
parseReconstructionCoverageWorkspace,
@ -10,29 +10,27 @@ import {
readRevisionedWorkspace,
writeRevisionedWorkspace,
type RevisionedWorkspaceCodec,
} from "./InvestigationWorkspaceStore.js";
} from "./RevisionedWorkspaceStore.js";
const codec: RevisionedWorkspaceCodec<ReconstructionCoverageWorkspace> = {
parse: parseReconstructionCoverageWorkspace,
serialize: serializeReconstructionCoverageWorkspace,
validateNext: (current, next, expectedRevision) => {
if ((current?.revision ?? null) !== expectedRevision)
return err(
new InvestigationWorkspaceError("update", "revision-conflict"),
);
return err(new WorkspaceStorageError("update", "revision-conflict"));
if (current === null)
return next.revision === 1 && next.previous_revision_sha256 === null
? ok(null)
: err(new InvestigationWorkspaceError("update", "revision-conflict"));
: err(new WorkspaceStorageError("update", "revision-conflict"));
if (
current.name !== next.name ||
current.workspace_id !== next.workspace_id
)
return err(new InvestigationWorkspaceError("update", "name-conflict"));
return err(new WorkspaceStorageError("update", "name-conflict"));
return next.revision === current.revision + 1 &&
next.previous_revision_sha256 === current.revision_sha256
? ok(null)
: err(new InvestigationWorkspaceError("update", "revision-conflict"));
: err(new WorkspaceStorageError("update", "revision-conflict"));
},
};
@ -41,7 +39,7 @@ export const readReconstructionCoverageWorkspace = (
path: string,
policy: EvidenceFilePolicy,
): Promise<
Result<ReconstructionCoverageWorkspace | null, InvestigationWorkspaceError>
Result<ReconstructionCoverageWorkspace | null, WorkspaceStorageError>
> => readRevisionedWorkspace(path, policy, codec);
/** Atomically append one reconstruction coverage workspace CAS revision. */
@ -53,7 +51,7 @@ export const writeReconstructionCoverageWorkspace = (
): Promise<
Result<
{ readonly path: string; readonly bytes: number },
InvestigationWorkspaceError
WorkspaceStorageError
>
> =>
writeRevisionedWorkspace(path, policy, {

View File

@ -11,16 +11,11 @@ import writeFileAtomic from "write-file-atomic";
import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js";
import { isJsonWithinLimits } from "../domain/jsonLimits.js";
import { InvestigationWorkspaceError } from "../domain/errors.js";
import {
parseInvestigationWorkspace,
serializeInvestigationWorkspace,
type InvestigationWorkspace,
} from "../domain/investigationWorkspace.js";
import { WorkspaceStorageError } from "../domain/errors.js";
import { err, ok, type Result } from "../domain/result.js";
import { canonicalizeConfiguredRoots } from "./ConfiguredRoots.js";
type WorkspaceResult<Value> = Result<Value, InvestigationWorkspaceError>;
type WorkspaceResult<Value> = Result<Value, WorkspaceStorageError>;
/** Parser, serializer, and CAS transition owned by one revisioned workspace. */
export interface RevisionedWorkspaceCodec<Document> {
@ -33,20 +28,6 @@ export interface RevisionedWorkspaceCodec<Document> {
) => WorkspaceResult<null>;
}
const investigationWorkspaceCodec: RevisionedWorkspaceCodec<InvestigationWorkspace> =
{
parse: parseInvestigationWorkspace,
serialize: serializeInvestigationWorkspace,
validateNext: validateNextRevision,
};
/** Read a validated workspace, returning null only when it does not exist. */
export const readInvestigationWorkspace = async (
path: string,
policy: EvidenceFilePolicy,
): Promise<WorkspaceResult<InvestigationWorkspace | null>> =>
readRevisionedWorkspace(path, policy, investigationWorkspaceCodec);
/** Read one root-confined, owner-only revisioned workspace document. */
export const readRevisionedWorkspace = async <Document>(
path: string,
@ -54,14 +35,14 @@ export const readRevisionedWorkspace = async <Document>(
codec: RevisionedWorkspaceCodec<Document>,
): Promise<WorkspaceResult<Document | null>> => {
if (policy.roots.length === 0)
return err(new InvestigationWorkspaceError("read", "disabled"));
return err(new WorkspaceStorageError("read", "disabled"));
try {
const destination = await resolveDestination(path, policy.roots);
if (destination === null)
return err(new InvestigationWorkspaceError("read", "outside-root"));
return err(new WorkspaceStorageError("read", "outside-root"));
return await readWorkspaceFile(destination, policy, codec);
} catch (cause: unknown) {
return err(new InvestigationWorkspaceError("read", "io", { cause }));
return err(new WorkspaceStorageError("read", "io", { cause }));
}
};
@ -71,21 +52,6 @@ export interface RevisionedWorkspaceWriteInput<Document> {
readonly codec: RevisionedWorkspaceCodec<Document>;
}
/** Atomically append one CAS-linked workspace revision under an exclusive lock. */
export const writeInvestigationWorkspace = async (
workspace: InvestigationWorkspace,
path: string,
expectedRevision: number | null,
policy: EvidenceFilePolicy,
): Promise<
WorkspaceResult<{ readonly path: string; readonly bytes: number }>
> =>
writeRevisionedWorkspace(path, policy, {
document: workspace,
expectedRevision,
codec: investigationWorkspaceCodec,
});
/** Atomically append one validated CAS-linked revision under an exclusive lock. */
export const writeRevisionedWorkspace = async <Document>(
path: string,
@ -96,23 +62,21 @@ export const writeRevisionedWorkspace = async <Document>(
> => {
const { document, expectedRevision, codec } = input;
if (policy.roots.length === 0)
return err(new InvestigationWorkspaceError("update", "disabled"));
return err(new WorkspaceStorageError("update", "disabled"));
let encoded: string;
try {
encoded = codec.serialize(document);
} catch (cause: unknown) {
return err(
new InvestigationWorkspaceError("update", "integrity", { cause }),
);
return err(new WorkspaceStorageError("update", "integrity", { cause }));
}
const bytes = Buffer.byteLength(encoded, "utf8");
if (bytes > policy.maxBytes)
return err(new InvestigationWorkspaceError("update", "too-large"));
return err(new WorkspaceStorageError("update", "too-large"));
let lock: StoreFileLock | undefined;
try {
const destination = await resolveDestination(path, policy.roots);
if (destination === null)
return err(new InvestigationWorkspaceError("update", "outside-root"));
return err(new WorkspaceStorageError("update", "outside-root"));
const acquired = await acquireLock(destination);
if (!acquired.ok) return acquired;
lock = acquired.value;
@ -131,7 +95,7 @@ export const writeRevisionedWorkspace = async <Document>(
});
return ok({ path: resolve(path), bytes });
} catch (cause: unknown) {
return err(new InvestigationWorkspaceError("update", "io", { cause }));
return err(new WorkspaceStorageError("update", "io", { cause }));
} finally {
if (lock !== undefined) await lock.release();
}
@ -163,26 +127,24 @@ const readWorkspaceFile = async <Document>(
});
if (stats === undefined) return ok(null);
if (!stats.isFile() || stats.isSymbolicLink())
return err(new InvestigationWorkspaceError("read", "not-file"));
return err(new WorkspaceStorageError("read", "not-file"));
if (stats.size > policy.maxBytes)
return err(new InvestigationWorkspaceError("read", "too-large"));
return err(new WorkspaceStorageError("read", "too-large"));
const encoded = await readFile(destination);
if (encoded.byteLength > policy.maxBytes)
return err(new InvestigationWorkspaceError("read", "too-large"));
return err(new WorkspaceStorageError("read", "too-large"));
let decoded: unknown;
try {
decoded = JSON.parse(encoded.toString("utf8"));
} catch (cause: unknown) {
return err(
new InvestigationWorkspaceError("read", "invalid-json", { cause }),
);
return err(new WorkspaceStorageError("read", "invalid-json", { cause }));
}
if (!isJsonWithinLimits(decoded, policy))
return err(new InvestigationWorkspaceError("read", "too-large"));
return err(new WorkspaceStorageError("read", "too-large"));
try {
return ok(codec.parse(decoded));
} catch (cause: unknown) {
return err(new InvestigationWorkspaceError("read", "integrity", { cause }));
return err(new WorkspaceStorageError("read", "integrity", { cause }));
}
};
@ -198,7 +160,7 @@ const acquireLock = async (
return ok(await createStoreFileLock(path));
} catch (retryCause: unknown) {
return err(
new InvestigationWorkspaceError(
new WorkspaceStorageError(
"update",
isAlreadyExists(retryCause) ? "locked" : "io",
{ cause: retryCause },
@ -207,7 +169,7 @@ const acquireLock = async (
}
}
return err(
new InvestigationWorkspaceError(
new WorkspaceStorageError(
"update",
isAlreadyExists(cause) ? "locked" : "io",
{ cause },
@ -216,26 +178,6 @@ const acquireLock = async (
}
};
function validateNextRevision(
current: InvestigationWorkspace | null,
next: InvestigationWorkspace,
expectedRevision: number | null,
): WorkspaceResult<null> {
if ((current?.revision ?? null) !== expectedRevision)
return err(new InvestigationWorkspaceError("update", "revision-conflict"));
if (current === null) {
return next.revision === 1 && next.previous_revision_digest === null
? ok(null)
: err(new InvestigationWorkspaceError("update", "revision-conflict"));
}
if (current.name !== next.name || current.workspace_id !== next.workspace_id)
return err(new InvestigationWorkspaceError("update", "name-conflict"));
return next.revision === current.revision + 1 &&
next.previous_revision_digest === current.revision_digest
? ok(null)
: err(new InvestigationWorkspaceError("update", "revision-conflict"));
}
const isFileNotFound = (cause: unknown): boolean =>
errorCode(cause) === "ENOENT";

View File

@ -8,7 +8,6 @@ import { registerCoreAnalysisCommands } from "./cli/coreAnalysisCommands.js";
import { registerUtilityCommands } from "./cli/utilityCommands.js";
import { registerArtifactCommands } from "./cli/artifactCommands.js";
import { registerManagedCommands } from "./cli/managedCommands.js";
import { registerInvestigationCommands } from "./cliInvestigationCommands.js";
import { registerEvidenceCommands } from "./cliEvidenceCommands.js";
import { registerProcessCommands } from "./cliProcessCommands.js";
import { registerPolicyCommands } from "./cliPolicyCommands.js";
@ -56,7 +55,6 @@ export const createCli = (): CliInstance => {
registerUtilityCommands(cli, logger);
registerArtifactCommands(cli, logger);
registerManagedCommands(cli, logger);
registerInvestigationCommands(cli, logger);
registerEvidenceCommands(cli, logger);
registerProcessCommands(cli, logger);
registerPolicyCommands(cli, logger);

View File

@ -252,8 +252,8 @@ const registerCoverageCommands = (
authority,
{
path: parsed.data.workspace_path,
readCapability: "investigation_workspace_read",
writeCapability: "investigation_workspace_write",
readCapability: "reconstruction_coverage_read",
writeCapability: "reconstruction_coverage_write",
operation: CLI_COMMANDS.commitReconstructionCoverage,
},
);
@ -282,7 +282,7 @@ const registerCoverageCommands = (
),
};
const authorized = await authorizeRootPermission(authority, {
capability: "investigation_workspace_read",
capability: "reconstruction_coverage_read",
roots: [parsed.data.workspace_path],
access: "read",
operation: CLI_COMMANDS.queryReconstructionCoverage,

View File

@ -35,7 +35,6 @@ export const CLI_COMMANDS = Object.freeze({
evidenceImport: "evidence-import",
evidenceExport: "evidence-export",
compare: "compare",
investigateVersions: "investigate-versions",
captureProcess: "capture-process",
compareProcessCaptures: "compare-process-captures",
policy: "policy",

View File

@ -78,22 +78,7 @@ export const registerEvidenceCommands = (
left: z.string().describe("Left Evidence bundle JSON path"),
right: z.string().describe("Right Evidence bundle JSON path"),
}),
options: z.object({
offset: z
.number()
.int()
.min(0)
.default(0)
.describe("Zero-based changed-record offset"),
limit: z
.number()
.int()
.min(1)
.max(500)
.default(100)
.describe("Maximum changed records to return"),
}),
run: ({ args, options }) =>
run: ({ args }) =>
logCliCommand(logger, "compare", async () => {
const config = parseConfig(process.env);
if (!config.ok) return cliError(config.error);
@ -105,8 +90,6 @@ export const registerEvidenceCommands = (
const compared = await compareEvidenceBundlesCommand({
leftPath: args.left,
rightPath: args.right,
offset: options.offset,
limit: options.limit,
policy: config.value.evidenceFilePolicy,
});
return compared.ok ? compared.value : cliError(compared.error);

View File

@ -1,190 +0,0 @@
import { Cli, z } from "incur";
import { runCrossVersionInvestigation } from "./application/CrossVersionInvestigation.js";
import { logCliCommand } from "./cliLogging.js";
import { parseConfig } from "./config.js";
import { projectAnalysisError, type AnalysisError } from "./domain/errors.js";
import type { Logger } from "./logger.js";
import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js";
import { authorizeFileReadWithDeferredWrite } from "./application/DeferredFileAuthorization.js";
import { CLI_COMMANDS } from "./cliCommandNames.js";
const investigationOptionsSchema = z.object({
yes: z
.boolean()
.default(false)
.describe("Approve persistent workspace writes"),
workspaceName: z
.string()
.trim()
.min(1)
.max(200)
.default("default")
.describe("Logical name recorded in the investigation workspace"),
expectedRevision: z
.number()
.int()
.min(1)
.optional()
.describe("Required current workspace revision for an atomic update"),
replayRunId: z
.string()
.regex(/^run_[a-f0-9]{64}$/u)
.optional()
.describe("Replay one verified complete workspace run without rescanning"),
maxEntries: z
.number()
.int()
.min(1)
.max(50_000)
.default(10_000)
.describe("Maximum artifact entries to inspect per version"),
maxTotalBytes: z
.number()
.int()
.min(1)
.max(Number.MAX_SAFE_INTEGER)
.default(1_073_741_824)
.describe("Maximum total uncompressed artifact content to inspect"),
maxEntryBytes: z
.number()
.int()
.min(1)
.max(Number.MAX_SAFE_INTEGER)
.default(268_435_456)
.describe("Maximum uncompressed content to inspect for one entry"),
pageSize: z
.number()
.int()
.min(1)
.max(500)
.default(500)
.describe("Maximum graph items loaded per investigation page"),
changeLimit: z
.number()
.int()
.min(1)
.max(500)
.default(500)
.describe("Maximum version changes to record"),
integrityPolicy: z
.enum(["fail", "record-and-continue"])
.default("fail")
.describe("Behavior when declared artifact integrity does not match"),
integrityContinueApproved: z
.boolean()
.default(false)
.describe("Approve continuing after recorded integrity mismatches"),
maxIntegrityMismatches: z
.number()
.int()
.min(1)
.max(100)
.default(10)
.describe("Maximum integrity mismatches to record before stopping"),
});
/** Register the persistent cross-version CLI workflow. */
export const registerInvestigationCommands = (
cli: ReturnType<typeof Cli.create>,
logger: Logger,
): void => {
cli.command(CLI_COMMANDS.investigateVersions, {
description:
"Run or resume a persistent cross-version artifact investigation",
args: z.object({
leftPath: z.string().describe("Earlier artifact or directory path"),
rightPath: z.string().describe("Later artifact or directory path"),
workspacePath: z.string().describe("Persistent workspace JSON path"),
}),
options: investigationOptionsSchema,
alias: {
workspaceName: "workspace-name",
expectedRevision: "expected-revision",
replayRunId: "replay-run-id",
maxEntries: "max-entries",
maxTotalBytes: "max-total-bytes",
maxEntryBytes: "max-entry-bytes",
pageSize: "page-size",
changeLimit: "change-limit",
integrityPolicy: "integrity-policy",
integrityContinueApproved: "integrity-continue-approved",
maxIntegrityMismatches: "max-integrity-mismatches",
yes: "y",
},
run: ({ args, options }) =>
logCliCommand(logger, "investigate-versions", () =>
runInvestigation(args, options),
),
});
};
const runInvestigation = async (
args: {
readonly leftPath: string;
readonly rightPath: string;
readonly workspacePath: string;
},
options: z.infer<typeof investigationOptionsSchema>,
) => {
if (!options.yes)
return {
error: "ApprovalRequired",
message: "Persistent workspace writes require explicit --yes approval.",
};
const config = parseConfig(process.env);
if (!config.ok) return cliError(config.error);
const authority = await loadConfiguredPermissionAuthority(config.value);
if (!authority.ok) return cliError(authority.error);
const workspaceAuthorization = await authorizeFileReadWithDeferredWrite(
authority.value,
{
path: args.workspacePath,
readCapability: "investigation_workspace_read",
writeCapability: "investigation_workspace_write",
operation: "investigate_versions",
},
);
if (!workspaceAuthorization.ok) return cliError(workspaceAuthorization.error);
const investigated = await runCrossVersionInvestigation(
{
approved: true,
workspace_path: args.workspacePath,
workspace_name: options.workspaceName,
...(options.expectedRevision === undefined
? {}
: { expected_workspace_revision: options.expectedRevision }),
...(options.replayRunId === undefined
? {}
: { replay_run_id: options.replayRunId }),
left_path: args.leftPath,
right_path: args.rightPath,
integrity_policy: options.integrityPolicy,
integrity_continue_approved: options.integrityContinueApproved,
max_integrity_mismatches: options.maxIntegrityMismatches,
options: {
max_entries: options.maxEntries,
max_total_bytes: options.maxTotalBytes,
max_entry_bytes: options.maxEntryBytes,
max_compression_ratio: 1_000,
max_depth: 20,
max_path_bytes: 4_096,
page_size: options.pageSize,
change_limit: options.changeLimit,
},
},
config.value.evidenceFilePolicy,
{
integrityContinueEnabled: config.value.artifactIntegrityContinueEnabled,
authorizeWorkspaceWrite: workspaceAuthorization.value.authorizeWrite,
},
);
return investigated.ok
? investigated.value.evidence
: cliError(investigated.error);
};
const cliError = (error: AnalysisError) => ({
error: "Analysis failed",
...projectAnalysisError(error),
});

View File

@ -113,7 +113,6 @@ export const INVESTIGATION_EXAMPLES = {
},
verify_reconstruction: {
specification: {
schema_version: 1,
name: "Terminal compatibility",
claims: [
{

View File

@ -407,22 +407,22 @@ export const SESSION_TOOL_CONTRACTS = [
),
session(
"compare_artifacts",
"Compare two bounded sets of inventory_artifact Evidence pages by logical occurrence path, content identity, metadata, and graph relations. Pages must share and satisfy their graph commitment; every delta cites both sets, and gaps yield truncated or unknown, never equivalence.",
"Compare complete inventory_artifact Evidence sets by logical occurrence path, content identity, metadata, and graph relations. Every delta cites both sets, and gaps yield truncated or unknown, never equivalence. Returns every change inline.",
artifactComparisonInputSchema,
),
session(
"compare_functions",
"Compare two explicit bounded sets of analyze_function Evidence pages across identity, exact provider text, calls, references, strings, and address-normalized CFG topology. Missing or provider-incompatible facets remain truncated or unknown; every conclusion cites both Evidence sets.",
"Compare two explicit sets of analyze_function Evidence across identity, exact provider text, calls, references, strings, and address-normalized CFG topology. Missing or provider-incompatible facets remain truncated or unknown; every conclusion cites both Evidence sets.",
functionComparisonInputSchema,
),
session(
"compare_bundles",
"Compare two canonical Evidence v2 bundles by exact record membership, explicit one-to-one observation pairs, and complete residual-unknown revision histories. Missing bundle members describe omission only, never behavioral equivalence; output is digest-anchored and deterministically paginated.",
"Compare two canonical Evidence bundles by exact record membership, explicit one-to-one observation pairs, and complete residual-unknown revision histories. Missing bundle members describe omission only, never behavioral equivalence; output is digest-anchored and returns every change inline.",
bundleComparisonInputSchema,
),
session(
"find_changed_behavior",
"Aggregate validated comparison Evidence, or automatically run and resume a persistent cross-version artifact investigation beneath an approved evidence root. Runtime observations remain distinct from static behavior candidates; missing or incomplete comparisons produce unresolved findings, never causal claims.",
"Aggregate validated process and artifact comparison Evidence. Runtime observations remain distinct from static behavior candidates; missing or incomplete comparisons produce unresolved findings, never causal claims. Returns every finding inline.",
changedBehaviorInputSchema,
),
session(

View File

@ -40,7 +40,7 @@ const ANALYSIS_ERROR_TAGS = [
"EvidenceIntegrityError",
"EvidenceLimitError",
"EvidenceFileError",
"InvestigationWorkspaceError",
"WorkspaceStorageError",
"UnknownRegistryError",
"HopperTimeoutError",
"HopperCancelledError",
@ -351,9 +351,9 @@ export class EvidenceFileError extends AnalysisError {
}
}
/** Persistent investigation workspace access or CAS validation failed. */
export class InvestigationWorkspaceError extends AnalysisError {
readonly _tag = "InvestigationWorkspaceError";
/** A persisted reconstruction coverage workspace could not be read or updated. */
export class WorkspaceStorageError extends AnalysisError {
readonly _tag = "WorkspaceStorageError";
constructor(
readonly operation: "read" | "update",
@ -370,7 +370,7 @@ export class InvestigationWorkspaceError extends AnalysisError {
| "io",
options?: ErrorOptions,
) {
super(`Investigation workspace ${operation} failed: ${reason}`, options);
super(`Workspace storage ${operation} failed: ${reason}`, options);
}
}

View File

@ -1,484 +0,0 @@
import { createHash } from "node:crypto";
import canonicalize from "canonicalize";
import { z } from "zod";
import {
evidenceBundleSchema,
parseEvidenceBundle,
type EvidenceBundle,
} from "./evidenceBundle.js";
import type { JsonValue } from "./jsonValue.js";
const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u);
const evidenceIdSchema = z.string().regex(/^ev_[a-f0-9]{64}$/u);
const artifactFormatSchema =
evidenceBundleSchema.shape.artifacts.element.shape.format;
/** Bounded controls for one automatic, provider-neutral artifact comparison. */
const investigationRunOptionsSchema = z.object({
max_entries: z.number().int().min(1).max(50_000).default(10_000),
max_total_bytes: z
.number()
.int()
.min(1)
.max(Number.MAX_SAFE_INTEGER)
.default(1_073_741_824),
max_entry_bytes: z
.number()
.int()
.min(1)
.max(Number.MAX_SAFE_INTEGER)
.default(268_435_456),
max_compression_ratio: z.number().min(1).max(100_000).default(1_000),
max_depth: z.number().int().min(0).max(100).default(20),
max_path_bytes: z.number().int().min(1).max(65_535).default(4_096),
page_size: z.number().int().min(1).max(500).default(500),
change_limit: z.number().int().min(1).max(500).default(500),
});
const DEFAULT_INVESTIGATION_RUN_OPTIONS = {
max_entries: 10_000,
max_total_bytes: 1_073_741_824,
max_entry_bytes: 268_435_456,
max_compression_ratio: 1_000,
max_depth: 20,
max_path_bytes: 4_096,
page_size: 500,
change_limit: 500,
} as const;
export type InvestigationRunOptions = z.infer<
typeof investigationRunOptionsSchema
>;
/** Explicit write approval and two local versions for an automatic run. */
const crossVersionInvestigationFacts = {
approved: z.literal(true),
workspace_path: z.string().min(1).max(4_096),
workspace_name: z.string().trim().min(1).max(200).default("default"),
expected_workspace_revision: z.number().int().min(1).optional(),
replay_run_id: z
.string()
.regex(/^run_[a-f0-9]{64}$/u)
.optional(),
left_path: z.string().min(1).max(4_096),
right_path: z.string().min(1).max(4_096),
options: investigationRunOptionsSchema.default(
DEFAULT_INVESTIGATION_RUN_OPTIONS,
),
} as const;
export const crossVersionInvestigationInputSchema = z.union([
z.object({
...crossVersionInvestigationFacts,
integrity_policy: z.literal("fail").default("fail"),
integrity_continue_approved: z.literal(false).default(false),
max_integrity_mismatches: z.number().int().min(1).max(100).default(10),
}),
z.object({
...crossVersionInvestigationFacts,
integrity_policy: z.literal("record-and-continue"),
integrity_continue_approved: z.literal(true),
max_integrity_mismatches: z.number().int().min(1).max(100).default(10),
}),
]);
export type CrossVersionInvestigationInput = z.infer<
typeof crossVersionInvestigationInputSchema
>;
const targetSchema = z.object({
root_sha256: digestSchema,
graph_sha256: digestSchema,
manifest_id: z.string().regex(/^agm_[a-f0-9]{64}$/u),
format: artifactFormatSchema,
});
export type InvestigationRunTarget = z.infer<typeof targetSchema>;
const investigationRunIdentityShape = {
schema_version: z.literal(1),
run_id: z.string().regex(/^run_[a-f0-9]{64}$/u),
request_sha256: digestSchema,
left: targetSchema,
right: targetSchema,
options: investigationRunOptionsSchema,
left_inventory_evidence_ids: z.array(evidenceIdSchema).min(1).max(100),
right_inventory_evidence_ids: z.array(evidenceIdSchema).min(1).max(100),
limitations: z.array(z.string().max(4_096)).max(100),
};
const investigationRunContextShape = {
...investigationRunIdentityShape,
integrity_policy: z.enum(["fail", "record-and-continue"]),
integrity_continue_approved: z.boolean(),
max_integrity_mismatches: z.number().int().min(1).max(100),
};
const inventoryRunStateShape = {
status: z.literal("running"),
completed_stages: z.tuple([
z.literal("inventory_left"),
z.literal("inventory_right"),
]),
comparison_evidence_id: z.null(),
result_evidence_id: z.null(),
};
const comparisonRunStateShape = {
status: z.literal("running"),
completed_stages: z.tuple([
z.literal("inventory_left"),
z.literal("inventory_right"),
z.literal("compare_artifacts"),
]),
comparison_evidence_id: evidenceIdSchema,
result_evidence_id: z.null(),
};
const completedRunStateShape = {
status: z.literal("complete"),
completed_stages: z.tuple([
z.literal("inventory_left"),
z.literal("inventory_right"),
z.literal("compare_artifacts"),
z.literal("find_changed_behavior"),
]),
comparison_evidence_id: evidenceIdSchema,
result_evidence_id: evidenceIdSchema,
};
const investigationRunStateSchemas = [
z.object({ ...investigationRunContextShape, ...inventoryRunStateShape }),
z.object({ ...investigationRunContextShape, ...comparisonRunStateShape }),
z.object({ ...investigationRunContextShape, ...completedRunStateShape }),
] as const;
const legacyIdentityRunStateSchemas = [
z.object({
...investigationRunContextShape,
...inventoryRunStateShape,
legacy_request_identity: z.literal(true),
}),
z.object({
...investigationRunContextShape,
...comparisonRunStateShape,
legacy_request_identity: z.literal(true),
}),
z.object({
...investigationRunContextShape,
...completedRunStateShape,
legacy_request_identity: z.literal(true),
}),
] as const;
export const investigationRunSchema = z
.union([
z.union(legacyIdentityRunStateSchemas),
z.union(investigationRunStateSchemas),
])
.superRefine((run, context) => {
if (!("legacy_request_identity" in run)) return;
const legacyIdentity = createLegacyInvestigationRunIdentity(run);
if (
run.integrity_policy !== "fail" ||
run.integrity_continue_approved ||
run.max_integrity_mismatches !== 10 ||
run.run_id !== legacyIdentity.runId ||
run.request_sha256 !== legacyIdentity.requestSha256
)
context.addIssue({
code: "custom",
path: ["legacy_request_identity"],
message:
"Legacy request identity is valid only for migrated strict runs",
});
});
export type InvestigationRun = z.infer<typeof investigationRunSchema>;
export const investigationRunSummarySchema = z.object({
schema_version: z.literal(1),
workspace_id: z.string().regex(/^ws_[a-f0-9]{64}$/u),
run_id: z.string().regex(/^run_[a-f0-9]{64}$/u),
left_manifest_id: z.string().regex(/^agm_[a-f0-9]{64}$/u),
right_manifest_id: z.string().regex(/^agm_[a-f0-9]{64}$/u),
inventory_evidence_count: z.number().int().min(2).max(200),
comparison_evidence_id: evidenceIdSchema,
limitations: z.array(z.string().max(4_096)).max(100),
});
/** Durable schema for one revisioned cross-version investigation workspace. */
export const investigationWorkspaceSchema = z.object({
workspace_version: z.literal(1),
workspace_id: z.string().regex(/^ws_[a-f0-9]{64}$/u),
name: z.string().trim().min(1).max(200),
revision: z.number().int().min(1),
previous_revision_digest: z
.string()
.regex(/^wrev_[a-f0-9]{64}$/u)
.nullable(),
revision_digest: z.string().regex(/^wrev_[a-f0-9]{64}$/u),
bundle: evidenceBundleSchema,
runs: z.array(investigationRunSchema).max(1_000),
});
const legacyInvestigationRunSchema = z.union([
z.strictObject({
...investigationRunIdentityShape,
...inventoryRunStateShape,
}),
z.strictObject({
...investigationRunIdentityShape,
...comparisonRunStateShape,
}),
z.strictObject({
...investigationRunIdentityShape,
...completedRunStateShape,
}),
]);
const legacyInvestigationWorkspaceSchema = investigationWorkspaceSchema
.extend({
runs: z.array(legacyInvestigationRunSchema).max(1_000),
})
.strict();
export type InvestigationWorkspace = z.infer<
typeof investigationWorkspaceSchema
>;
/** Derive a stable run identity from content commitments and bounded controls. */
export const createInvestigationRunIdentity = (input: {
readonly left: InvestigationRunTarget;
readonly right: InvestigationRunTarget;
readonly options: InvestigationRunOptions;
readonly integrity_policy: CrossVersionInvestigationInput["integrity_policy"];
readonly integrity_continue_approved: boolean;
readonly max_integrity_mismatches: number;
}): { readonly runId: string; readonly requestSha256: string } => {
const requestSha256 = digestCanonical({
schema: "rea.cross-version-investigation-request/v1",
left: input.left,
right: input.right,
options: input.options,
integrity: {
policy: input.integrity_policy,
approved: input.integrity_continue_approved,
max_mismatches: input.max_integrity_mismatches,
},
});
return { runId: `run_${requestSha256}`, requestSha256 };
};
/** Build the first canonical workspace revision. */
export const createInvestigationWorkspace = (
name: string,
bundle: EvidenceBundle,
runs: readonly InvestigationRun[],
): InvestigationWorkspace =>
buildWorkspace({
workspaceId: `ws_${digestCanonical({ schema: "rea.workspace/v1", name })}`,
name,
revision: 1,
previousRevisionDigest: null,
bundle,
runs,
});
/** Create the next immutable workspace revision. */
export const reviseInvestigationWorkspace = (
current: InvestigationWorkspace,
bundle: EvidenceBundle,
runs: readonly InvestigationRun[],
): InvestigationWorkspace =>
buildWorkspace({
workspaceId: current.workspace_id,
name: current.name,
revision: current.revision + 1,
previousRevisionDigest: current.revision_digest,
bundle,
runs,
});
/** Parse a workspace and verify identities, revision digest, and references. */
export const parseInvestigationWorkspace = (
input: unknown,
): InvestigationWorkspace => {
if (isLegacyWorkspace(input)) return migrateLegacyWorkspace(input);
const parsed = investigationWorkspaceSchema.parse(input);
const bundle = parseEvidenceBundle(parsed.bundle);
const canonical = buildWorkspace({
workspaceId: parsed.workspace_id,
name: parsed.name,
revision: parsed.revision,
previousRevisionDigest: parsed.previous_revision_digest,
bundle,
runs: parsed.runs,
});
if (parsed.workspace_id !== workspaceIdFor(parsed.name))
throw new TypeError("Investigation workspace ID does not match its name");
if (parsed.revision_digest !== canonical.revision_digest)
throw new TypeError("Investigation workspace revision digest is invalid");
if (JSON.stringify(parsed) !== JSON.stringify(canonical))
throw new TypeError("Investigation workspace is not canonical");
validateRunReferences(canonical);
return canonical;
};
/** Encode a validated workspace as byte-stable canonical JSON. */
export const serializeInvestigationWorkspace = (
workspace: InvestigationWorkspace,
): string => canonicalJson(parseInvestigationWorkspace(workspace));
const buildWorkspace = (input: {
readonly workspaceId: string;
readonly name: string;
readonly revision: number;
readonly previousRevisionDigest: string | null;
readonly bundle: EvidenceBundle;
readonly runs: readonly InvestigationRun[];
}): InvestigationWorkspace => {
const runs = [...input.runs]
.map((run) => investigationRunSchema.parse(run))
.sort((left, right) => left.run_id.localeCompare(right.run_id));
assertUnique(
runs.map(({ run_id: id }) => id),
"investigation run ID",
);
const semantic = {
workspace_version: 1,
workspace_id: input.workspaceId,
name: input.name,
revision: input.revision,
previous_revision_digest: input.previousRevisionDigest,
bundle: parseEvidenceBundle(input.bundle),
runs,
} satisfies JsonValue;
return investigationWorkspaceSchema.parse({
...semantic,
revision_digest: `wrev_${digestCanonical(semantic)}`,
});
};
const validateRunReferences = (workspace: InvestigationWorkspace): void => {
const evidenceIds = new Set(
workspace.bundle.records.map(({ evidence_id: id }) => id),
);
for (const run of workspace.runs) {
const expectedIdentity = createInvestigationRunIdentity(run);
const legacyIdentity = createLegacyInvestigationRunIdentity(run);
if (
(run.run_id !== expectedIdentity.runId ||
run.request_sha256 !== expectedIdentity.requestSha256) &&
(!("legacy_request_identity" in run) ||
run.integrity_policy !== "fail" ||
run.integrity_continue_approved ||
run.max_integrity_mismatches !== 10 ||
run.run_id !== legacyIdentity.runId ||
run.request_sha256 !== legacyIdentity.requestSha256)
)
throw new TypeError("Investigation run identity is invalid");
const references = [
...run.left_inventory_evidence_ids,
...run.right_inventory_evidence_ids,
...(run.comparison_evidence_id === null
? []
: [run.comparison_evidence_id]),
...(run.result_evidence_id === null ? [] : [run.result_evidence_id]),
];
if (references.some((evidenceId) => !evidenceIds.has(evidenceId)))
throw new TypeError("Investigation run references missing Evidence");
}
};
/** Derive the pre-integrity-policy run identity for strict workspace migration. */
export const createLegacyInvestigationRunIdentity = (input: {
readonly left: InvestigationRunTarget;
readonly right: InvestigationRunTarget;
readonly options: InvestigationRunOptions;
}): { readonly runId: string; readonly requestSha256: string } => {
const requestSha256 = digestCanonical({
schema: "rea.cross-version-investigation-request/v1",
left: input.left,
right: input.right,
options: input.options,
});
return { runId: `run_${requestSha256}`, requestSha256 };
};
const isLegacyWorkspace = (input: unknown): boolean => {
if (typeof input !== "object" || input === null || !("runs" in input))
return false;
const runs = input.runs;
return (
Array.isArray(runs) &&
runs.some(
(run) =>
typeof run === "object" && run !== null && !("integrity_policy" in run),
)
);
};
const migrateLegacyWorkspace = (input: unknown): InvestigationWorkspace => {
const parsed = legacyInvestigationWorkspaceSchema.parse(input);
const bundle = parseEvidenceBundle(parsed.bundle);
const runs = [...parsed.runs].sort((left, right) =>
left.run_id.localeCompare(right.run_id),
);
const semantic = {
workspace_version: 1,
workspace_id: parsed.workspace_id,
name: parsed.name,
revision: parsed.revision,
previous_revision_digest: parsed.previous_revision_digest,
bundle,
runs,
} satisfies JsonValue;
if (parsed.workspace_id !== workspaceIdFor(parsed.name))
throw new TypeError("Investigation workspace ID does not match its name");
if (parsed.revision_digest !== `wrev_${digestCanonical(semantic)}`)
throw new TypeError("Investigation workspace revision digest is invalid");
const canonical = legacyInvestigationWorkspaceSchema.parse({
...semantic,
revision_digest: parsed.revision_digest,
});
if (JSON.stringify(parsed) !== JSON.stringify(canonical))
throw new TypeError("Investigation workspace is not canonical");
for (const run of runs) {
const identity = createLegacyInvestigationRunIdentity(run);
if (
run.run_id !== identity.runId ||
run.request_sha256 !== identity.requestSha256
)
throw new TypeError("Investigation run identity is invalid");
}
const migrated = buildWorkspace({
workspaceId: parsed.workspace_id,
name: parsed.name,
revision: parsed.revision,
previousRevisionDigest: parsed.previous_revision_digest,
bundle,
runs: runs.map((run) => ({
...run,
legacy_request_identity: true as const,
integrity_policy: "fail" as const,
integrity_continue_approved: false,
max_integrity_mismatches: 10,
})),
});
validateRunReferences(migrated);
return migrated;
};
const workspaceIdFor = (name: string): string =>
`ws_${digestCanonical({ schema: "rea.workspace/v1", name })}`;
const digestCanonical = (value: JsonValue): string =>
createHash("sha256").update(canonicalJson(value)).digest("hex");
const canonicalJson = (value: JsonValue): string => {
const encoded = canonicalize(value);
if (encoded === undefined)
throw new TypeError("Investigation workspace could not canonicalize data");
return encoded;
};
const assertUnique = (values: readonly string[], description: string): void => {
if (new Set(values).size !== values.length)
throw new TypeError(`Duplicate ${description}`);
};

View File

@ -12,8 +12,8 @@ export const PERMISSION_CAPABILITIES = [
"v8_inspector_observe",
"evidence_read",
"evidence_write",
"investigation_workspace_read",
"investigation_workspace_write",
"reconstruction_coverage_read",
"reconstruction_coverage_write",
"snapshot_read",
"snapshot_write",
"artifact_extract",

File diff suppressed because one or more lines are too long

View File

@ -1,38 +1,22 @@
import type { McpServer } from "@modelcontextprotocol/server";
import type { z } from "zod";
import type { BinarySessionPort } from "../application/BinarySession.js";
import { runCrossVersionInvestigationValidated } from "../application/CrossVersionInvestigation.js";
import {
authorizeFileReadWithDeferredWrite,
type DeferredFileWriteAuthorization,
} from "../application/DeferredFileAuthorization.js";
import { SESSION_TOOL_CONTRACTS } from "../contracts/toolContracts.js";
import { buildCallPath } from "../domain/callPath.js";
import {
changedBehaviorInputSchema,
changedBehaviorResultSchema,
findChangedBehavior,
} from "../domain/changedBehavior.js";
import { findChangedBehavior } from "../domain/changedBehavior.js";
import { createEvidence } from "../domain/evidence.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { verifyReconstruction } from "../domain/reconstructionVerification.js";
import { correlateStaticAndRuntime } from "../domain/staticRuntimeCorrelation.js";
import { mcpProgressReporter } from "./mcpProgress.js";
import {
comparisonClosure,
evidenceClosure,
functionEvidenceIds,
investigationContext,
isIncomplete,
recordWorkflowEvidence,
verifyCoverageReadiness,
} from "./registerInvestigationTools/helpers.js";
import type { InvestigationToolPolicies } from "./registerInvestigationTools/types.js";
import {
runDerivedOperation,
type DerivedOperationContext,
} from "./runDerivedOperation.js";
import { runDerivedOperation } from "./runDerivedOperation.js";
import {
CALL_PATH_PROVIDER,
CHANGED_BEHAVIOR_PROVIDER,
@ -52,9 +36,8 @@ export const registerInvestigationTools = (
(typeof SESSION_TOOL_CONTRACTS)[12],
(typeof SESSION_TOOL_CONTRACTS)[13],
],
policies: InvestigationToolPolicies,
): void => {
registerChangedBehavior(server, session, contracts[0], policies);
registerChangedBehavior(server, session, contracts[0]);
registerCallPath(server, session, contracts[1]);
registerStaticRuntime(server, session, contracts[2]);
registerReconstruction(server, session, contracts[3]);
@ -64,23 +47,11 @@ const registerChangedBehavior = (
server: McpServer,
session: BinarySessionPort,
contract: (typeof SESSION_TOOL_CONTRACTS)[10],
policies: InvestigationToolPolicies,
): void => {
server.registerTool(
contract.name,
contractOptions(contract),
async (input, context) => {
const investigationRun = input.investigation_run;
if (investigationRun !== undefined)
return runAutomaticInvestigation({
server,
session,
contract,
policies,
investigationRun,
unknownRegistryApproved: input.unknown_registry_approved,
context,
});
const closure = evidenceClosure(
session,
comparisonClosure(input.comparisons),
@ -88,7 +59,7 @@ const registerChangedBehavior = (
if (!closure.ok) return toCallToolResult(closure, contract);
const links = closure.value;
const computed = await runDerivedOperation(context, contract.name, () =>
findChangedBehavior(input.comparisons, input.offset, input.limit),
findChangedBehavior(input.comparisons),
);
if (!computed.ok) return toCallToolResult(computed, contract);
const result = computed.value;
@ -99,8 +70,6 @@ const registerChangedBehavior = (
comparison_evidence_ids: input.comparisons.map(
({ evidence_id: id }) => id,
),
offset: input.offset,
limit: input.limit,
},
result: jsonValueSchema.parse(result),
confidence: "derived",
@ -133,81 +102,6 @@ const registerChangedBehavior = (
);
};
const runAutomaticInvestigation = async (input: {
readonly server: McpServer;
readonly session: BinarySessionPort;
readonly contract: (typeof SESSION_TOOL_CONTRACTS)[10];
readonly policies: InvestigationToolPolicies;
readonly investigationRun: NonNullable<
z.output<typeof changedBehaviorInputSchema>["investigation_run"]
>;
readonly unknownRegistryApproved: true | undefined;
readonly context: DerivedOperationContext;
}) => {
const { policies, investigationRun, contract, session } = input;
const progress = mcpProgressReporter(input.context);
let workspaceAuthorization: DeferredFileWriteAuthorization | undefined;
if (policies.permissionAuthority !== undefined) {
const authority = policies.permissionAuthority;
const authorizedWorkspace = await authorizeFileReadWithDeferredWrite(
authority,
{
path: investigationRun.workspace_path,
readCapability: "investigation_workspace_read",
writeCapability: "investigation_workspace_write",
operation: contract.name,
},
);
if (!authorizedWorkspace.ok)
return toCallToolResult(authorizedWorkspace, contract);
workspaceAuthorization = authorizedWorkspace.value;
}
const investigated = await runCrossVersionInvestigationValidated(
investigationRun,
policies.evidenceFiles,
{
...investigationContext({
session,
signal: input.context.mcpReq.signal,
integrityContinueEnabled:
policies.integrityContinueEnabled?.() ?? false,
progress,
}),
...(workspaceAuthorization === undefined
? {}
: { authorizeWorkspaceWrite: workspaceAuthorization.authorizeWrite }),
},
);
if (!investigated.ok) return toCallToolResult(investigated, contract);
session.retainInvestigationWorkspace(investigated.value.workspace);
const result = changedBehaviorResultSchema.parse(
investigated.value.evidence.normalized_result,
);
const toolResult = toCallToolResult(
recordWorkflowEvidence(
session,
investigated.value.evidence,
input.unknownRegistryApproved,
isIncomplete(result.behavior_status),
{
question: `Automatic changed behavior run ${result.investigation_run?.run_id ?? "unknown"} remains ${result.behavior_status}`,
domain: "changed-behavior",
requiredAuthority: "controlled-replay",
requiredConfidence: "observed",
probes: [
{
operation: "capture_process_scenario",
rationale:
"Capture both versions under the same bounded scenario and environment.",
},
],
},
),
contract,
);
return toolResult;
};
const registerCallPath = (
server: McpServer,
session: BinarySessionPort,
@ -294,8 +188,6 @@ const registerStaticRuntime = (
operation: contract.name,
parameters: {
mapping_count: input.mappings.length,
offset: input.offset,
limit: input.limit,
},
result: jsonValueSchema.parse(result),
confidence: "inferred",
@ -343,12 +235,7 @@ const registerReconstruction = (
if (!coverage.ok) return toCallToolResult(coverage, contract);
const owned = session.exportEvidenceBundle();
const computed = await runDerivedOperation(context, contract.name, () =>
verifyReconstruction(
input.specification,
owned,
input.offset,
input.limit,
),
verifyReconstruction(input.specification, owned),
);
if (!computed.ok) return toCallToolResult(computed, contract);
const result = computed.value;
@ -361,8 +248,6 @@ const registerReconstruction = (
parameters: {
specification_sha256: result.specification_sha256,
claim_ids: input.specification.claims.map(({ claim_id: id }) => id),
offset: input.offset,
limit: input.limit,
},
result: jsonValueSchema.parse(result),
confidence: "derived",

View File

@ -1,5 +1,4 @@
import type { BinarySessionPort } from "../../application/BinarySession.js";
import type { ProgressReporter } from "../../application/ProgressReporter.js";
import type { Evidence } from "../../domain/evidence.js";
import {
EvidenceIntegrityError,
@ -13,29 +12,6 @@ import type { z } from "zod";
import type { reconstructionVerificationInputSchema } from "../../domain/reconstructionVerification.js";
import { AnalysisInputError } from "../../domain/errors.js";
/** Build the execution context passed to cross-version investigation. */
export const investigationContext = ({
session,
signal,
integrityContinueEnabled,
progress,
}: {
readonly session: BinarySessionPort;
readonly signal: AbortSignal;
readonly integrityContinueEnabled: boolean;
readonly progress?: ProgressReporter;
}): {
readonly session: BinarySessionPort;
readonly signal: AbortSignal;
readonly integrityContinueEnabled: boolean;
readonly progress?: ProgressReporter;
} => ({
session,
signal,
integrityContinueEnabled,
...(progress === undefined ? {} : { progress }),
});
export const isIncomplete = (status: string): boolean =>
status === "unknown" || status === "truncated";

View File

@ -1,13 +1,3 @@
import type { EvidenceFilePolicy } from "../../domain/evidenceBundle.js";
import type { PermissionAuthority } from "../../application/PermissionAuthority.js";
/** Policy inputs shared by all investigation workflow tools. */
export interface InvestigationToolPolicies {
readonly evidenceFiles: EvidenceFilePolicy;
readonly permissionAuthority?: PermissionAuthority;
readonly integrityContinueEnabled?: () => boolean;
}
/** Fields used to build an approved residual unknown from a workflow result. */
export interface WorkflowUnknownInput {
readonly question: string;

View File

@ -412,17 +412,7 @@ export const registerSessionTools = (
staticRuntimeContract,
reconstructionContract,
] as const;
registerInvestigationTools(server, session, investigationContracts, {
evidenceFiles: evidenceFilePolicy,
...(options.artifactIntegrityContinueEnabled === undefined
? {}
: {
integrityContinueEnabled: options.artifactIntegrityContinueEnabled,
}),
...(options.permissionAuthority === undefined
? {}
: { permissionAuthority: options.permissionAuthority }),
});
registerInvestigationTools(server, session, investigationContracts);
registerRecordAndReplayTools(server, session, logger);
registerContextTools(server, session);
};

View File

@ -1,313 +0,0 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { createPackageWithOptions } from "@electron/asar";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { runCrossVersionInvestigation } from "../../../src/application/CrossVersionInvestigation.js";
import {
readInvestigationWorkspace,
writeInvestigationWorkspace,
} from "../../../src/application/InvestigationWorkspaceStore.js";
import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js";
import type { EvidenceFilePolicy } from "../../../src/domain/evidenceBundle.js";
import {
createInvestigationWorkspace,
crossVersionInvestigationInputSchema,
investigationRunSchema,
} from "../../../src/domain/investigationWorkspace.js";
import { ok } from "../../../src/domain/result.js";
const policy = (root: string): EvidenceFilePolicy => ({
roots: [root],
maxBytes: 64 * 1024 * 1024,
maxDepth: 64,
maxStringLength: 1024 * 1024,
maxNodes: 1_000_000,
});
const fixture = async () => {
const directory = await createTestTempDirectory("rea-workspace-");
const left = join(directory, "left");
const right = join(directory, "right");
await Promise.all([mkdir(left), mkdir(right)]);
await Promise.all([
writeFile(join(left, "app.txt"), "version one\n"),
writeFile(join(right, "app.txt"), "version two\n"),
writeFile(join(right, "added.txt"), "new behavior candidate\n"),
]);
const path = join(directory, "workspace.json");
const input = crossVersionInvestigationInputSchema.parse({
approved: true,
workspace_path: path,
workspace_name: "release-diff",
left_path: left,
right_path: right,
options: { page_size: 2, change_limit: 100 },
});
return { directory, left, right, path, input };
};
describe("persistent cross-version investigation workspace", () => {
it("uses valid workspace roots when another configured root is missing", async () => {
const root = await createTestTempDirectory("rea-workspace-stale-root-");
const path = join(root, "workspace.json");
const workspace = createInvestigationWorkspace(
"stale-root",
createEvidenceBundle([]),
[],
);
const configured = {
...policy(root),
roots: [join(root, "missing"), root],
};
expect(
await writeInvestigationWorkspace(workspace, path, null, configured),
).toMatchObject({ ok: true });
expect(await readInvestigationWorkspace(path, configured)).toEqual({
ok: true,
value: workspace,
});
});
it("stores workspaces in dot-prefixed child directories", async () => {
const root = await createTestTempDirectory("rea-workspace-dot-child-");
const directory = join(root, "..cache");
await mkdir(directory);
const path = join(directory, "workspace.json");
const workspace = createInvestigationWorkspace(
"dot-child",
createEvidenceBundle([]),
[],
);
expect(
await writeInvestigationWorkspace(workspace, path, null, policy(root)),
).toMatchObject({ ok: true });
expect(await readInvestigationWorkspace(path, policy(root))).toEqual({
ok: true,
value: workspace,
});
});
it("enforces shared JSON depth limits while reading workspaces", async () => {
const { directory, path, input } = await fixture();
const completed = await runCrossVersionInvestigation(
input,
policy(directory),
{},
);
if (!completed.ok) throw completed.error;
await expect(
readInvestigationWorkspace(path, {
...policy(directory),
maxDepth: 1,
}),
).resolves.toMatchObject({
ok: false,
error: { _tag: "InvestigationWorkspaceError", reason: "too-large" },
});
});
it("resumes from a comparison checkpoint without recomputing its identity", async () => {
const { directory, input } = await fixture();
const completed = await runCrossVersionInvestigation(
input,
policy(directory),
{},
);
if (!completed.ok) throw completed.error;
const run = completed.value.workspace.runs[0];
if (run === undefined || run.comparison_evidence_id === null)
throw new Error("missing completed run");
expect(
investigationRunSchema.safeParse({ ...run, status: "running" }).success,
).toBe(false);
const partialRun = investigationRunSchema.parse({
...run,
status: "running",
completed_stages: [
"inventory_left",
"inventory_right",
"compare_artifacts",
],
result_evidence_id: null,
});
const records = completed.value.workspace.bundle.records.filter(
({ evidence_id: id }) => id !== run.result_evidence_id,
);
const partial = createInvestigationWorkspace(
"release-diff",
createEvidenceBundle(records),
[partialRun],
);
const resumePath = join(directory, "resume.json");
expect(
await writeInvestigationWorkspace(
partial,
resumePath,
null,
policy(directory),
),
).toMatchObject({ ok: true });
const replayAuthorization = vi.fn(() => Promise.resolve(ok(null)));
await expect(
runCrossVersionInvestigation(
{
...input,
workspace_path: resumePath,
replay_run_id: partialRun.run_id,
},
policy(directory),
{},
),
).resolves.toMatchObject({
ok: false,
error: {
_tag: "InvestigationWorkspaceError",
reason: "revision-conflict",
},
});
expect(replayAuthorization).not.toHaveBeenCalled();
const resumed = await runCrossVersionInvestigation(
{ ...input, workspace_path: resumePath },
policy(directory),
{},
);
expect(resumed).toMatchObject({
ok: true,
value: {
reused: false,
evidence: { evidence_id: completed.value.evidence.evidence_id },
workspace: { revision: 2 },
},
});
});
});
describe("persistent investigation workspace history", () => {
it("appends a changed-content run without replacing earlier Evidence", async () => {
const { directory, right, input } = await fixture();
const first = await runCrossVersionInvestigation(
input,
policy(directory),
{},
);
if (!first.ok) throw first.error;
const firstRun = first.value.workspace.runs[0];
if (firstRun === undefined || firstRun.result_evidence_id === null)
throw new Error("missing first completed run");
await writeFile(join(right, "app.txt"), "version three\n");
const second = await runCrossVersionInvestigation(
input,
policy(directory),
{},
);
expect(second).toMatchObject({
ok: true,
value: {
reused: false,
workspace: {
revision: 6,
runs: [{ status: "complete" }, { status: "complete" }],
},
},
});
if (!second.ok) throw second.error;
expect(second.value.workspace.runs).toHaveLength(2);
expect(second.value.workspace.runs.map(({ run_id: id }) => id)).toContain(
firstRun.run_id,
);
expect(
second.value.workspace.bundle.records.some(
({ evidence_id: id }) => id === firstRun.result_evidence_id,
),
).toBe(true);
});
it("checkpoints bounded integrity contradictions and safely reuses them", async () => {
const directory = await createTestTempDirectory("rea-workspace-integrity-");
const source = join(directory, "source");
await mkdir(source);
await writeFile(join(source, "addon.node"), "verified native addon\n");
const left = join(directory, "left.asar");
const right = join(directory, "right.asar");
await Promise.all([
createPackageWithOptions(source, left, { unpack: "*.node" }),
createPackageWithOptions(source, right, { unpack: "*.node" }),
]);
await writeFile(join(`${right}.unpacked`, "addon.node"), "tampered\n");
const base = {
approved: true as const,
workspace_path: join(directory, "strict.json"),
workspace_name: "integrity-diff",
left_path: left,
right_path: right,
options: { page_size: 500, change_limit: 100 },
};
const strict = await runCrossVersionInvestigation(
crossVersionInvestigationInputSchema.parse(base),
policy(directory),
{},
);
expect(strict).toMatchObject({
ok: false,
error: { _tag: "ArtifactOperationError", reason: "integrity" },
});
const continuedInput = crossVersionInvestigationInputSchema.parse({
...base,
workspace_path: join(directory, "continued.json"),
integrity_policy: "record-and-continue",
integrity_continue_approved: true,
max_integrity_mismatches: 2,
});
const continued = await runCrossVersionInvestigation(
continuedInput,
policy(directory),
{ integrityContinueEnabled: true },
);
expect(continued).toMatchObject({
ok: true,
value: { reused: false, workspace: { revision: 3 } },
});
if (!continued.ok) throw continued.error;
expect(
continued.value.workspace.bundle.records.some((record) =>
JSON.stringify(record.normalized_result).includes(
'"integrity_contradictions":[{',
),
),
).toBe(true);
expect(
continued.value.workspace.bundle.records.find(
({ operation }) => operation === "compare_artifacts",
)?.normalized_result,
).toMatchObject({ status: "contradiction" });
await expect(
runCrossVersionInvestigation(continuedInput, policy(directory), {
integrityContinueEnabled: true,
}),
).resolves.toMatchObject({ ok: true, value: { reused: true } });
const strictAfterContinuation = crossVersionInvestigationInputSchema.parse({
...base,
workspace_path: continuedInput.workspace_path,
});
await expect(
runCrossVersionInvestigation(
strictAfterContinuation,
policy(directory),
{},
),
).resolves.toMatchObject({
ok: false,
error: { _tag: "ArtifactOperationError", reason: "integrity" },
});
});
});

View File

@ -1,152 +0,0 @@
import {
chmod,
mkdir,
readFile,
rm,
stat,
symlink,
writeFile,
} from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { runCrossVersionInvestigation } from "../../../src/application/CrossVersionInvestigation.js";
import { readInvestigationWorkspace } from "../../../src/application/InvestigationWorkspaceStore.js";
import type { EvidenceFilePolicy } from "../../../src/domain/evidenceBundle.js";
import { crossVersionInvestigationInputSchema } from "../../../src/domain/investigationWorkspace.js";
const policy = (root: string): EvidenceFilePolicy => ({
roots: [root],
maxBytes: 64 * 1024 * 1024,
maxDepth: 64,
maxStringLength: 1024 * 1024,
maxNodes: 1_000_000,
});
const fixture = async () => {
const directory = await createTestTempDirectory("rea-workspace-");
const left = join(directory, "left");
const right = join(directory, "right");
await Promise.all([mkdir(left), mkdir(right)]);
await Promise.all([
writeFile(join(left, "app.txt"), "version one\n"),
writeFile(join(right, "app.txt"), "version two\n"),
writeFile(join(right, "added.txt"), "new behavior candidate\n"),
]);
const path = join(directory, "workspace.json");
const input = crossVersionInvestigationInputSchema.parse({
approved: true,
workspace_path: path,
workspace_name: "release-diff",
left_path: left,
right_path: right,
options: { page_size: 2, change_limit: 100 },
});
return { directory, left, right, path, input };
};
describe("persistent cross-version investigation workspace", () => {
it("fails closed on locks, CAS conflicts, tampering, and cancellation", async () => {
const { directory, path, input } = await fixture();
await writeFile(`${path}.lock`, `${String(process.pid)}\n`, {
mode: 0o600,
});
expect(
await runCrossVersionInvestigation(input, policy(directory), {}),
).toMatchObject({
ok: false,
error: { _tag: "InvestigationWorkspaceError", reason: "locked" },
});
await rm(`${path}.lock`);
await writeFile(`${path}.lock`, "occupied\n", { mode: 0o600 });
expect(
await runCrossVersionInvestigation(input, policy(directory), {}),
).toMatchObject({
ok: false,
error: { _tag: "InvestigationWorkspaceError", reason: "locked" },
});
await rm(`${path}.lock`);
await writeFile(`${path}.lock`, "2147483647\n", { mode: 0o600 });
const completed = await runCrossVersionInvestigation(
input,
policy(directory),
{},
);
if (!completed.ok) throw completed.error;
await expect(stat(`${path}.lock`)).rejects.toMatchObject({
code: "ENOENT",
});
expect(
await runCrossVersionInvestigation(
{ ...input, expected_workspace_revision: 2 },
policy(directory),
{},
),
).toMatchObject({
ok: false,
error: {
_tag: "InvestigationWorkspaceError",
reason: "revision-conflict",
},
});
const decoded = JSON.parse(await readFile(path, "utf8")) as unknown;
if (typeof decoded !== "object" || decoded === null)
throw new Error("invalid workspace fixture");
await chmod(path, 0o600);
await writeFile(path, JSON.stringify({ ...decoded, revision: 99 }));
expect(
await readInvestigationWorkspace(path, policy(directory)),
).toMatchObject({
ok: false,
error: { _tag: "InvestigationWorkspaceError", reason: "integrity" },
});
const cancelledPath = join(directory, "cancelled.json");
const controller = new AbortController();
controller.abort();
expect(
await runCrossVersionInvestigation(
{ ...input, workspace_path: cancelledPath },
policy(directory),
{ signal: controller.signal },
),
).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
});
it("rejects workspace paths and symlinks outside approved roots", async () => {
const { directory, input } = await fixture();
const approved = join(directory, "approved");
await mkdir(approved);
expect(
await runCrossVersionInvestigation(input, policy(approved), {}),
).toMatchObject({
ok: false,
error: {
_tag: "InvestigationWorkspaceError",
reason: "outside-root",
},
});
const outside = join(directory, "outside.json");
await writeFile(outside, "{}", { mode: 0o600 });
const escaped = join(approved, "escaped.json");
await symlink(outside, escaped);
expect(
await runCrossVersionInvestigation(
{ ...input, workspace_path: escaped },
policy(approved),
{},
),
).toMatchObject({
ok: false,
error: { _tag: "InvestigationWorkspaceError", reason: "not-file" },
});
});
});

View File

@ -1,379 +0,0 @@
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import canonicalize from "canonicalize";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { runCrossVersionInvestigation } from "../../../src/application/CrossVersionInvestigation.js";
import {
readInvestigationWorkspace,
writeInvestigationWorkspace,
} from "../../../src/application/InvestigationWorkspaceStore.js";
import { changedBehaviorResultSchema } from "../../../src/domain/changedBehavior.js";
import type { EvidenceFilePolicy } from "../../../src/domain/evidenceBundle.js";
import {
createInvestigationWorkspace,
crossVersionInvestigationInputSchema,
investigationRunSchema,
parseInvestigationWorkspace,
serializeInvestigationWorkspace,
} from "../../../src/domain/investigationWorkspace.js";
const digestCanonical = (value: unknown): string => {
const encoded = canonicalize(value);
if (encoded === undefined) throw new TypeError("fixture is not canonical");
return createHash("sha256").update(encoded).digest("hex");
};
const policy = (root: string): EvidenceFilePolicy => ({
roots: [root],
maxBytes: 64 * 1024 * 1024,
maxDepth: 64,
maxStringLength: 1024 * 1024,
maxNodes: 1_000_000,
});
const fixture = async () => {
const directory = await createTestTempDirectory("rea-workspace-");
const left = join(directory, "left");
const right = join(directory, "right");
await Promise.all([mkdir(left), mkdir(right)]);
await Promise.all([
writeFile(join(left, "app.txt"), "version one\n"),
writeFile(join(right, "app.txt"), "version two\n"),
writeFile(join(right, "added.txt"), "new behavior candidate\n"),
]);
const path = join(directory, "workspace.json");
const input = crossVersionInvestigationInputSchema.parse({
approved: true,
workspace_path: path,
workspace_name: "release-diff",
left_path: left,
right_path: right,
options: { page_size: 2, change_limit: 100 },
});
return { directory, left, right, path, input };
};
describe("persistent cross-version investigation workspace", () => {
it("migrates legacy v1 runs without losing their revision chain", async () => {
const { directory, input } = await fixture();
const completed = await runCrossVersionInvestigation(
input,
policy(directory),
{},
);
if (!completed.ok) throw completed.error;
const current = completed.value.workspace;
const currentRun = current.runs[0];
if (currentRun === undefined) throw new Error("missing completed run");
expect(() =>
investigationRunSchema.parse({
...currentRun,
legacy_request_identity: true,
}),
).toThrow(/migrated strict runs/iu);
const legacyIdentity = digestCanonical({
schema: "rea.cross-version-investigation-request/v1",
left: currentRun.left,
right: currentRun.right,
options: currentRun.options,
});
expect(() =>
investigationRunSchema.parse({
...currentRun,
legacy_request_identity: true,
run_id: `run_${legacyIdentity}`,
request_sha256: legacyIdentity,
max_integrity_mismatches: 100,
}),
).toThrow(/migrated strict runs/iu);
const legacyRuns = current.runs.map((run) => {
const requestSha256 = digestCanonical({
schema: "rea.cross-version-investigation-request/v1",
left: run.left,
right: run.right,
options: run.options,
});
const {
integrity_policy: _integrityPolicy,
integrity_continue_approved: _integrityApproved,
max_integrity_mismatches: _integrityLimit,
...legacy
} = run;
return {
...legacy,
run_id: `run_${requestSha256}`,
request_sha256: requestSha256,
};
});
const legacySemantic = {
workspace_version: 1 as const,
workspace_id: current.workspace_id,
name: current.name,
revision: current.revision,
previous_revision_digest: current.previous_revision_digest,
bundle: current.bundle,
runs: legacyRuns,
};
const legacyDigest = `wrev_${digestCanonical(legacySemantic)}`;
expect(() =>
parseInvestigationWorkspace({
...legacySemantic,
runs: legacyRuns.map((run) => ({
...run,
max_integrity_mismatches: 100,
})),
revision_digest: legacyDigest,
}),
).toThrow(/unrecognized key/iu);
const migrated = parseInvestigationWorkspace({
...legacySemantic,
revision_digest: legacyDigest,
});
expect(migrated).toMatchObject({
revision: current.revision,
previous_revision_digest: current.previous_revision_digest,
runs: [
{
legacy_request_identity: true,
integrity_policy: "fail",
integrity_continue_approved: false,
max_integrity_mismatches: 10,
},
],
});
const encodedLegacy = canonicalize({
...legacySemantic,
revision_digest: legacyDigest,
});
if (encodedLegacy === undefined)
throw new TypeError("legacy fixture is not canonical");
await writeFile(input.workspace_path, encodedLegacy, { mode: 0o600 });
await expect(
runCrossVersionInvestigation(
{ ...input, expected_workspace_revision: current.revision },
policy(directory),
{},
),
).resolves.toMatchObject({ ok: true, value: { reused: true } });
await expect(
runCrossVersionInvestigation(
{
...input,
options: {
...input.options,
change_limit: input.options.change_limit + 1,
},
},
policy(directory),
{},
),
).resolves.toMatchObject({ ok: true, value: { reused: false } });
});
});
describe("persistent investigation workspace reuse", () => {
it("checkpoints, validates, and reuses a completed deterministic run", async () => {
const { directory, path, input } = await fixture();
const first = await runCrossVersionInvestigation(
input,
policy(directory),
{},
);
expect(first).toMatchObject({
ok: true,
value: { reused: false, workspace: { revision: 3 } },
});
if (!first.ok) throw first.error;
const result = changedBehaviorResultSchema.parse(
first.value.evidence.normalized_result,
);
expect(result).toMatchObject({
behavior_status: "unknown",
summary: { static_candidates: 3 },
investigation_run: {
workspace_id: first.value.workspace.workspace_id,
run_id: first.value.workspace.runs[0]?.run_id,
},
});
expect(result.limitations).toContain(
"No process comparison Evidence was supplied.",
);
expect(first.value.workspace.bundle.records).toHaveLength(5);
expect((await stat(path)).mode & 0o777).toBe(0o600);
expect(await readFile(path, "utf8")).toBe(
serializeInvestigationWorkspace(first.value.workspace),
);
const second = await runCrossVersionInvestigation(
input,
policy(directory),
{},
);
expect(second).toMatchObject({
ok: true,
value: {
reused: true,
evidence: { evidence_id: first.value.evidence.evidence_id },
workspace: { revision: 3 },
},
});
expect(await readInvestigationWorkspace(path, policy(directory))).toEqual({
ok: true,
value: first.value.workspace,
});
});
it("produces stable multi-provider Evidence closure across independent version runs", async () => {
const { directory, input } = await fixture();
const repeatedInput = crossVersionInvestigationInputSchema.parse({
...input,
workspace_path: join(directory, "repeated-workspace.json"),
});
const [first, repeated] = await Promise.all([
runCrossVersionInvestigation(input, policy(directory), {}),
runCrossVersionInvestigation(repeatedInput, policy(directory), {}),
]);
if (!first.ok) throw first.error;
if (!repeated.ok) throw repeated.error;
expect(repeated.value.evidence).toEqual(first.value.evidence);
expect(
[
...new Set(
first.value.workspace.bundle.records.map(
({ provider }) => provider.id,
),
),
].sort(),
).toEqual([
"rea-artifact-comparison",
"rea-artifact-graph",
"rea-changed-behavior",
]);
const comparison = first.value.workspace.bundle.records.find(
({ operation }) => operation === "compare_artifacts",
);
if (comparison === undefined)
throw new Error("missing artifact comparison Evidence");
const result = changedBehaviorResultSchema.parse(
first.value.evidence.normalized_result,
);
const evidenceIds = new Set(
first.value.workspace.bundle.records.map(({ evidence_id: id }) => id),
);
expect(result.findings.items).not.toHaveLength(0);
for (const finding of result.findings.items) {
expect(finding).toMatchObject({
classification: "derived_relationship",
source_comparison_id: comparison.evidence_id,
});
expect(finding.evidence_links).toContain(comparison.evidence_id);
expect(
finding.evidence_links.every((evidenceId) =>
evidenceIds.has(evidenceId),
),
).toBe(true);
}
});
});
describe("persistent investigation workspace replay", () => {
it("replays only an explicitly selected and fully verified complete run", async () => {
const { directory, left, right, input } = await fixture();
const completed = await runCrossVersionInvestigation(
input,
policy(directory),
{},
);
if (!completed.ok) throw completed.error;
const run = completed.value.workspace.runs[0];
if (run === undefined || run.comparison_evidence_id === null)
throw new Error("missing completed run");
await expect(
runCrossVersionInvestigation(input, policy(directory), {}),
).resolves.toMatchObject({ ok: true, value: { reused: true } });
const inconsistentRun = investigationRunSchema.parse({
...run,
result_evidence_id: run.comparison_evidence_id,
});
const inconsistent = createInvestigationWorkspace(
input.workspace_name,
completed.value.workspace.bundle,
[inconsistentRun],
);
const inconsistentPath = join(directory, "inconsistent.json");
expect(
await writeInvestigationWorkspace(
inconsistent,
inconsistentPath,
null,
policy(directory),
),
).toMatchObject({ ok: true });
await expect(
runCrossVersionInvestigation(
{
...input,
workspace_path: inconsistentPath,
replay_run_id: run.run_id,
},
policy(directory),
{},
),
).resolves.toMatchObject({
ok: false,
error: { _tag: "EvidenceIntegrityError" },
});
await Promise.all([
rm(left, { recursive: true, force: true }),
rm(right, { recursive: true, force: true }),
]);
await expect(
runCrossVersionInvestigation(
{
...input,
expected_workspace_revision: completed.value.workspace.revision,
replay_run_id: run.run_id,
},
policy(directory),
{},
),
).resolves.toMatchObject({
ok: true,
value: {
reused: true,
evidence: { evidence_id: completed.value.evidence.evidence_id },
workspace: { revision: completed.value.workspace.revision },
},
});
for (const replayInput of [
{ ...input, replay_run_id: `run_${"f".repeat(64)}` },
{ ...input, right_path: `${right}-other`, replay_run_id: run.run_id },
{
...input,
options: { ...input.options, change_limit: 99 },
replay_run_id: run.run_id,
},
])
await expect(
runCrossVersionInvestigation(replayInput, policy(directory), {}),
).resolves.toMatchObject({
ok: false,
error: {
_tag: "InvestigationWorkspaceError",
reason: "revision-conflict",
},
});
});
});

View File

@ -1,323 +0,0 @@
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { z } from "zod";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import type { BinarySession } from "../../../src/application/BinarySession.js";
import { FUNCTION_COMPARISON_EXAMPLE } from "../../../src/contracts/functionComparisonExample.js";
import { INVESTIGATION_EXAMPLES } from "../../../src/contracts/investigationExamples.js";
import {
FUNCTION_COMPARISON_EVIDENCE,
PROCESS_CAPTURE_RECONSTRUCTION,
PROCESS_CAPTURE_REFERENCE,
PROCESS_COMPARISON_EVIDENCE,
} from "../../../src/contracts/investigationExamples.js";
import { changedBehaviorResultSchema } from "../../../src/domain/changedBehavior.js";
import { createServer } from "../../../src/server/createServer.js";
import { observed } from "../../fixtures/analysisExecution.js";
import { readInvestigationWorkspace } from "../../../src/application/InvestigationWorkspaceStore.js";
import type { EvidenceFilePolicy } from "../../../src/domain/evidenceBundle.js";
import type { PermissionAuthority } from "../../../src/application/PermissionAuthority.js";
import { permissionAuthorityForRoot } from "../../fixtures/permissionAuthority.js";
it("never retains derived evidence after request cancellation", async () => {
const { session, server, client } = await connected();
const inputs = [
{
name: "find_changed_behavior",
arguments: INVESTIGATION_EXAMPLES.find_changed_behavior,
},
{
name: "build_call_path",
arguments: INVESTIGATION_EXAMPLES.build_call_path,
},
{
name: "correlate_static_and_runtime",
arguments: INVESTIGATION_EXAMPLES.correlate_static_and_runtime,
},
] as const;
for (const evidence of [
FUNCTION_COMPARISON_EXAMPLE.left,
FUNCTION_COMPARISON_EXAMPLE.right,
FUNCTION_COMPARISON_EVIDENCE,
PROCESS_CAPTURE_REFERENCE,
PROCESS_CAPTURE_RECONSTRUCTION,
PROCESS_COMPARISON_EVIDENCE,
])
expect(session.recordEvidence(evidence).ok).toBe(true);
const initialCount = session.exportEvidenceBundle().records.length;
try {
for (const input of inputs) {
const controller = new AbortController();
const request = client.callTool(input, {
signal: controller.signal,
onprogress: () => controller.abort(),
});
await expect(request).rejects.toThrow(/abort/iu);
}
await expect
.poll(() => session.exportEvidenceBundle().records.length)
.toBe(initialCount);
} finally {
await close(session, server, client);
}
}, 10_000);
it("runs and reuses a persistent cross-version workspace", async () => {
const directory = await createTestTempDirectory("rea-investigation-mcp-");
const left = join(directory, "left");
const right = join(directory, "right");
const workspace = join(directory, "workspace.json");
await Promise.all([mkdir(left), mkdir(right)]);
await Promise.all([
writeFile(join(left, "feature.txt"), "old\n"),
writeFile(join(right, "feature.txt"), "new\n"),
]);
const filePolicy = evidencePolicy(directory);
const { session, server, client } = await connected(filePolicy);
try {
const arguments_ = {
investigation_run: {
approved: true,
workspace_path: workspace,
workspace_name: "mcp-release-diff",
left_path: left,
right_path: right,
options: { page_size: 500, change_limit: 100 },
},
};
const first = await client.callTool({
name: "find_changed_behavior",
arguments: arguments_,
});
expect(first.isError).not.toBe(true);
const firstEvidence = sessionEvidence(session, first.structuredContent);
expect(firstEvidence.normalized_result).toMatchObject({
behavior_status: "unknown",
summary: { static_candidates: 2 },
investigation_run: { inventory_evidence_count: 2 },
});
expect(session.hasEvidence(firstEvidence.evidence_id)).toBe(true);
const loaded = await readInvestigationWorkspace(workspace, filePolicy);
expect(loaded).toMatchObject({
ok: true,
value: { revision: 3, runs: [{ status: "complete" }] },
});
expect(firstEvidence.normalized_result).toHaveProperty(
"investigation_run.workspace_id",
);
const second = await client.callTool({
name: "find_changed_behavior",
arguments: arguments_,
});
expect(second.isError, JSON.stringify(second)).not.toBe(true);
const secondEvidence = sessionEvidence(session, second.structuredContent);
expect(secondEvidence.evidence_id).toBe(firstEvidence.evidence_id);
expect(
await readInvestigationWorkspace(workspace, filePolicy),
).toMatchObject({ ok: true, value: { revision: 3 } });
} finally {
await close(session, server, client);
await rm(directory, { recursive: true, force: true });
}
});
it("replays a complete workspace without write authority", async () => {
const directory = await createTestTempDirectory("rea-investigation-read-");
const left = join(directory, "left");
const right = join(directory, "right");
const workspace = join(directory, "workspace.json");
await Promise.all([mkdir(left), mkdir(right)]);
await Promise.all([
writeFile(join(left, "feature.txt"), "old\n"),
writeFile(join(right, "feature.txt"), "new\n"),
]);
const filePolicy = evidencePolicy(directory);
const arguments_ = {
investigation_run: {
approved: true,
workspace_path: workspace,
workspace_name: "read-only-replay",
left_path: left,
right_path: right,
options: { page_size: 500, change_limit: 100 },
},
};
const writer = await investigationAuthority(directory, true);
const initial = await connected(filePolicy, writer);
try {
const created = await initial.client.callTool({
name: "find_changed_behavior",
arguments: arguments_,
});
expect(created.isError, JSON.stringify(created)).not.toBe(true);
} finally {
await close(initial.session, initial.server, initial.client);
}
const reader = await investigationAuthority(directory, false);
const replay = await connected(filePolicy, reader);
try {
const cached = await replay.client.callTool({
name: "find_changed_behavior",
arguments: arguments_,
});
expect(cached.isError, JSON.stringify(cached)).not.toBe(true);
expect(
await readInvestigationWorkspace(workspace, filePolicy),
).toMatchObject({ ok: true, value: { revision: 3 } });
await writeFile(join(right, "feature.txt"), "changed again\n");
const denied = await replay.client.callTool({
name: "find_changed_behavior",
arguments: arguments_,
});
expect(denied).toMatchObject({
isError: true,
structuredContent: {
error: {
code: "permission_required",
details: { capability: "investigation_workspace_write" },
},
},
});
} finally {
await close(replay.session, replay.server, replay.client);
await rm(directory, { recursive: true, force: true });
}
});
it("defers input permission for an explicit replay with deleted inputs", async () => {
const directory = await createTestTempDirectory("rea-investigation-replay-");
const left = join(directory, "left");
const right = join(directory, "right");
const workspace = join(directory, "workspace.json");
await Promise.all([mkdir(left), mkdir(right)]);
await Promise.all([
writeFile(join(left, "feature.txt"), "old\n"),
writeFile(join(right, "feature.txt"), "new\n"),
]);
const filePolicy = evidencePolicy(directory);
const investigationRun = {
approved: true,
workspace_path: workspace,
workspace_name: "selected-replay",
left_path: left,
right_path: right,
options: { page_size: 500, change_limit: 100 },
};
const writer = await investigationAuthority(directory, true);
const initial = await connected(filePolicy, writer);
let runId: string | undefined;
try {
const created = await initial.client.callTool({
name: "find_changed_behavior",
arguments: { investigation_run: investigationRun },
});
expect(created.isError, JSON.stringify(created)).not.toBe(true);
const evidence = sessionEvidence(
initial.session,
created.structuredContent,
);
const result = changedBehaviorResultSchema.parse(
evidence.normalized_result,
);
runId = result.investigation_run?.run_id;
} finally {
await close(initial.session, initial.server, initial.client);
}
if (runId === undefined) throw new Error("missing completed run ID");
await Promise.all([
rm(left, { recursive: true, force: true }),
rm(right, { recursive: true, force: true }),
]);
const workspaceOnly = await permissionAuthorityForRoot(
directory,
["investigation_workspace_read", "investigation_workspace_write"],
["investigation_workspace_read"],
);
const replay = await connected(filePolicy, workspaceOnly);
try {
const cached = await replay.client.callTool({
name: "find_changed_behavior",
arguments: {
investigation_run: {
...investigationRun,
replay_run_id: runId,
},
},
});
expect(cached.isError, JSON.stringify(cached)).not.toBe(true);
expect(
await readInvestigationWorkspace(workspace, filePolicy),
).toMatchObject({ ok: true, value: { revision: 3 } });
} finally {
await close(replay.session, replay.server, replay.client);
await rm(directory, { recursive: true, force: true });
}
});
const connected = async (
evidenceFilePolicy?: EvidenceFilePolicy,
permissionAuthority?: PermissionAuthority,
) => {
const session = createTestBinarySession(() => ({
health: () => Promise.resolve(),
execute: () => Promise.resolve(observed(null)),
close: () => Promise.resolve(),
}));
const server = createServer(session, session, {
...(evidenceFilePolicy === undefined ? {} : { evidenceFilePolicy }),
...(permissionAuthority === undefined ? {} : { permissionAuthority }),
});
const client = new Client({ name: "investigation-test", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
return { session, server, client };
};
const evidencePolicy = (root: string): EvidenceFilePolicy => ({
roots: [root],
maxBytes: 64 * 1024 * 1024,
maxDepth: 64,
maxStringLength: 1024 * 1024,
maxNodes: 1_000_000,
});
const sessionEvidence = (session: BinarySession, value: unknown) => {
const parsed = z
.object({ evidence_id: z.string().regex(/^ev_[a-f0-9]{64}$/u) })
.parse(value);
const evidence = session.evidenceById(parsed.evidence_id);
if (evidence === undefined) throw new TypeError("Missing session Evidence");
return evidence;
};
const investigationAuthority = async (
root: string,
includeWrite: boolean,
): Promise<PermissionAuthority> =>
permissionAuthorityForRoot(
root,
["investigation_workspace_read", "investigation_workspace_write"],
[
"investigation_workspace_read",
...(includeWrite ? (["investigation_workspace_write"] as const) : []),
],
);
const close = async (
session: BinarySession,
server: Awaited<ReturnType<typeof createServer>>,
client: Client,
) => {
await Promise.allSettled([client.close(), server.close(), session.close()]);
};

View File

@ -1,95 +0,0 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { scanArtifactInventory } from "../../../src/application/ArtifactInventory.js";
import {
scanVersionInventories,
type VersionInventoryScanner,
} from "../../../src/application/CrossVersionInventory.js";
const LIMITS = {
maxEntries: 10,
maxTotalBytes: 1_024,
maxEntryBytes: 512,
maxCompressionRatio: 10,
maxDepth: 8,
maxPathBytes: 256,
} as const;
const INTEGRITY = {
mode: "fail",
} as const;
const deferred = <Value>() => {
let resolvePromise: (value: Value) => void = () => undefined;
let rejectPromise: (reason: unknown) => void = () => undefined;
const promise = new Promise<Value>((resolve, reject) => {
resolvePromise = resolve;
rejectPromise = reject;
});
return { promise, resolve: resolvePromise, reject: rejectPromise };
};
describe("cross-version inventory", () => {
it("starts both scans together and preserves left/right result order", async () => {
const root = await createTestTempDirectory("rea-version-inventory-");
const artifact = join(root, "artifact.js");
await writeFile(artifact, "export const value = 1;\n");
const snapshot = await scanArtifactInventory(artifact, LIMITS);
const left = deferred<typeof snapshot>();
const right = deferred<typeof snapshot>();
const calls: string[] = [];
const controller = new AbortController();
const scanner: VersionInventoryScanner = (...arguments_) => {
const [path, limits, options] = arguments_;
if (options === undefined) throw new Error("Expected scanner options");
calls.push(path);
expect(limits).toBe(LIMITS);
expect(options.signal).toBe(controller.signal);
expect(options.integrity).toBe(INTEGRITY);
return path === "left" ? left.promise : right.promise;
};
const result = scanVersionInventories(
{
leftPath: "left",
rightPath: "right",
limits: LIMITS,
signal: controller.signal,
integrity: INTEGRITY,
},
scanner,
);
expect(calls).toEqual(["left", "right"]);
right.resolve(snapshot);
left.resolve(snapshot);
await expect(result).resolves.toEqual({
left: snapshot,
right: snapshot,
});
});
it("rejects promptly when either parallel scan fails", async () => {
const left = deferred<never>();
const failure = new Error("right scan failed");
const scanner: VersionInventoryScanner = (path) =>
path === "left" ? left.promise : Promise.reject(failure);
await expect(
scanVersionInventories(
{
leftPath: "left",
rightPath: "right",
limits: LIMITS,
integrity: INTEGRITY,
},
scanner,
),
).rejects.toBe(failure);
});
});