[GH-ISSUE #7675] [Bug] Missing unknown references lose their diagnostic and recommend an ineffective retry #4597

Closed
opened 2026-10-31 00:51:09 +00:00 by bsakaguchi · 0 comments
Owner

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

Before submitting

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

Area

MCP or agent integration

Observed behavior

In a fresh target-free stdio MCP connection, record_unknown with supporting_evidence_ids: ["ev_" + "0".repeat(64)] rejects the absent reference, but reports that Evidence state changed before an update completed. Its remedy tells the caller to refresh state and try again. Neither the message nor its details identifies the missing ID or reference constraint.

Repeating that request after reading the current empty ledger produces the identical failure. Both failures leave the complete Evidence bundle unchanged and list_unknowns.total at zero. There is no competing writer or completed mutation in this sequence.

The same connection returns the same diagnostic for a relationship to an absent unknown and for a current-revision update_unknown claiming a verified resolution using only mutation Evidence. Those are related controls for the shared loss of failure context. Replacing the absent supporting reference with an existing record succeeds; withdrawing the unsupported claim also succeeds without changing the rejected update's expected revision.

Reproduction

  1. Use the source baseline below with its locked dependencies and prepared runtime build. Put Node.js 24.22.1 on PATH, then run the following from the checkout root. It needs no native target, provider, network service, or downloaded package.
  2. The script starts the public stdio entry point with disposable HOME/XDG/temp directories and fixture npx/npm/bun executables first on PATH. It checks the complete retained bundle after rejected calls and deletes its temporary state after closing.
node --input-type=module <<'JS'
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Client } from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'rea-reference-check-'));
const bin = path.join(work, 'bin');
fs.mkdirSync(bin);
for (const name of ['npx', 'npm', 'bun']) {
  fs.writeFileSync(path.join(bin, name), '#!/bin/sh\nexit 97\n', { mode: 0o755 });
}
const env = {
  PATH: [bin, path.dirname(process.execPath), process.env.PATH].join(path.delimiter),
  REA_LOG_LEVEL: 'silent', NODE_DISABLE_COMPILE_CACHE: '1'
};
for (const key of ['HOME', 'XDG_CONFIG_HOME', 'XDG_CACHE_HOME',
                   'XDG_DATA_HOME', 'XDG_STATE_HOME', 'TMPDIR']) {
  env[key] = path.join(work, key.toLowerCase());
  fs.mkdirSync(env[key]);
}
const client = new Client({ name: 'reference-diagnostic-check', version: '1' });
const transport = new StdioClientTransport({ command: process.execPath,
  args: ['scripts/rea.mjs', 'mcp'], cwd: process.cwd(), env, stderr: 'pipe' });
let stderr = '';
transport.stderr?.on('data', chunk => { stderr += chunk; });
async function good(name, args = {}) {
  const r = await client.callTool({ name, arguments: args });
  assert.notEqual(r.isError, true, JSON.stringify(r));
  return r.structuredContent.result;
}
async function rejected(label, name, args) {
  const r = await client.callTool({ name, arguments: args });
  assert.equal(r.isError, true);
  assert.equal(r.structuredContent, undefined);
  const error = JSON.parse(r.content.find(c => c.type === 'text').text).error;
  assert.equal(error.code, 'evidence_integrity_mismatch');
  assert.deepEqual(error.details, { reason: 'integrity' });
  console.log(label, JSON.stringify({ isError: r.isError, error }));
}
const fields = { severity: 'medium', required_authority: 'controlled-replay',
  required_confidence: 'observed', required_environment: null,
  recommended_probes: [], relationships: [],
  supporting_evidence_ids: [], contradicting_evidence_ids: [] };
