fix(workspace): migrate legacy integrity identities

This commit is contained in:
morluto 2026-07-14 01:35:08 +00:00
parent 6540d64848
commit 009fa3311e
3 changed files with 262 additions and 4 deletions

View File

@ -13,6 +13,7 @@ import {
type AnalysisError,
} from "../domain/errors.js";
import {
createLegacyInvestigationRunIdentity,
createInvestigationRunIdentity,
createInvestigationWorkspace,
crossVersionInvestigationInputSchema,
@ -137,8 +138,20 @@ const continueInvestigation = async (context: {
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_id: id }) => id === identity.runId,
(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);

View File

@ -100,7 +100,7 @@ const stageSchema = z.enum([
"find_changed_behavior",
]);
export const investigationRunSchema = z.object({
const investigationRunBaseSchema = z.object({
schema_version: z.literal(1),
run_id: z.string().regex(/^run_[a-f0-9]{64}$/u),
request_sha256: digestSchema,
@ -119,6 +119,31 @@ export const investigationRunSchema = z.object({
limitations: z.array(z.string().max(4_096)).max(100),
});
export const investigationRunSchema = z
.union([
investigationRunBaseSchema.extend({
legacy_request_identity: z.literal(true),
}),
investigationRunBaseSchema,
])
.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({
@ -146,6 +171,20 @@ const investigationWorkspaceSchema = z.object({
runs: z.array(investigationRunSchema).max(1_000),
});
const legacyInvestigationRunSchema = investigationRunBaseSchema
.omit({
integrity_policy: true,
integrity_continue_approved: true,
max_integrity_mismatches: true,
})
.strict();
const legacyInvestigationWorkspaceSchema = investigationWorkspaceSchema
.extend({
runs: z.array(legacyInvestigationRunSchema).max(1_000),
})
.strict();
export type InvestigationWorkspace = z.infer<
typeof investigationWorkspaceSchema
>;
@ -207,6 +246,7 @@ export const reviseInvestigationWorkspace = (
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({
@ -268,9 +308,16 @@ const validateRunReferences = (workspace: InvestigationWorkspace): void => {
);
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
(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");
validateRunStages(run);
@ -287,6 +334,85 @@ const validateRunReferences = (workspace: InvestigationWorkspace): void => {
}
};
/** 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 + 1,
previousRevisionDigest: parsed.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 validateRunStages = (run: InvestigationRun): void => {
const expected = [
"inventory_left",

View File

@ -8,11 +8,13 @@ import {
symlink,
writeFile,
} from "node:fs/promises";
import { createHash } from "node:crypto";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createPackageWithOptions } from "@electron/asar";
import canonicalize from "canonicalize";
import { runCrossVersionInvestigation } from "../src/application/CrossVersionInvestigation.js";
import {
@ -26,9 +28,16 @@ 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");
};
let directory: string | undefined;
afterEach(async () => {
@ -68,6 +77,116 @@ const fixture = async () => {
};
describe("persistent cross-version investigation workspace", () => {
it("migrates legacy v1 runs without losing their revision chain", async () => {
const { input } = await fixture();
if (directory === undefined) throw new Error("missing fixture root");
const completed = await runCrossVersionInvestigation(
input,
policy(directory),
{ inputRoots: [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 + 1,
previous_revision_digest: legacyDigest,
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, policy(directory), {
inputRoots: [directory],
}),
).resolves.toMatchObject({ ok: true, value: { reused: true } });
await expect(
runCrossVersionInvestigation(
{ ...input, integrity_continue_approved: true },
policy(directory),
{ inputRoots: [directory] },
),
).resolves.toMatchObject({ ok: true, value: { reused: false } });
});
it("checkpoints, validates, and reuses a completed deterministic run", async () => {
const { path, input } = await fixture();
if (directory === undefined) throw new Error("missing fixture root");