[GH-ISSUE #5921] [Bug] Failed target switch drops the profileless native snapshot profile #3596

Closed
opened 2026-10-31 00:42:14 +00:00 by bsakaguchi · 1 comment
Owner

Originally created by @coygeek on GitHub (Oct 21, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/5921

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

Evidence, artifacts, or reconstruction

Observed behavior

After a profileless native target switch fails while merging a snapshot, the previous target remains open but loses snapshot support and the analysis profile on subsequent dispatch Evidence.

Executed through the built stdio MCP server with the real native-macos dispatch metadata reader, without simulated providers. The failed switch reports Conflicting unknown revision. Afterwards, binary_session reports the original target open, but close_binary with snapshot_path fails with Analysis snapshots require a concrete provider analysis profile. Re-importing the original, previously accepted snapshot also fails. A fresh inspect_native_dispatch_metadata query succeeds with analysis_profile: null.

Reproduction

Use the source revision below with its locked dependencies and built runtime. On macOS, save the following focused test as tests/acceptance/profilelessRestore.test.ts. It creates two identical inert Mach-O arm64 files at different paths and valid snapshots containing conflicting revisions of the same Unknown. No target code executes. The helper starts and closes the actual stdio MCP server.

import { writeFile, rm } from "node:fs/promises";
import { basename, join } from "node:path";
import { expect, it } from "vitest";
import { CPU, machoImage } from "../../src/artifacts/apple/MachoImage.fixture.js";
import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js";

it("observes profileless snapshot failure after a rejected target switch", async () => {
  const root = process.env.REA_REPRO_ROOT!;
  const first = join(root, "first.macho");
  const second = join(root, "second.macho");
  const saved = join(root, "before.json");
  const after = join(root, "after.json");
  const conflicting = join(root, "conflicting.json");
  await Promise.all([
    writeFile(first, machoImage({ cpu: CPU.arm64, commands: [] })),
    writeFile(second, machoImage({ cpu: CPU.arm64, commands: [] })),
  ]);
  const { client, call } = await connectLocalToolsMcp({
    REA_ANALYSIS_PROVIDER: "auto",
    GHIDRA_INSTALL_DIR: "/rea-unconfigured-deep-provider/ghidra",
    JAVA_HOME: "/rea-unconfigured-deep-provider/java",
    REA_IDA_MCP_CONFIG: "/rea-unconfigured-deep-provider/ida-mcp.json",
    REA_KUNA_COMMAND: "/rea-unconfigured-deep-provider/kuna",
    REA_KUNA_CACHE_DIR: "/rea-unconfigured-deep-provider/kuna-cache",
  });
  const good = async (name: string, args: Record<string, unknown>) => {
    const response = await call(name, args);
    expect(response.isError, JSON.stringify(response)).not.toBe(true);
    return response;
  };
  const unknown = (severity: string) => ({
    question: "Does this function reach the network?", severity, domain: "network",
    supporting_evidence_ids: [], contradicting_evidence_ids: [],
    required_authority: "shipped-artifact", required_confidence: "observed",
    required_environment: null,
    recommended_probes: [{ operation: "analyze_function", rationale: "Inspect its callers." }],
    relationships: [],
  });
  const error = (response: any) => JSON.parse(response.content[0].text).error;
  try {
    await good("open_binary", { path: second });
    await good("inspect_native_dispatch_metadata", { max_records: 100 });
    await good("record_unknown", unknown("high"));
    await good("close_binary", { snapshot_path: conflicting, overwrite: true });
    await good("open_binary", { path: first });
    const before = await good("inspect_native_dispatch_metadata", { max_records: 100 });
    expect(before.structuredContent?.analysis_profile).not.toBeNull();
    console.log("before analysis profile present", before.structuredContent?.analysis_profile != null);
    await good("record_unknown", unknown("medium"));
    await good("close_binary", { snapshot_path: saved, overwrite: true });
    await good("open_binary", { path: first, snapshot_path: saved });
    const rejected = await call("open_binary", { path: second, snapshot_path: conflicting });
    expect(rejected.isError).toBe(true);
    expect(error(rejected).details.reason).toBe("Conflicting unknown revision");
    console.log("rejected switch", error(rejected).details.reason);
    const status = await good("binary_session", {});
    const state = status.structuredContent?.result as any;
    expect(state.open).toBe(true);
    expect(basename(state.path)).toBe("first.macho");
    expect(state.analysis_provider_binding).toBeNull();
    console.log("restored status", JSON.stringify({ open: state.open, target: basename(state.path), binding: state.analysis_provider_binding }));
    const failedSave = await call("close_binary", { snapshot_path: after, overwrite: true });
    expect(failedSave.isError).toBe(true);
    expect(JSON.stringify(failedSave)).toContain("Analysis snapshots require a concrete provider analysis profile");
    console.log("save after restore", error(failedSave).details.reason);
    const observed = await good("inspect_native_dispatch_metadata", { max_records: 1 });
    expect(observed.structuredContent?.analysis_profile).toBeNull();
    console.log("after analysis profile", observed.structuredContent?.analysis_profile);
    const reimport = await call("open_binary", { path: first, snapshot_path: saved });
    expect(reimport.isError).toBe(true);
    console.log("reimport after restore", error(reimport).details.reason);
    await good("close_binary", {});
    await good("open_binary", { path: first, snapshot_path: saved });
    await good("close_binary", { snapshot_path: after, overwrite: true });
    console.log("recovery", "explicit close and reopen restores snapshot save");
    await client.ping();
  } finally {
    await rm(first, { force: true });
    await rm(second, { force: true });
    await rm(saved, { force: true });
    await rm(after, { force: true });
    await rm(conflicting, { force: true });
  }
}, 30000);

Run just that test from the checkout root:

REA_REPRO_ROOT="$(mktemp -d)" node node_modules/vitest/vitest.mjs run tests/acceptance/profilelessRestore.test.ts --maxWorkers 1

The assertions characterize the current defective behavior. Two completed focused runs reproduced the save/re-import failures and successful explicit-close recovery; the final run also checks that a fresh query loses its profile. The test removes its generated binaries and snapshots.

Expected behavior

A failed switch should restore the previous target's concrete native dispatch workflow commitment along with its target and provider client. The original valid snapshot should remain importable and the restored session should still support snapshot export and profiled dispatch Evidence.

The session restoration contract explicitly recreates the previous target after a failed switch. The normal open path stores a separate profileless workflow commitment. The existing native dispatch snapshot acceptance test establishes successful save and reopen for this route, and the snapshot documentation documents MCP save/import and retaining an open session after a failed save.

Impact and scope

P2: a recoverable failed switch leaves the restored session unable to save or re-import otherwise valid snapshots, and new dispatch Evidence loses configuration provenance. This is bounded state-restoration failure, with a demonstrated workaround: explicitly close, then reopen the target with its previously saved snapshot. That sequence restores successful snapshot saving. Closing has the normal session-record lifetime effects.

Observed with no deep analysis provider configured, two identical minimal Mach-O arm64 fixtures, and a snapshot merge conflict. Cancellation and failed-health restoration paths share the restoration routine by source inspection but were not executed. CLI behavior and occurrence rates with real user investigations were not measured.

REA version

Source revision 52415975942a4b9c53a817915b313ccc404f280e; source package and built server version 6.7.0.

Runtime and platform

macOS 27.0.1 arm64, Node.js v26.12.0, Vitest 5.0.3. Real built stdio MCP adapter and native dispatch reader; no Hopper, Ghidra, IDA, or Kuna analysis engine ran.

Target and artifact context

Two byte-identical minimal thin Mach-O arm64 images, each generated by machoImage({ cpu: CPU.arm64, commands: [] }), at distinct paths. Each snapshot contains a dispatch workflow observation and revision 1 of the same deterministic Unknown, recorded independently with high versus medium severity. Both snapshots are created by successful close_binary calls.

Interface

MCP

Earliest failure stage

Cleanup, cancellation, or lifecycle

Relevant output or evidence

Observed output from the focused test, with assertions at each stage:

before analysis profile present true
rejected switch Conflicting unknown revision
restored status {"open":true,"target":"first.macho","binding":null}
save after restore Analysis snapshots require a concrete provider analysis profile
after analysis profile null
reimport after restore Analysis snapshot profile_mismatch: the snapshot is not bound to the active native dispatch reader
recovery explicit close and reopen restores snapshot save

The MCP failure replies carry error.code: "evidence_integrity_mismatch"; the save message shown above is in error.details.reason. The original snapshot was successfully imported before the failing switch and again after explicit close, so the later rejection is tied to the restored session state.

Typed outcome or recovery expected

Return the original switch error while preserving a usable previous session. The restored session should export/import its existing dispatch snapshot and attach the same concrete upstream reader profile to new dispatch Evidence. Snapshot integrity checks must continue rejecting the conflicting Unknown revision.

Analysis or suspected cause

Source-derived cause: snapshot-import failure invokes failed-open cleanup and restoration. The restore routine rebuilds the previous binding with target, profile, and a fresh run, but omits profilelessWorkflowProfile. For this route profile is null, so the omitted field is the sole concrete snapshot commitment.

Snapshot export/import resolve the commitment from profile ?? profilelessWorkflowProfile. Operation-scoped profile lookup also reads the omitted field. The MCP workflow projector then receives no upstream profile, matching the observed null profile.

Additional context

The relevant session, snapshot-record, and profileless-snapshot files are unchanged on checked origin/main revision bcfcd8b6e2ee0c44c7ac656688db5ceb61a847d5. This is a source freshness check; runtime reproduction used the stated baseline.

A snapshot for different target bytes is rejected during preflight and does not reproduce this defect. The matching-byte fixtures above deliberately reach the later evidence-merge failure. The existing snapshot-switch rollback test covers a conflicting Unknown revision with a concrete deep-provider profile; this report covers the separate profileless native commitment.

No matching report was found in the searched open and closed issues and PRs. Related reports concern different contracts: failed-cleanup ledger retention, dead-client reopening, concurrent snapshot-open ordering, and late-cancelled document-selection projection.

Originally created by @coygeek on GitHub (Oct 21, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/5921 ### Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area Evidence, artifacts, or reconstruction ### Observed behavior After a profileless native target switch fails while merging a snapshot, the previous target remains open but loses snapshot support and the analysis profile on subsequent dispatch Evidence. Executed through the built stdio MCP server with the real native-macos dispatch metadata reader, without simulated providers. The failed switch reports `Conflicting unknown revision`. Afterwards, `binary_session` reports the original target open, but `close_binary` with `snapshot_path` fails with `Analysis snapshots require a concrete provider analysis profile`. Re-importing the original, previously accepted snapshot also fails. A fresh `inspect_native_dispatch_metadata` query succeeds with `analysis_profile: null`. ### Reproduction Use the source revision below with its locked dependencies and built runtime. On macOS, save the following focused test as `tests/acceptance/profilelessRestore.test.ts`. It creates two identical inert Mach-O arm64 files at different paths and valid snapshots containing conflicting revisions of the same Unknown. No target code executes. The helper starts and closes the actual stdio MCP server. ```ts import { writeFile, rm } from "node:fs/promises"; import { basename, join } from "node:path"; import { expect, it } from "vitest"; import { CPU, machoImage } from "../../src/artifacts/apple/MachoImage.fixture.js"; import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js"; it("observes profileless snapshot failure after a rejected target switch", async () => { const root = process.env.REA_REPRO_ROOT!; const first = join(root, "first.macho"); const second = join(root, "second.macho"); const saved = join(root, "before.json"); const after = join(root, "after.json"); const conflicting = join(root, "conflicting.json"); await Promise.all([ writeFile(first, machoImage({ cpu: CPU.arm64, commands: [] })), writeFile(second, machoImage({ cpu: CPU.arm64, commands: [] })), ]); const { client, call } = await connectLocalToolsMcp({ REA_ANALYSIS_PROVIDER: "auto", GHIDRA_INSTALL_DIR: "/rea-unconfigured-deep-provider/ghidra", JAVA_HOME: "/rea-unconfigured-deep-provider/java", REA_IDA_MCP_CONFIG: "/rea-unconfigured-deep-provider/ida-mcp.json", REA_KUNA_COMMAND: "/rea-unconfigured-deep-provider/kuna", REA_KUNA_CACHE_DIR: "/rea-unconfigured-deep-provider/kuna-cache", }); const good = async (name: string, args: Record<string, unknown>) => { const response = await call(name, args); expect(response.isError, JSON.stringify(response)).not.toBe(true); return response; }; const unknown = (severity: string) => ({ question: "Does this function reach the network?", severity, domain: "network", supporting_evidence_ids: [], contradicting_evidence_ids: [], required_authority: "shipped-artifact", required_confidence: "observed", required_environment: null, recommended_probes: [{ operation: "analyze_function", rationale: "Inspect its callers." }], relationships: [], }); const error = (response: any) => JSON.parse(response.content[0].text).error; try { await good("open_binary", { path: second }); await good("inspect_native_dispatch_metadata", { max_records: 100 }); await good("record_unknown", unknown("high")); await good("close_binary", { snapshot_path: conflicting, overwrite: true }); await good("open_binary", { path: first }); const before = await good("inspect_native_dispatch_metadata", { max_records: 100 }); expect(before.structuredContent?.analysis_profile).not.toBeNull(); console.log("before analysis profile present", before.structuredContent?.analysis_profile != null); await good("record_unknown", unknown("medium")); await good("close_binary", { snapshot_path: saved, overwrite: true }); await good("open_binary", { path: first, snapshot_path: saved }); const rejected = await call("open_binary", { path: second, snapshot_path: conflicting }); expect(rejected.isError).toBe(true); expect(error(rejected).details.reason).toBe("Conflicting unknown revision"); console.log("rejected switch", error(rejected).details.reason); const status = await good("binary_session", {}); const state = status.structuredContent?.result as any; expect(state.open).toBe(true); expect(basename(state.path)).toBe("first.macho"); expect(state.analysis_provider_binding).toBeNull(); console.log("restored status", JSON.stringify({ open: state.open, target: basename(state.path), binding: state.analysis_provider_binding })); const failedSave = await call("close_binary", { snapshot_path: after, overwrite: true }); expect(failedSave.isError).toBe(true); expect(JSON.stringify(failedSave)).toContain("Analysis snapshots require a concrete provider analysis profile"); console.log("save after restore", error(failedSave).details.reason); const observed = await good("inspect_native_dispatch_metadata", { max_records: 1 }); expect(observed.structuredContent?.analysis_profile).toBeNull(); console.log("after analysis profile", observed.structuredContent?.analysis_profile); const reimport = await call("open_binary", { path: first, snapshot_path: saved }); expect(reimport.isError).toBe(true); console.log("reimport after restore", error(reimport).details.reason); await good("close_binary", {}); await good("open_binary", { path: first, snapshot_path: saved }); await good("close_binary", { snapshot_path: after, overwrite: true }); console.log("recovery", "explicit close and reopen restores snapshot save"); await client.ping(); } finally { await rm(first, { force: true }); await rm(second, { force: true }); await rm(saved, { force: true }); await rm(after, { force: true }); await rm(conflicting, { force: true }); } }, 30000); ``` Run just that test from the checkout root: ```sh REA_REPRO_ROOT="$(mktemp -d)" node node_modules/vitest/vitest.mjs run tests/acceptance/profilelessRestore.test.ts --maxWorkers 1 ``` The assertions characterize the current defective behavior. Two completed focused runs reproduced the save/re-import failures and successful explicit-close recovery; the final run also checks that a fresh query loses its profile. The test removes its generated binaries and snapshots. ### Expected behavior A failed switch should restore the previous target's concrete native dispatch workflow commitment along with its target and provider client. The original valid snapshot should remain importable and the restored session should still support snapshot export and profiled dispatch Evidence. The [session restoration contract](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/binary/BinarySession.ts#L136-L140) explicitly recreates the previous target after a failed switch. The [normal open path](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/binary/BinarySession.ts#L655-L664) stores a separate profileless workflow commitment. The existing [native dispatch snapshot acceptance test](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/tests/acceptance/nativeDispatchSnapshot.test.ts#L19-L128) establishes successful save and reopen for this route, and the [snapshot documentation](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/docs/cli.md#L251-L265) documents MCP save/import and retaining an open session after a failed save. ### Impact and scope P2: a recoverable failed switch leaves the restored session unable to save or re-import otherwise valid snapshots, and new dispatch Evidence loses configuration provenance. This is bounded state-restoration failure, with a demonstrated workaround: explicitly close, then reopen the target with its previously saved snapshot. That sequence restores successful snapshot saving. Closing has the normal session-record lifetime effects. Observed with no deep analysis provider configured, two identical minimal Mach-O arm64 fixtures, and a snapshot merge conflict. Cancellation and failed-health restoration paths share the restoration routine by source inspection but were not executed. CLI behavior and occurrence rates with real user investigations were not measured. ### REA version Source revision `52415975942a4b9c53a817915b313ccc404f280e`; source package and built server version `6.7.0`. ### Runtime and platform macOS 27.0.1 arm64, Node.js v26.12.0, Vitest 5.0.3. Real built stdio MCP adapter and native dispatch reader; no Hopper, Ghidra, IDA, or Kuna analysis engine ran. ### Target and artifact context Two byte-identical minimal thin Mach-O arm64 images, each generated by `machoImage({ cpu: CPU.arm64, commands: [] })`, at distinct paths. Each snapshot contains a dispatch workflow observation and revision 1 of the same deterministic Unknown, recorded independently with high versus medium severity. Both snapshots are created by successful `close_binary` calls. ### Interface MCP ### Earliest failure stage Cleanup, cancellation, or lifecycle ### Relevant output or evidence Observed output from the focused test, with assertions at each stage: ```text before analysis profile present true rejected switch Conflicting unknown revision restored status {"open":true,"target":"first.macho","binding":null} save after restore Analysis snapshots require a concrete provider analysis profile after analysis profile null reimport after restore Analysis snapshot profile_mismatch: the snapshot is not bound to the active native dispatch reader recovery explicit close and reopen restores snapshot save ``` The MCP failure replies carry `error.code: "evidence_integrity_mismatch"`; the save message shown above is in `error.details.reason`. The original snapshot was successfully imported before the failing switch and again after explicit close, so the later rejection is tied to the restored session state. ### Typed outcome or recovery expected Return the original switch error while preserving a usable previous session. The restored session should export/import its existing dispatch snapshot and attach the same concrete upstream reader profile to new dispatch Evidence. Snapshot integrity checks must continue rejecting the conflicting Unknown revision. ### Analysis or suspected cause Source-derived cause: [snapshot-import failure](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/binary/BinarySession.ts#L679-L685) invokes failed-open cleanup and restoration. The [restore routine](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/binary/BinarySession.ts#L1361-L1384) rebuilds the previous binding with `target`, `profile`, and a fresh run, but omits `profilelessWorkflowProfile`. For this route `profile` is null, so the omitted field is the sole concrete snapshot commitment. [Snapshot export/import](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/binary/BinarySessionRecords.ts#L160-L243) resolve the commitment from `profile ?? profilelessWorkflowProfile`. [Operation-scoped profile lookup](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/binary/BinarySession.ts#L396-L413) also reads the omitted field. The [MCP workflow projector](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/server/registerEnhancedTools.ts#L255-L284) then receives no upstream profile, matching the observed null profile. ### Additional context The relevant session, snapshot-record, and profileless-snapshot files are unchanged on checked `origin/main` revision `bcfcd8b6e2ee0c44c7ac656688db5ceb61a847d5`. This is a source freshness check; runtime reproduction used the stated baseline. A snapshot for different target bytes is rejected during preflight and does not reproduce this defect. The matching-byte fixtures above deliberately reach the later evidence-merge failure. The existing [snapshot-switch rollback test](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/tests/composition/analysis-sessions/binarySession.test.ts#L865-L907) covers a conflicting Unknown revision with a concrete deep-provider profile; this report covers the separate profileless native commitment. No matching report was found in the searched open and closed issues and PRs. Related reports concern different contracts: failed-cleanup ledger retention, dead-client reopening, concurrent snapshot-open ordering, and late-cancelled document-selection projection.
Author
Owner

@coygeek commented on GitHub (Oct 24, 2026):

Checked the reported fix on main at d87e73f2db.

Session restoration spreads the prior complete binding before replacing the runtime client/run, preserving profilelessWorkflowProfile. Snapshot export and Evidence profile lookup still consume that commitment. The acceptance regression reproduces a conflicting Unknown switch and asserts restoration, later profiled dispatch, valid original snapshot import and snapshot export.

Implementation: BinarySession.ts, BinarySessionRecords.ts.

The relevant regression checks in nativeDispatchSnapshot.test.ts passed in the focused test run on this revision. This closure is based on the current source and those checks; provider fixtures do not establish a real-engine run. Closing as completed on main.

<!-- gh-comment-id:6380733049 --> @coygeek commented on GitHub (Oct 24, 2026): Checked the reported fix on `main` at d87e73f2db50be72d33a4b3c7199af35518b441c. Session restoration spreads the prior complete binding before replacing the runtime client/run, preserving profilelessWorkflowProfile. Snapshot export and Evidence profile lookup still consume that commitment. The acceptance regression reproduces a conflicting Unknown switch and asserts restoration, later profiled dispatch, valid original snapshot import and snapshot export. Implementation: [BinarySession.ts](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/src/application/binary/BinarySession.ts#L1416), [BinarySessionRecords.ts](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/src/application/binary/BinarySessionRecords.ts#L192). The relevant regression checks in [nativeDispatchSnapshot.test.ts](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/tests/acceptance/nativeDispatchSnapshot.test.ts) passed in the focused test run on this revision. This closure is based on the current source and those checks; provider fixtures do not establish a real-engine run. Closing as completed on main.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#3596
No description provided.