try {
  await client.connect(transport);
  const missing = { ...fields, question: 'Can the absent reference be diagnosed?',
    domain: 'missing-reference', supporting_evidence_ids: ['ev_' + '0'.repeat(64)] };
  const empty = await good('get_evidence_bundle');
  for (let attempt = 1; attempt <= 2; attempt++) {
    await rejected('missing-evidence-' + attempt, 'record_unknown', missing);
    assert.deepEqual(await good('get_evidence_bundle'), empty);
    assert.equal((await good('list_unknowns')).total, 0);
  }
  await rejected('missing-relationship', 'record_unknown', { ...fields,
    question: 'Does the related unknown exist?', domain: 'missing-related',
    relationships: [{ type: 'related-to', unknown_id: 'unk_' + '0'.repeat(64) }] });
  assert.deepEqual(await good('get_evidence_bundle'), empty);
  const head = await good('record_unknown', { ...fields,
    question: 'Is the claim supported?', domain: 'resolution-check' });
  const id = head.mutation_evidence_ids[0];
  const before = await good('get_evidence_bundle');
  const update = { ...fields, unknown_id: head.unknown_id, expected_revision: 1,
    status: 'resolved', supporting_evidence_ids: [id], resolution: {
      disposition: 'verified', rationale: 'A mutation alone cannot verify the claim.',
      evidence_ids: [id] } };
  await rejected('unqualified-resolution', 'update_unknown', update);
  assert.deepEqual(await good('get_evidence_bundle'), before);
  assert.equal((await good('list_unknowns')).items[0].revision, 1);
  const corrected = await good('record_unknown', {
    ...missing, supporting_evidence_ids: [id] });
  assert.equal(corrected.revision, 1);
  const withdrawn = await good('update_unknown', { ...update, resolution: {
    disposition: 'withdrawn', rationale: 'Withdraw the unsupported claim.', evidence_ids: [] } });
  assert.equal(withdrawn.revision, 2);
  console.log('controls: rejected calls unchanged; corrected reference accepted; withdrawal at revision 2');
} finally {
  await client.close();
  await transport.close();
  fs.rmSync(work, { recursive: true, force: true });
}
assert.equal(stderr, '');
JS

Two independent stdio sessions reproduced all three input cases. The exact reduced recipe above completed once; an earlier independent client also submitted the absent-Evidence case twice. That makes four matching absent-Evidence refusals, two missing-relationship refusals, and two unqualified-resolution refusals. Both sessions passed their correction and unchanged-state assertions.

Expected behavior

Keep rejecting references absent from the session and truth claims without qualifying Evidence. Preserve the actual missing reference or failed graph constraint in the canonical diagnostic, and give recovery that addresses that cause. Refreshing an unchanged ledger and retrying unchanged input cannot create a missing record or qualify mutation Evidence as truth proof.

The advertised creation contract explicitly validates Evidence and relationship references. The repository's error-handling invariant requires meaningful failure reasons, and its MCP recovery contract directs callers to correct invalid input rather than repeat it unchanged. A truthful error code, details structure, and exact wording remain implementation choices; this report does not require the unknown registry to adopt another workflow's missing-reference code.

Impact and scope

Supported P2: clients cannot identify the rejected reference from this error, and its sole remedy describes a state race instead of the deterministic input problem. With several candidate references, the missing-ID context needed for correction is lost. The bounded failure reproduced through production stdio; no data loss or committed partial mutation was observed. retryable is already false, and no downstream automated retry loop was measured.

A demonstrated way to proceed is to correct the request: replacing the absent supporting ID with an existing record was accepted. The successful withdrawn-resolution control asserts no verified truth and is not a substitute for obtaining qualifying proof. Unknown relationships, schema rejection, and Evidence authentication must remain enforced.

HTTP, CLI parity, other platforms, and all other graph constraints were not executed. No last-known-good release or regression range was established.

REA version

Exact node scripts/rea.mjs --version output:

6.8.0

Source baseline: 65f1c447344a423fa2785847dbcea1e9a9e2454a.

Runtime and platform

Node.js v24.22.1; macOS 27.0.1; arm64. MCP client and stdio transport came from the checkout's locked dependencies. The prepared source build was used without rebuilding or reinstalling dependencies.

Target and artifact context

Only synthetic session-local residual unknowns and their immutable mutation Evidence. The absent IDs use the accepted prefix/digest shape with 64 zeroes. No binary is opened and no target code executes.

