[GH-ISSUE #3192] [Bug] Ambiguous runtime script candidates receive false absence load states #1131

Closed
opened 2026-10-15 18:31:20 +00:00 by bsakaguchi · 1 comment
Owner

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

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

rea reconcile-javascript-runtime reports a captured script as ambiguous between two static layers while classifying both candidate assets as not-observed-in-capture. The reproduced case supplies an application layer and an assets layer with identical app.js bytes, the same exercised file-root mapping, and complete capture coverage.

The ambiguous CLI call exited 0 and returned coverage.status: "complete-within-inputs". Its script reconciliation had status: "ambiguous", reason: "ambiguous-static-candidates", and candidate_static_count: 2. Both exact candidate references appeared in static_load_states with status: "not-observed-in-capture", reason: "not-observed-in-bounded-capture", and no runtime node IDs. The separate uncaptured absent.js assets also received absence states.

A single-layer control using the same capture exited 0, matched the script with basis: "content-and-location", and classified its asset as loaded. Its absent.js asset remained not-observed-in-capture. Both static analyzer calls succeeded and reported complete graph coverage.

Reproduction

  1. Start from a clean checkout of the baseline under REA version. Install locked dependencies and build the standalone CLI with the repository's development toolchain. These setup commands were not rerun during verification because the checkout already had dependencies and a baseline build.
npm ci
npm run build:cached
  1. From the checkout root, run the following fixture generator. It analyzes two local directories through the real CLI and constructs complete Electron inspection Evidence using the same production constructors and capture shape as the test suite. The capture is synthetic; it does not connect to an Electron instance or the declared loopback endpoint. The application and assets layers each contain the same app.js and a separate absent.js control. HOME and XDG directories are isolated beneath the disposable reproduction directory.
export REPRO_DIR="$(mktemp -d)"
mkdir -p "$REPRO_DIR/home/config" "$REPRO_DIR/home/cache" "$REPRO_DIR/home/data"
export HOME="$REPRO_DIR/home"
export XDG_CONFIG_HOME="$HOME/config" XDG_CACHE_HOME="$HOME/cache" XDG_DATA_HOME="$HOME/data"
node --input-type=module <<'JS'
import { execFileSync } from 'node:child_process';
import { mkdirSync, writeFileSync } from 'node:fs';
import { resolve, join } from 'node:path';
import { pathToFileURL } from 'node:url';
const base = resolve(process.env.REPRO_DIR);
const load = p => import(pathToFileURL(resolve('dist', p)));
const { createElectronEvidence } = await load('application/javascript/ElectronEvidence.js');
const { inspectElectronPageInputSchema } = await load('domain/javascript/electronObservation.js');
const { createWebTextArtifact } = await load('domain/webContentArtifact.js');
const source = 'export const observed = 1;\n';
const application = join(base, 'application');
const assets = join(base, 'assets');
const runtime = join(base, 'runtime');
for (const root of [application, assets, runtime]) {
  mkdirSync(root, { recursive: true });
  writeFileSync(join(root, 'app.js'), source);
  writeFileSync(join(root, 'index.html'), '<script src="app.js"></script>');
}
writeFileSync(join(application, 'absent.js'), 'export const absent = 2;\n');
writeFileSync(join(assets, 'absent.js'), 'export const absent = 2;\n');
function cli(args, filename) {
  const stdout = execFileSync(process.execPath, ['scripts/rea.mjs', ...args, '--json'], { encoding: 'utf8', maxBuffer: 16*1024*1024, env: process.env });
  writeFileSync(join(base, filename), stdout);
  return JSON.parse(stdout);
}
function analysis(root, filename) {
  const output = cli(['analyze-javascript-application', root], filename);
  const evidence = output.normalized_result ? output : output.details?.partial_observation;
  if (!evidence) throw new Error(JSON.stringify(output));
  return evidence;
}
const a = analysis(application, 'application-output.json');
const b = analysis(assets, 'assets-output.json');
const input = inspectElectronPageInputSchema.parse({ cdp_endpoint: 'http://127.0.0.1:9223', target_id: 'target-main', observation_ms: 100, include_script_sources: true });
const capture = createElectronEvidence('inspect_electron_page', input, {
  browser: { product: 'Electron/fixture', protocol_version: '1.3', revision: 'fixture', user_agent: 'Electron fixture', js_version: '13' },
  target: { target_id: 'target-main', type: 'page', title: 'Fixture', file_path: join(runtime, 'index.html'), attached: false },
  capture_window: { started_at: '2026-07-15T00:00:00.000Z', ended_at: '2026-07-15T00:00:00.100Z', observation_ms: 100 },
  completeness: { status: 'complete_within_window', conditions: ['complete_within_window'], policy_filtered_sections: [], attach_limited_sections: [], truncated_sections: [], unavailable_sections: [], excluded: [], dropped_events: { scripts: 0, network_requests: 0, console_events: 0, websocket_connections: 0, websocket_frames: 0, webmcp_tools: 0, timeline_events: 0, total: 0 } },
  frames: [{ frame_id: 'frame-main', parent_frame_id: null, file_path: join(runtime, 'index.html') }],
  dom: { total_nodes: 0, nodes: [] },
  scripts: { total: 1, items: [{ script_key: `electron_script_${'1'.repeat(64)}`, frame_id: 'frame-main', file_path: join(runtime, 'app.js'), cdp_hash: 'fixture', length: Buffer.byteLength(source), is_module: true, language: 'JavaScript', source: { included: true, artifact: createWebTextArtifact(source, 'text/javascript') } }] },
  resources: [], workers: [], limitations: ['Synthetic passive capture fixture.']
}, { id: 'rea-cdp-electron', name: 'REA Electron file-page CDP observation provider', version: '1' });
const mapping = [{ kind: 'file-root', root: runtime, artifact_prefix: '' }];
const layers = [{ role: 'application', analysis: a, runtime_mappings: mapping }, { role: 'assets', analysis: b, runtime_mappings: mapping }];
function run(name, staticLayers) {
  const path = join(base, `${name}-input.json`);
  writeFileSync(path, JSON.stringify({ static_layers: staticLayers, runtime_observations: [capture] }));
  const evidence = cli(['reconcile-javascript-runtime', path], `${name}-output.json`);
  if (!evidence.normalized_result) throw new Error(JSON.stringify(evidence));
  const result = evidence.normalized_result;
  const script = result.reconciliations.find(x => x.entity_kind === 'script');
  const states = result.static_load_states.filter(x => script.candidate_static_nodes.some(c => c.static_layer_id === x.static_layer_id && c.static_node_id === x.static_node_id));
  const absent = result.static_load_states.filter(x => x.kind === 'javascript-asset' && !states.includes(x));
  const summary = { name, script, candidate_load_states: states, other_asset_load_states: absent, coverage: result.coverage, summary: result.summary, static_coverage: [a.normalized_result.graph.coverage, b.normalized_result.graph.coverage] };
  writeFileSync(join(base, `${name}-summary.json`), JSON.stringify(summary, null, 2));
  console.log(JSON.stringify({
    name, script_status: script.status, candidate_count: script.candidate_static_count,
    candidate_states: states.map(x => ({ status: x.status, reason: x.reason })),
    absent_asset_states: absent.map(x => x.status), coverage: result.coverage.status
  }, null, 2));
}
run('ambiguous', layers);
run('single-layer', layers.slice(0, 1));
JS
  1. Compare the two printed results. The ambiguous input includes both static layers, each with an exercised file-root mapping to the runtime directory and an empty artifact prefix. The single-layer control keeps only the application layer with the identical capture. The script writes both input and full output Evidence JSON files to the reproduction directory, so either can also be replayed with node scripts/rea.mjs reconcile-javascript-runtime "$REPRO_DIR/ambiguous-input.json" --json.

The verifier independently replayed the inline fixture and CLI calls above. The clean-checkout install and build commands are setup instructions; verification used preinstalled locked dependencies and prebuilt baseline runtime artifacts and did not rerun those setup commands.

Expected behavior

docs/javascript-runtime-reconciliation.md:99-101 requires multiple qualified candidates to remain ambiguous. docs/javascript-runtime-reconciliation.md:122-127 defines unknown to cover unresolved correspondence and limits absence claims to entities not seen within complete mapped captures.

Keep the script reconciliation ambiguous and classify both candidate assets as unknown. Byte identity establishes that either layer-qualified candidate could correspond to the captured bytes; it does not establish which one appeared. Complete coverage should still permit not-observed-in-capture for the separate absent.js assets. The single-layer control should retain its accepted match and loaded state.

Impact and scope

Consumers of the load-state projection receive a definite absence classification for assets whose layer attribution remains unresolved. The observed failure is bounded to ambiguous candidates within complete exercised mapping coverage and can mislead interpretation of captured script presence. It concerns evidence interpretation, with no observed security boundary violation.

A practical workaround is to cross-check the ambiguous reconciliation's returned layer/node candidate references and treat those candidates' absence states as uncertain. Both the references and the contradictory states were present in the reproduced output. Omitting the additional layer is a diagnostic control rather than a workaround because it drops input scope.

REA version

6.4.0 (main at 5830088520)

Runtime and platform

Node.js v26.10.0; macOS arm64. The checkout pins Node.js 24.18.0 for development; that exact runtime was not exercised during verification.

Target and artifact context

Two local JavaScript directories analyzed through the supported CLI, with identical external app.js assets and separate uncaptured absent.js controls. The runtime input is schema-valid synthetic complete Electron inspection Evidence constructed with the production Evidence and text-artifact constructors, following tests/fixtures/javascriptRuntimeReconciliation.ts:48-135. No live Electron instance, browser, or external API is needed. The declared loopback endpoint is fixture metadata and is not contacted.

Interface

CLI

Earliest failure stage

Transport or result projection

Relevant output or evidence

The following selected fields summarize the independently observed CLI results.

ambiguous:
  coverage.status: complete-within-inputs
  script.status: ambiguous
  script.reason: ambiguous-static-candidates
  script.candidate_static_count: 2
  candidate 1 load status: not-observed-in-capture
  candidate 1 reason: not-observed-in-bounded-capture
  candidate 2 load status: not-observed-in-capture
  candidate 2 reason: not-observed-in-bounded-capture
  separate absent.js asset states: not-observed-in-capture

single-layer:
  script.status: matched
  script.basis: content-and-location
  candidate asset load status: loaded
  separate absent.js asset state: not-observed-in-capture

Typed outcome or recovery expected

Retain status: "ambiguous" and both qualified candidate_static_nodes references. Return status: "unknown" for each candidate asset's load state because correspondence remains unresolved. Preserve the separate uncaptured assets' not-observed-in-capture states and the single-layer control's loaded state. No particular new reason string or implementation is required.

Analysis or suspected cause

Source-derived reasoning at baseline 58300885207133422af235e0f2b10658cf958b03: src/domain/javascript/javascriptRuntimeLoadState.ts:49-55 collects only successful matches. Lines 61-85 then apply completeness-based absence classification within mapping scope without retaining ambiguous candidate references. src/domain/javascript/javascriptRuntimeReconciliationMatching.ts:111-118 preserves the two-candidate ambiguity in the reconciliation result separately. This source path explains the reproduced contradictory load-state projection.

Additional context

Related issue #2524 and merged PRs #2422 and #2635 concern Windows case handling for recorded POSIX roots; this report concerns absence states assigned to multiple valid byte-identical candidates after ambiguity is already retained.

P2 is supported by the bounded incorrect analytical classification and the practical candidate-reference cross-check described above.

Originally created by @coygeek on GitHub (Oct 14, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/3192 ### 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 `rea reconcile-javascript-runtime` reports a captured script as ambiguous between two static layers while classifying both candidate assets as `not-observed-in-capture`. The reproduced case supplies an application layer and an assets layer with identical `app.js` bytes, the same exercised file-root mapping, and complete capture coverage. The ambiguous CLI call exited 0 and returned `coverage.status: "complete-within-inputs"`. Its script reconciliation had `status: "ambiguous"`, `reason: "ambiguous-static-candidates"`, and `candidate_static_count: 2`. Both exact candidate references appeared in `static_load_states` with `status: "not-observed-in-capture"`, `reason: "not-observed-in-bounded-capture"`, and no runtime node IDs. The separate uncaptured `absent.js` assets also received absence states. A single-layer control using the same capture exited 0, matched the script with `basis: "content-and-location"`, and classified its asset as `loaded`. Its `absent.js` asset remained `not-observed-in-capture`. Both static analyzer calls succeeded and reported complete graph coverage. ### Reproduction 1. Start from a clean checkout of the baseline under REA version. Install locked dependencies and build the standalone CLI with the repository's development toolchain. These setup commands were not rerun during verification because the checkout already had dependencies and a baseline build. ```sh npm ci npm run build:cached ``` 2. From the checkout root, run the following fixture generator. It analyzes two local directories through the real CLI and constructs complete Electron inspection Evidence using the same production constructors and capture shape as the test suite. The capture is synthetic; it does not connect to an Electron instance or the declared loopback endpoint. The application and assets layers each contain the same `app.js` and a separate `absent.js` control. HOME and XDG directories are isolated beneath the disposable reproduction directory. ```sh export REPRO_DIR="$(mktemp -d)" mkdir -p "$REPRO_DIR/home/config" "$REPRO_DIR/home/cache" "$REPRO_DIR/home/data" export HOME="$REPRO_DIR/home" export XDG_CONFIG_HOME="$HOME/config" XDG_CACHE_HOME="$HOME/cache" XDG_DATA_HOME="$HOME/data" node --input-type=module <<'JS' import { execFileSync } from 'node:child_process'; import { mkdirSync, writeFileSync } from 'node:fs'; import { resolve, join } from 'node:path'; import { pathToFileURL } from 'node:url'; const base = resolve(process.env.REPRO_DIR); const load = p => import(pathToFileURL(resolve('dist', p))); const { createElectronEvidence } = await load('application/javascript/ElectronEvidence.js'); const { inspectElectronPageInputSchema } = await load('domain/javascript/electronObservation.js'); const { createWebTextArtifact } = await load('domain/webContentArtifact.js'); const source = 'export const observed = 1;\n'; const application = join(base, 'application'); const assets = join(base, 'assets'); const runtime = join(base, 'runtime'); for (const root of [application, assets, runtime]) { mkdirSync(root, { recursive: true }); writeFileSync(join(root, 'app.js'), source); writeFileSync(join(root, 'index.html'), '<script src="app.js"></script>'); } writeFileSync(join(application, 'absent.js'), 'export const absent = 2;\n'); writeFileSync(join(assets, 'absent.js'), 'export const absent = 2;\n'); function cli(args, filename) { const stdout = execFileSync(process.execPath, ['scripts/rea.mjs', ...args, '--json'], { encoding: 'utf8', maxBuffer: 16*1024*1024, env: process.env }); writeFileSync(join(base, filename), stdout); return JSON.parse(stdout); } function analysis(root, filename) { const output = cli(['analyze-javascript-application', root], filename); const evidence = output.normalized_result ? output : output.details?.partial_observation; if (!evidence) throw new Error(JSON.stringify(output)); return evidence; } const a = analysis(application, 'application-output.json'); const b = analysis(assets, 'assets-output.json'); const input = inspectElectronPageInputSchema.parse({ cdp_endpoint: 'http://127.0.0.1:9223', target_id: 'target-main', observation_ms: 100, include_script_sources: true }); const capture = createElectronEvidence('inspect_electron_page', input, { browser: { product: 'Electron/fixture', protocol_version: '1.3', revision: 'fixture', user_agent: 'Electron fixture', js_version: '13' }, target: { target_id: 'target-main', type: 'page', title: 'Fixture', file_path: join(runtime, 'index.html'), attached: false }, capture_window: { started_at: '2026-07-15T00:00:00.000Z', ended_at: '2026-07-15T00:00:00.100Z', observation_ms: 100 }, completeness: { status: 'complete_within_window', conditions: ['complete_within_window'], policy_filtered_sections: [], attach_limited_sections: [], truncated_sections: [], unavailable_sections: [], excluded: [], dropped_events: { scripts: 0, network_requests: 0, console_events: 0, websocket_connections: 0, websocket_frames: 0, webmcp_tools: 0, timeline_events: 0, total: 0 } }, frames: [{ frame_id: 'frame-main', parent_frame_id: null, file_path: join(runtime, 'index.html') }], dom: { total_nodes: 0, nodes: [] }, scripts: { total: 1, items: [{ script_key: `electron_script_${'1'.repeat(64)}`, frame_id: 'frame-main', file_path: join(runtime, 'app.js'), cdp_hash: 'fixture', length: Buffer.byteLength(source), is_module: true, language: 'JavaScript', source: { included: true, artifact: createWebTextArtifact(source, 'text/javascript') } }] }, resources: [], workers: [], limitations: ['Synthetic passive capture fixture.'] }, { id: 'rea-cdp-electron', name: 'REA Electron file-page CDP observation provider', version: '1' }); const mapping = [{ kind: 'file-root', root: runtime, artifact_prefix: '' }]; const layers = [{ role: 'application', analysis: a, runtime_mappings: mapping }, { role: 'assets', analysis: b, runtime_mappings: mapping }]; function run(name, staticLayers) { const path = join(base, `${name}-input.json`); writeFileSync(path, JSON.stringify({ static_layers: staticLayers, runtime_observations: [capture] })); const evidence = cli(['reconcile-javascript-runtime', path], `${name}-output.json`); if (!evidence.normalized_result) throw new Error(JSON.stringify(evidence)); const result = evidence.normalized_result; const script = result.reconciliations.find(x => x.entity_kind === 'script'); const states = result.static_load_states.filter(x => script.candidate_static_nodes.some(c => c.static_layer_id === x.static_layer_id && c.static_node_id === x.static_node_id)); const absent = result.static_load_states.filter(x => x.kind === 'javascript-asset' && !states.includes(x)); const summary = { name, script, candidate_load_states: states, other_asset_load_states: absent, coverage: result.coverage, summary: result.summary, static_coverage: [a.normalized_result.graph.coverage, b.normalized_result.graph.coverage] }; writeFileSync(join(base, `${name}-summary.json`), JSON.stringify(summary, null, 2)); console.log(JSON.stringify({ name, script_status: script.status, candidate_count: script.candidate_static_count, candidate_states: states.map(x => ({ status: x.status, reason: x.reason })), absent_asset_states: absent.map(x => x.status), coverage: result.coverage.status }, null, 2)); } run('ambiguous', layers); run('single-layer', layers.slice(0, 1)); JS ``` 3. Compare the two printed results. The `ambiguous` input includes both static layers, each with an exercised file-root mapping to the runtime directory and an empty artifact prefix. The `single-layer` control keeps only the application layer with the identical capture. The script writes both input and full output Evidence JSON files to the reproduction directory, so either can also be replayed with `node scripts/rea.mjs reconcile-javascript-runtime "$REPRO_DIR/ambiguous-input.json" --json`. The verifier independently replayed the inline fixture and CLI calls above. The clean-checkout install and build commands are setup instructions; verification used preinstalled locked dependencies and prebuilt baseline runtime artifacts and did not rerun those setup commands. ### Expected behavior `docs/javascript-runtime-reconciliation.md:99-101` requires multiple qualified candidates to remain ambiguous. `docs/javascript-runtime-reconciliation.md:122-127` defines `unknown` to cover unresolved correspondence and limits absence claims to entities not seen within complete mapped captures. Keep the script reconciliation ambiguous and classify both candidate assets as `unknown`. Byte identity establishes that either layer-qualified candidate could correspond to the captured bytes; it does not establish which one appeared. Complete coverage should still permit `not-observed-in-capture` for the separate `absent.js` assets. The single-layer control should retain its accepted match and `loaded` state. ### Impact and scope Consumers of the load-state projection receive a definite absence classification for assets whose layer attribution remains unresolved. The observed failure is bounded to ambiguous candidates within complete exercised mapping coverage and can mislead interpretation of captured script presence. It concerns evidence interpretation, with no observed security boundary violation. A practical workaround is to cross-check the ambiguous reconciliation's returned layer/node candidate references and treat those candidates' absence states as uncertain. Both the references and the contradictory states were present in the reproduced output. Omitting the additional layer is a diagnostic control rather than a workaround because it drops input scope. ### REA version 6.4.0 (main at 58300885207133422af235e0f2b10658cf958b03) ### Runtime and platform Node.js v26.10.0; macOS arm64. The checkout pins Node.js 24.18.0 for development; that exact runtime was not exercised during verification. ### Target and artifact context Two local JavaScript directories analyzed through the supported CLI, with identical external `app.js` assets and separate uncaptured `absent.js` controls. The runtime input is schema-valid synthetic complete Electron inspection Evidence constructed with the production Evidence and text-artifact constructors, following `tests/fixtures/javascriptRuntimeReconciliation.ts:48-135`. No live Electron instance, browser, or external API is needed. The declared loopback endpoint is fixture metadata and is not contacted. ### Interface CLI ### Earliest failure stage Transport or result projection ### Relevant output or evidence The following selected fields summarize the independently observed CLI results. ```text ambiguous: coverage.status: complete-within-inputs script.status: ambiguous script.reason: ambiguous-static-candidates script.candidate_static_count: 2 candidate 1 load status: not-observed-in-capture candidate 1 reason: not-observed-in-bounded-capture candidate 2 load status: not-observed-in-capture candidate 2 reason: not-observed-in-bounded-capture separate absent.js asset states: not-observed-in-capture single-layer: script.status: matched script.basis: content-and-location candidate asset load status: loaded separate absent.js asset state: not-observed-in-capture ``` ### Typed outcome or recovery expected Retain `status: "ambiguous"` and both qualified `candidate_static_nodes` references. Return `status: "unknown"` for each candidate asset's load state because correspondence remains unresolved. Preserve the separate uncaptured assets' `not-observed-in-capture` states and the single-layer control's `loaded` state. No particular new reason string or implementation is required. ### Analysis or suspected cause Source-derived reasoning at baseline `58300885207133422af235e0f2b10658cf958b03`: `src/domain/javascript/javascriptRuntimeLoadState.ts:49-55` collects only successful matches. Lines 61-85 then apply completeness-based absence classification within mapping scope without retaining ambiguous candidate references. `src/domain/javascript/javascriptRuntimeReconciliationMatching.ts:111-118` preserves the two-candidate ambiguity in the reconciliation result separately. This source path explains the reproduced contradictory load-state projection. ### Additional context Related issue [#2524](https://github.com/morluto/rea/issues/2524) and merged PRs [#2422](https://github.com/morluto/rea/pull/2422) and [#2635](https://github.com/morluto/rea/pull/2635) concern Windows case handling for recorded POSIX roots; this report concerns absence states assigned to multiple valid byte-identical candidates after ambiguity is already retained. P2 is supported by the bounded incorrect analytical classification and the practical candidate-reference cross-check described above.
bsakaguchi 2026-10-15 18:31:20 +00:00
  • closed this issue
  • added the
    bug
    label
Author
Owner

@morluto commented on GitHub (Oct 14, 2026):

Resolved on main by 5e79d468e.

Ambiguous runtime candidates retain unknown load states rather than being classified as absent.

Rechecked on main b8510ee00 in the current focused run: 20 test files passed, 225 tests passed and one platform-dependent test skipped. Relevant regression coverage: tests/boundary/cli/electronCli.test.ts.

<!-- gh-comment-id:6174981331 --> @morluto commented on GitHub (Oct 14, 2026): Resolved on main by 5e79d468e. Ambiguous runtime candidates retain unknown load states rather than being classified as absent. Rechecked on main b8510ee00 in the current focused run: 20 test files passed, 225 tests passed and one platform-dependent test skipped. Relevant regression coverage: tests/boundary/cli/electronCli.test.ts.
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#1131
No description provided.