Interface

MCP

Earliest failure stage

Transport or result projection

Relevant output or evidence

Each rejected call returned isError: true, omitted success structuredContent, and supplied this complete decoded diagnostic in text content:

{"error":{"code":"evidence_integrity_mismatch","category":"execution_failure","message":"Evidence state changed before the update completed. Refresh the current state and try again.","retryable":false,"remediation":{"action":"Evidence state changed before the update completed. Refresh the current state and try again."},"details":{"reason":"integrity"}}}

The reduced recipe printed this after its assertions passed:

controls: rejected calls unchanged; corrected reference accepted; withdrawal at revision 2

Source locators at the baseline:

  • src/contracts/sessionToolContracts.ts:151-160 advertises reference validation and exact-revision updates; the first independent session confirmed the creation description through live tools/list.
  • AGENTS.md, Architecture and Cleanup, requires meaningful failure reasons to survive error boundaries. docs/mcp-contracts.md:345-350 says invalid input should be corrected, not retried unchanged.
  • src/domain/evidenceBundle.ts:484-496 detects the missing references and constructs messages containing the absent IDs. Lines 441-461 require qualifying non-mutation Evidence for a verified resolution.
  • src/application/investigation/EvidenceLedger.ts:431-443 catches that graph-validation failure and returns an unknown-registry integrity error before appending state.
  • src/domain/analysisErrorProjection.ts:245-252,408 emits the integrity code and retains only its generic reason. src/domain/analysisErrorPresentation.ts:330-331 supplies the state-change/update message.

The source explanation is separate from the executed MCP observations. No repair was implemented or tested.

Typed outcome or recovery expected

A nonretryable refusal that names the absent Evidence reference or failed qualification constraint and tells the caller to correct the request or supply the required retained Evidence. Preserve atomic rejection. An unchanged retry could succeed only after some relevant input or ledger fact changes; a read-only refresh alone is insufficient.

Analysis or suspected cause

Source-derived: the graph validator already distinguishes missing Evidence, missing related unknowns, and insufficient resolution proof. The registry wraps all of them in the same integrity reason, then the public projection discards the cause's useful message. The presentation fallback associates that reason with update-state changes. This accounts for the identical output from the three executed cases without requiring any actual revision race.

Additional context

Duplicate searches covered open/closed issues, issue comments and open/closed/merged PRs. Closely related prior work:

  • Closed #6106 and merged PR #6302 correct schema-level contradictory unknown input. Their discussion explicitly leaves integrity errors and the remaining registry wording unchanged. This is the graph-validation branch after schema admission.
  • Closed #6300 and merged PR #6321 correct malformed graph diagnostics in build_reconstruction_obligation_ledger. That separate service now recognizes the graph exception; unknown mutation diagnostics still reproduce here.
  • Closed #5396 reports duplicate creation incorrectly marked retryable, and includes an absent-relationship control showing this same integrity message. Its closure corrects already-exists; it does not correct missing supporting-reference diagnosis. Closed unmerged PR #5432 also has a review comment identifying absent reference details in proposed integrity guidance. These are overlapping prior observations, not a new claim that nobody has seen the shared symptom.
  • Open #3005 seeks successful valid updates; open #7603 concerns divergent-history imports. Open #7552 and #7553 concern oversized-result recovery. Their closure checks do not repair this small absent-reference mutation diagnostic.

The report retains rejection and asks for truthful cause/recovery on this remaining branch. It does not claim regression of the scoped closed fixes.

Triage assessment

  • Impact level: P2
  • Assessment status: supported
  • Impact basis: Repeated target-free MCP calls lose the specific failed reference/qualification reason and recommend an ineffective refresh/retry, while accepted state remains intact.
  • Workaround status: available
  • Workaround basis: Replacing the absent supporting ID with an existing retained Evidence ID succeeded in both sessions; request correction avoids the false retry instruction. No verified-resolution claim was accepted using insufficient proof.
Originally created by @coygeek on GitHub (Oct 28, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/7675 ### 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 MCP or agent integration ### Observed behavior In a fresh target-free stdio MCP connection, `record_unknown` with `supporting_evidence_ids: ["ev_" + "0".repeat(64)]` rejects the absent reference, but reports that Evidence state changed before an update completed. Its remedy tells the caller to refresh state and try again. Neither the message nor its details identifies the missing ID or reference constraint. Repeating that request after reading the current empty ledger produces the identical failure. Both failures leave the complete Evidence bundle unchanged and `list_unknowns.total` at zero. There is no competing writer or completed mutation in this sequence. The same connection returns the same diagnostic for a relationship to an absent unknown and for a current-revision `update_unknown` claiming a verified resolution using only mutation Evidence. Those are related controls for the shared loss of failure context. Replacing the absent supporting reference with an existing record succeeds; withdrawing the unsupported claim also succeeds without changing the rejected update's expected revision. ### Reproduction 1. Use the source baseline below with its locked dependencies and prepared runtime build. Put Node.js 24.22.1 on PATH, then run the following from the checkout root. It needs no native target, provider, network service, or downloaded package. 2. The script starts the public stdio entry point with disposable HOME/XDG/temp directories and fixture npx/npm/bun executables first on PATH. It checks the complete retained bundle after rejected calls and deletes its temporary state after closing. ```sh node --input-type=module <<'JS' import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { Client } from '@modelcontextprotocol/client'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; const work = fs.mkdtempSync(path.join(os.tmpdir(), 'rea-reference-check-')); const bin = path.join(work, 'bin'); fs.mkdirSync(bin); for (const name of ['npx', 'npm', 'bun']) { fs.writeFileSync(path.join(bin, name), '#!/bin/sh\nexit 97\n', { mode: 0o755 }); } const env = { PATH: [bin, path.dirname(process.execPath), process.env.PATH].join(path.delimiter), REA_LOG_LEVEL: 'silent', NODE_DISABLE_COMPILE_CACHE: '1' }; for (const key of ['HOME', 'XDG_CONFIG_HOME', 'XDG_CACHE_HOME', 'XDG_DATA_HOME', 'XDG_STATE_HOME', 'TMPDIR']) { env[key] = path.join(work, key.toLowerCase()); fs.mkdirSync(env[key]); } const client = new Client({ name: 'reference-diagnostic-check', version: '1' }); const transport = new StdioClientTransport({ command: process.execPath, args: ['scripts/rea.mjs', 'mcp'], cwd: process.cwd(), env, stderr: 'pipe' }); let stderr = ''; transport.stderr?.on('data', chunk => { stderr += chunk; }); async function good(name, args = {}) { const r = await client.callTool({ name, arguments: args }); assert.notEqual(r.isError, true, JSON.stringify(r)); return r.structuredContent.result; } async function rejected(label, name, args) { const r = await client.callTool({ name, arguments: args }); assert.equal(r.isError, true); assert.equal(r.structuredContent, undefined); const error = JSON.parse(r.content.find(c => c.type === 'text').text).error; assert.equal(error.code, 'evidence_integrity_mismatch'); assert.deepEqual(error.details, { reason: 'integrity' }); console.log(label, JSON.stringify({ isError: r.isError, error })); } const fields = { severity: 'medium', required_authority: 'controlled-replay', required_confidence: 'observed', required_environment: null, recommended_probes: [], relationships: [], supporting_evidence_ids: [], contradicting_evidence_ids: [] }; try { await client.connect(transport); const missing = { ...fields, question: 'Can the absent reference be diagnosed?', domain: 'missing-reference', supporting_evidence_ids: ['ev_' + '0'.repeat(64)] }; const empty = await good('get_evidence_bundle'); for (let attempt = 1; attempt <= 2; attempt++) { await rejected('missing-evidence-' + attempt, 'record_unknown', missing); assert.deepEqual(await good('get_evidence_bundle'), empty); assert.equal((await good('list_unknowns')).total, 0); } await rejected('missing-relationship', 'record_unknown', { ...fields, question: 'Does the related unknown exist?', domain: 'missing-related', relationships: [{ type: 'related-to', unknown_id: 'unk_' + '0'.repeat(64) }] }); assert.deepEqual(await good('get_evidence_bundle'), empty); const head = await good('record_unknown', { ...fields, question: 'Is the claim supported?', domain: 'resolution-check' }); const id = head.mutation_evidence_ids[0]; const before = await good('get_evidence_bundle'); const update = { ...fields, unknown_id: head.unknown_id, expected_revision: 1, status: 'resolved', supporting_evidence_ids: [id], resolution: { disposition: 'verified', rationale: 'A mutation alone cannot verify the claim.', evidence_ids: [id] } }; await rejected('unqualified-resolution', 'update_unknown', update); assert.deepEqual(await good('get_evidence_bundle'), before); assert.equal((await good('list_unknowns')).items[0].revision, 1); const corrected = await good('record_unknown', { ...missing, supporting_evidence_ids: [id] }); assert.equal(corrected.revision, 1); const withdrawn = await good('update_unknown', { ...update, resolution: { disposition: 'withdrawn', rationale: 'Withdraw the unsupported claim.', evidence_ids: [] } }); assert.equal(withdrawn.revision, 2); console.log('controls: rejected calls unchanged; corrected reference accepted; withdrawal at revision 2'); } finally { await client.close(); await transport.close(); fs.rmSync(work, { recursive: true, force: true }); } assert.equal(stderr, ''); JS ``` Two independent stdio sessions reproduced all three input cases. The exact reduced recipe above completed once; an earlier independent client also submitted the absent-Evidence case twice. That makes four matching absent-Evidence refusals, two missing-relationship refusals, and two unqualified-resolution refusals. Both sessions passed their correction and unchanged-state assertions. ### Expected behavior Keep rejecting references absent from the session and truth claims without qualifying Evidence. Preserve the actual missing reference or failed graph constraint in the canonical diagnostic, and give recovery that addresses that cause. Refreshing an unchanged ledger and retrying unchanged input cannot create a missing record or qualify mutation Evidence as truth proof. The advertised creation contract explicitly validates Evidence and relationship references. The repository's error-handling invariant requires meaningful failure reasons, and its MCP recovery contract directs callers to correct invalid input rather than repeat it unchanged. A truthful error code, details structure, and exact wording remain implementation choices; this report does not require the unknown registry to adopt another workflow's missing-reference code. ### Impact and scope Supported P2: clients cannot identify the rejected reference from this error, and its sole remedy describes a state race instead of the deterministic input problem. With several candidate references, the missing-ID context needed for correction is lost. The bounded failure reproduced through production stdio; no data loss or committed partial mutation was observed. `retryable` is already false, and no downstream automated retry loop was measured. A demonstrated way to proceed is to correct the request: replacing the absent supporting ID with an existing record was accepted. The successful withdrawn-resolution control asserts no verified truth and is not a substitute for obtaining qualifying proof. Unknown relationships, schema rejection, and Evidence authentication must remain enforced. HTTP, CLI parity, other platforms, and all other graph constraints were not executed. No last-known-good release or regression range was established. ### REA version Exact `node scripts/rea.mjs --version` output: ```text 6.8.0 ``` Source baseline: `65f1c447344a423fa2785847dbcea1e9a9e2454a`. ### Runtime and platform Node.js v24.22.1; macOS 27.0.1; arm64. MCP client and stdio transport came from the checkout's locked dependencies. The prepared source build was used without rebuilding or reinstalling dependencies. ### Target and artifact context Only synthetic session-local residual unknowns and their immutable mutation Evidence. The absent IDs use the accepted prefix/digest shape with 64 zeroes. No binary is opened and no target code executes. ### Interface MCP ### Earliest failure stage Transport or result projection ### Relevant output or evidence Each rejected call returned `isError: true`, omitted success `structuredContent`, and supplied this complete decoded diagnostic in text content: ```json {"error":{"code":"evidence_integrity_mismatch","category":"execution_failure","message":"Evidence state changed before the update completed. Refresh the current state and try again.","retryable":false,"remediation":{"action":"Evidence state changed before the update completed. Refresh the current state and try again."},"details":{"reason":"integrity"}}} ``` The reduced recipe printed this after its assertions passed: ```text controls: rejected calls unchanged; corrected reference accepted; withdrawal at revision 2 ``` Source locators at the baseline: - `src/contracts/sessionToolContracts.ts:151-160` advertises reference validation and exact-revision updates; the first independent session confirmed the creation description through live `tools/list`. - `AGENTS.md`, Architecture and Cleanup, requires meaningful failure reasons to survive error boundaries. `docs/mcp-contracts.md:345-350` says invalid input should be corrected, not retried unchanged. - `src/domain/evidenceBundle.ts:484-496` detects the missing references and constructs messages containing the absent IDs. Lines 441-461 require qualifying non-mutation Evidence for a verified resolution. - `src/application/investigation/EvidenceLedger.ts:431-443` catches that graph-validation failure and returns an unknown-registry integrity error before appending state. - `src/domain/analysisErrorProjection.ts:245-252,408` emits the integrity code and retains only its generic reason. `src/domain/analysisErrorPresentation.ts:330-331` supplies the state-change/update message. The source explanation is separate from the executed MCP observations. No repair was implemented or tested. ### Typed outcome or recovery expected A nonretryable refusal that names the absent Evidence reference or failed qualification constraint and tells the caller to correct the request or supply the required retained Evidence. Preserve atomic rejection. An unchanged retry could succeed only after some relevant input or ledger fact changes; a read-only refresh alone is insufficient. ### Analysis or suspected cause Source-derived: the graph validator already distinguishes missing Evidence, missing related unknowns, and insufficient resolution proof. The registry wraps all of them in the same integrity reason, then the public projection discards the cause's useful message. The presentation fallback associates that reason with update-state changes. This accounts for the identical output from the three executed cases without requiring any actual revision race. ### Additional context Duplicate searches covered open/closed issues, issue comments and open/closed/merged PRs. Closely related prior work: - Closed [#6106](https://github.com/morluto/rea/issues/6106) and merged [PR #6302](https://github.com/morluto/rea/pull/6302) correct schema-level contradictory unknown input. Their discussion explicitly leaves integrity errors and the remaining registry wording unchanged. This is the graph-validation branch after schema admission. - Closed [#6300](https://github.com/morluto/rea/issues/6300) and merged [PR #6321](https://github.com/morluto/rea/pull/6321) correct malformed graph diagnostics in `build_reconstruction_obligation_ledger`. That separate service now recognizes the graph exception; unknown mutation diagnostics still reproduce here. - Closed [#5396](https://github.com/morluto/rea/issues/5396) reports duplicate creation incorrectly marked retryable, and includes an absent-relationship control showing this same integrity message. Its closure corrects `already-exists`; it does not correct missing supporting-reference diagnosis. Closed unmerged [PR #5432](https://github.com/morluto/rea/pull/5432) also has a review comment identifying absent reference details in proposed integrity guidance. These are overlapping prior observations, not a new claim that nobody has seen the shared symptom. - Open [#3005](https://github.com/morluto/rea/issues/3005) seeks successful valid updates; open [#7603](https://github.com/morluto/rea/issues/7603) concerns divergent-history imports. Open [#7552](https://github.com/morluto/rea/issues/7552) and [#7553](https://github.com/morluto/rea/issues/7553) concern oversized-result recovery. Their closure checks do not repair this small absent-reference mutation diagnostic. The report retains rejection and asks for truthful cause/recovery on this remaining branch. It does not claim regression of the scoped closed fixes. ## Triage assessment - Impact level: P2 - Assessment status: supported - Impact basis: Repeated target-free MCP calls lose the specific failed reference/qualification reason and recommend an ineffective refresh/retry, while accepted state remains intact. - Workaround status: available - Workaround basis: Replacing the absent supporting ID with an existing retained Evidence ID succeeded in both sessions; request correction avoids the false retry instruction. No verified-resolution claim was accepted using insufficient proof.
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#4597
No description provided.