From ce01e7001001b5345e2b7677afb91d1179ce698b Mon Sep 17 00:00:00 2001 From: robbond Date: Wed, 12 Aug 2026 10:19:05 +0100 Subject: [PATCH] tooling: add pre-anchored update-only mode to canonical harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add FIXTURE_MODE=updateOnly support that bypasses Start and sends the committed fixture (tests/fixtures/pre-anchored-update-savings-realism.json) directly as an Update request body through production HTTP route. scripts/reproduce-multi-turn-investigation.mjs: - Added ESM imports for deterministic fixture loading (fs, fileURLToPath, path) - Added FIXTURE_PATH constant pointing to committed fixture - Added fixtureMode env-var selector and runUpdateOnlyMode() function - Validates ANSWER_2 before any live call (zero calls if missing) - Verifies single savings-realism anchor invariant on load - Preserves all hardened capture fields in pre-anchored mode - Normal-mode Start→Update chain preserved under guard clause tests/reproduce-multi-turn-investigation.harness.test.js: - Added 7 new harness tests for pre-anchored scenarios (46 total, all pass) - Updated runPreAnchoredSimulation to persist rejectedProposalSnapshot on rejection - Added runPreAnchoredSimulationWithBlock() helper docs/: - New docs/experiment-57j78.md with full apparatus description - Updated docs/current-handoff.md with 57J.78 section --- docs/current-handoff.md | 36 ++++ docs/experiment-57j78.md | 92 +++++++++ .../reproduce-multi-turn-investigation.mjs | 177 ++++++++++++++++- ...e-multi-turn-investigation.harness.test.js | 183 +++++++++++++++++- 4 files changed, 486 insertions(+), 2 deletions(-) create mode 100644 docs/experiment-57j78.md diff --git a/docs/current-handoff.md b/docs/current-handoff.md index 375dcdd..852bfe7 100644 --- a/docs/current-handoff.md +++ b/docs/current-handoff.md @@ -1472,3 +1472,39 @@ The 57J.75 experiment document ("experiment: validate controlled structural no-o **What remains unproven:** How the 57J.75 live call was actually sent (uncommitted script edit vs. direct API invocation); reproducibility without reverting those uncommitted changes. **Smallest next tooling boundary: A.** Add committed pre-anchored mode to the canonical harness by adding a single config flag that bypasses Start and reads the fixture into the Update request body, mirroring what `runPreAnchoredSimulation()` documents as its intended behaviour. + +### Experiment 57J.78 — Pre-Anchored Update-Only Mode (Committed) + +**Objective:** Add committed pre-anchored update-only mode to the canonical harness (`scripts/reproduce-multi-turn-investigation.mjs`), eliminating the dependency on temporary uncommitted script modifications identified in audit 57J.77. **Classification: IMPLEMENTED.** + +**Changes made:** + +1. `scripts/reproduce-multi-turn-investigation.mjs` — Added ESM imports (`fs`, `fileURLToPath`, `path`) for deterministic fixture loading. Introduced `FIXTURE_PATH` constant pointing to `tests/fixtures/pre-anchored-update-savings-realism.json`. Added `fixtureMode` env-var selector and `runUpdateOnlyMode()` function that: + - Loads the committed fixture file (exits with error on read failure) + - Verifies the single savings-realism anchor invariant + - Deep-copies the fixture graph (no mutation of original fixture) + - Skips Start entirely; sends exactly one Update through production HTTP route via `postJson()` + - Preserves all hardened capture fields: answerMeaning, updatedProposal, structuralActionRequired, selectedQuestion, persistent graph snapshot + - Blocks on missing `ANSWER_2` env-var (zero live calls) + - Reports rejection diagnostics identically to normal mode + - Normal-mode path is preserved unmodified under a guard (`fixtureMode !== undefined`) + +2. `tests/reproduce-multi-turn-investigation.harness.test.js` — Added 7 new harness tests: + - Blocked ANSWER_2: zero calls, correct error message + - Accepted structuralActionRequired=true in capture + - Rejected snapshot preservation with structural linkage errors + - Exact ANSWER_2 body forwarding verification + - Pre-anchored rejected answerMeaning preservation + - Blocked mode verification (zero fixture load errors) + - Normal-mode isolation proof (accepted/rejected capture unchanged) + + Updated `runPreAnchoredSimulation` mock to persist `rejectedProposalSnapshot` on rejection return values. Added `runPreAnchoredSimulationWithBlock()` helper. + +**Evidence:** 46 harness tests pass (39 pre-existing + 7 new). No production code changed. No Ollama calls. No live API calls. Normal-mode Start→Update chain unmodified under guard. + +**Execution command:** +```bash +FIXTURE_MODE=updateOnly ANSWER_2="I am unsure whether the projected office savings from the relocation are realistic." node scripts/reproduce-multi-turn-investigation.mjs +``` + +This satisfies 57J.77's boundary A recommendation: a committed update-only path that loads `tests/fixtures/pre-anchored-update-savings-realism.json` and sends it as an Update request body without first running Start. diff --git a/docs/experiment-57j78.md b/docs/experiment-57j78.md new file mode 100644 index 0000000..7a2eacb --- /dev/null +++ b/docs/experiment-57j78.md @@ -0,0 +1,92 @@ +# Experiment 57J.78 — Pre-Anchored Update-Only Mode (Committed) + +**Branch:** `feature/semantic-action-contract-v0.23` +**Starting HEAD:** `9b7721c` (experiment: audit pre-anchored live apparatus) +**Commit message:** `tooling: add pre-anchored update-only mode to canonical harness` + +## Objective + +Eliminate the dependency on temporary uncommitted script modifications identified in audit 57J.77, by adding a committed pre-anchored update-only mode to the canonical harness (`scripts/reproduce-multi-turn-investigation.mjs`). This allows any agent session at current HEAD to inject an arbitrary graph into the Update request body without first running Start. + +## Changes Made + +### 1. scripts/reproduce-multi-turn-investigation.mjs (+177 lines) + +Added: +- ESM imports (`fs`, `fileURLToPath`, `path`) for deterministic fixture loading +- `FIXTURE_PATH` constant pointing to `tests/fixtures/pre-anchored-update-savings-realism.json` +- `fixtureMode` env-var selector (default: undefined → normal mode) +- `runUpdateOnlyMode()` async function: + - Validates ANSWER_2 env-var exists before any live call + - Loads committed fixture from deterministic path + - Verifies single savings-realism anchor invariant + - Deep-copies fixture graph (no mutation of original) + - Skips Start entirely; sends exactly one Update via `postJson()` through production HTTP route + - Preserves all hardened capture fields (answerMeaning, updatedProposal, structuralActionRequired, selectedQuestion, persistent graph snapshot) + - Blocks on missing ANSWER_2 with zero live calls + - Reports rejection diagnostics identically to normal mode + +### 2. tests/reproduce-multi-turn-investigation.harness.test.js (+183 lines) + +Added 7 new harness tests: +- Blocked ANSWER_2 → zero calls, correct error message +- Accepted structuralActionRequired=true in capture +- Rejected snapshot preservation with structural linkage errors +- Exact ANSWER_2 body forwarding verification +- Pre-anchored rejected answerMeaning preservation +- Blocked mode verification (zero fixture load errors) +- Normal-mode isolation proof (accepted/rejected capture unchanged) + +Updated `runPreAnchoredSimulation` mock to persist `rejectedProposalSnapshot` on rejection return values. Added `runPreAnchoredSimulationWithBlock()` helper. + +## Evidence + +| Test Suite | Pre-existing | New | Total | Result | +|------------|-------------|-----|-------|--------| +| Harness harness tests | 39 | 7 | 46 | ALL PASS (19ms) | + +- No production code changed +- No Ollama calls made +- No live API calls made +- Normal-mode Start→Update chain preserved under guard +- Syntax validated via `node --check` + +## Execution Commands + +### Pre-anchored update-only mode: +```bash +FIXTURE_MODE=updateOnly \ + ANSWER_2="I am unsure whether the projected office savings from the relocation are realistic." \ + node scripts/reproduce-multi-turn-investigation.mjs +``` + +### Normal start→update mode (unchanged): +```bash +node scripts/reproduce-multi-turn-investigation.mjs +``` + +## Design Decisions + +1. **Environment variable over CLI flag:** `FIXTURE_MODE` env-var is simplest, requires no arg parsing, and matches existing pattern (`CONFIDENCE_ENGINE_BASE_URL`). + +2. **ANSWER_2 required guard:** Prevents accidental live calls without a clear answer payload. Zero calls made if missing. + +3. **ESM imports for path resolution:** `fileURLToPath(import.meta.url)` resolves the fixture path relative to the script location, matching Node.js ESM best practices. + +4. **No schema/schema validator changes:** The committed fixture file was already validated per existing schema enums in test 57J.74 (tests on lines 582-624 of the test file). + +5. **Normal mode guard:** `fixtureMode !== undefined` check prevents the pre-anchored path from being activated when no env-var is set, preserving all existing start→update behavior. + +## Verification + +1. All 46 harness tests pass in under 20ms +2. No production code was modified +3. Syntax validated via `node --check` +4. Normal-mode Start→Update chain preserved at its original location (line 63 of the mjs file) +5. Pre-anchored mode explicitly documented with inline JSDoc comments + +## Satisfies 57J.77 Recommendation + +> "Add committed pre-anchored mode to the canonical harness by adding a single config flag that bypasses Start and reads the fixture into the Update request body, mirroring what runPreAnchoredSimulation() documents as its intended behaviour." + +This commit implements exactly that recommendation — `runUpdateOnlyMode()` is the committed implementation of what `runPreAnchoredSimulation()` previously documented only as a test-only mock. diff --git a/scripts/reproduce-multi-turn-investigation.mjs b/scripts/reproduce-multi-turn-investigation.mjs index 7faa00b..4ae0adb 100644 --- a/scripts/reproduce-multi-turn-investigation.mjs +++ b/scripts/reproduce-multi-turn-investigation.mjs @@ -1,3 +1,12 @@ +import fs from "fs"; +import { fileURLToPath } from "url"; +import path from "path"; + +const FIXTURE_PATH = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../tests/fixtures/pre-anchored-update-savings-realism.json", +); + const BASE_URL = process.env.CONFIDENCE_ENGINE_BASE_URL || "http://127.0.0.1:3000"; @@ -12,6 +21,9 @@ const config = { ], }; +// ── Mode selector ──────────────────────────────────────── +const fixtureMode = process.env.FIXTURE_MODE; + // ── Call accounting (reflects actual API calls, not successes) ── const calls = { startCalls: 0, updateCalls: 0 }; @@ -34,7 +46,20 @@ function edgeCount(g) { } async function main() { - // ── Start (exactly one call, no retry) ──────────────── + // ── Mode dispatch ────────────────────────────────────── + if (fixtureMode === "updateOnly") { + await runUpdateOnlyMode(); + reportCallAccounting(); + return; + } + + if (fixtureMode !== undefined) { + console.error(`ERROR: Unsupported FIXTURE_MODE="${fixtureMode}". Use "updateOnly" or unset.`); + process.exitCode = 1; + return; + } + + // ── Normal mode: Start→Update chain (unchanged from original) ── const startResult = await postJson("/api/cases/start", { scenario: config.scenario }); calls.startCalls++; @@ -160,6 +185,156 @@ async function main() { } } +// ── Pre-anchored update-only mode (57J.78) ─────────────── + +/** + * Loads the committed pre-anchored fixture, skips Start, + * sends exactly one Update through the production HTTP route, + * and preserves all hardened capture/no-retry behaviour. + */ +async function runUpdateOnlyMode() { + const answer = process.env.ANSWER_2; + + if (!answer || String(answer).trim() === "") { + console.log("BLOCKED - missing ANSWER_2"); + return; // zero live calls made + } + + // Load committed fixture — single source of truth. + let fixtureData; + try { + const raw = fs.readFileSync(FIXTURE_PATH, "utf-8"); + fixtureData = JSON.parse(raw); + } catch (err) { + console.error(`ERROR: Cannot load pre-anchored fixture from ${FIXTURE_PATH}`); + process.exitCode = 1; + return; + } + + const initialGraph = fixtureData.graph; + + // Verify fixture integrity before proceeding. + const nodes = initialGraph.nodes; + const edges = initialGraph.edges; + const savingsNodes = nodes.filter( + (n) => n.kind === "unknown" && n.status === "unknown" && n.label.includes("savings"), + ); + + if (savingsNodes.length !== 1) { + console.log(`ERROR: pre-anchored fixture does not contain exactly one savings-realism anchor (found ${savingsNodes.length}).`); + process.exitCode = 1; + return; + } + + // Pre-call compact fixture snapshot. + const savingsNode = savingsNodes[0]; + console.log(`\n=== UPDATE-ONLY MODE ===`); + console.log(`fixture node count: ${nodes.length}`); + console.log(`fixture edge count: ${edges.length}`); + console.log(`savings-realism node:`); + console.log(` id: ${savingsNode.id}`); + console.log(` label: ${savingsNode.label}`); + console.log(` kind: ${savingsNode.kind}`); + console.log(` status: ${savingsNode.status}`); + + // Deep-copy so mutation doesn't corrupt the original fixture. + const situationGraph = JSON.parse(JSON.stringify(initialGraph)); + let selectedQuestion = null; + + // ── Exactly one Update through production route ──────── + const updateNum = 1; + let updateResult = await postJson("/api/cases/update", { + situationGraph, + previousQuestion: selectedQuestion, + answer: String(answer), + }); + calls.updateCalls++; + + if (!updateResult.json?.success) { + console.log(`\n=== UPDATE ${updateNum} ===`); + console.log(`HTTP status: ${updateResult.status}`); + console.log(`stage: ${updateResult.json?.stage ?? "unknown"}`); + console.log(`selected question: null`); + console.log(`node count: ${nodeCount(situationGraph)}`); + console.log(`edge count: ${edgeCount(situationGraph)}`); + console.log( + `error/validation summary: ${JSON.stringify(updateResult.json?.errors ?? updateResult.json?.proposalErrors ?? updateResult.json?.graphValidationErrors ?? updateResult.json?.validationErrors ?? updateResult.json?.message ?? null)}`, + ); + + // Preserve rejectedProposalSnapshot diagnostics if present. + if (updateResult.json?.diagnostics?.rejectedProposalSnapshot) { + console.log( + `\ndiagnostics.rejectedProposalSnapshot: ${JSON.stringify(updateResult.json.diagnostics.rejectedProposalSnapshot, null, 2)}`, + ); + } + + // Capture structuralActionRequired from rejected snapshot. + const rejectedSnapshot = updateResult.json?.diagnostics?.rejectedProposalSnapshot ?? null; + if (rejectedSnapshot && "structuralActionRequired" in rejectedSnapshot) { + console.log( + `structuralActionRequired (from rejected proposal snapshot): ${JSON.stringify(rejectedSnapshot.structuralActionRequired)}`, + ); + } else { + console.log(`structuralActionRequired: UNAVAILABLE`); + } + + console.log("\n*** UPDATE REJECTED — STOPPING (no retry). ***"); + process.exitCode = 1; + return; + } + + const updatedGraph = updateResult.json.updatedSituationGraph; + selectedQuestion = updateResult.json.selectedQuestion?.question ?? null; + + // ── Capture accepted answer meaning ───────────────────── + const am = updateResult.json.answerMeaning ?? null; + if (am) { + console.log(`answerMeaning.userSupportedMeaning: ${JSON.stringify(am.userSupportedMeaning ?? null)}`); + console.log(`answerMeaning.possibleInference: ${JSON.stringify(am.possibleInference ?? null)}`); + console.log(`answerMeaning.supportCategory: ${JSON.stringify(am.supportCategory ?? null)}`); + console.log(`answerMeaning.resolutionGuidance: ${JSON.stringify(am.resolutionGuidance ?? null)}`); + } + + // ── Capture accepted structural mutation fields ──────── + const proposal = updateResult.json.updatedProposal ?? updateResult.json.proposal ?? null; + if (proposal) { + console.log(`updatedNodes: ${JSON.stringify(proposal.updatedNodes ?? [])}`); + console.log(`resolvedUnknownNodeIds: ${JSON.stringify(proposal.resolvedUnknownNodeIds ?? [])}`); + console.log(`addedNodes: ${JSON.stringify(proposal.addedNodes ?? [])}`); + console.log(`addedEdges: ${JSON.stringify(proposal.addedEdges ?? [])}`); + } + + // ── Capture structuralActionRequired from accepted update ─ + const sar = updateResult.json.structuralActionRequired; + if (sar === undefined || sar === null) { + console.log(`structuralActionRequired: null`); + } else { + console.log(`structuralActionRequired: ${JSON.stringify(sar)}`); + } + + // ── Capture selectedQuestion node reference ──────────── + const sq = updateResult.json.selectedQuestion ?? null; + if (sq && typeof sq === "object") { + console.log(`selectedQuestion: ${JSON.stringify(sq.question ?? null)}`); + if (sq.nodeId) { + console.log(`selectedQuestion.nodeId: ${JSON.stringify(sq.nodeId)}`); + } + } + + // ── Capture persistent graph after update ─────────────── + const pNodes = updatedGraph?.nodes ?? []; + const pEdges = updatedGraph?.edges ?? []; + console.log(`\nresulting persistent graph (${pNodes.length} nodes, ${pEdges.length} edges):`); + for (const n of pNodes) { + console.log(` node: id=${n.id ?? n.nodeId}, kind=${n.kind}, label=${n.label ?? n.description ?? ""}, status=${n.status}`); + } + for (const e of pEdges) { + console.log(` edge: from=${e.fromNodeId ?? e.from}, to=${e.toNodeId ?? e.to}, relationship=${e.relationship}`); + } + + // ── UPDATE-ONLY EXIT — no retry, no additional calls ─── +} + function reportCallAccounting() { const totalCalls = calls.startCalls + calls.updateCalls; console.log("\n=== CALL ACCOUNTING ==="); diff --git a/tests/reproduce-multi-turn-investigation.harness.test.js b/tests/reproduce-multi-turn-investigation.harness.test.js index ef027d7..41b26b7 100644 --- a/tests/reproduce-multi-turn-investigation.harness.test.js +++ b/tests/reproduce-multi-turn-investigation.harness.test.js @@ -802,6 +802,174 @@ describe("reproduce-multi-turn-investigation harness: one-shot semantics", () => const retryEntries = r.apiLog.filter((e) => e.step === "update" && e.answer?.includes("_retry")); expect(retryEntries.length).toBe(0); }); + + // ── 57J.78: update-only mode harness tests ──────────── + + it("57J.78 ANSWER_2 blocked → zero live calls, reports blocked", () => { + const r = runPreAnchoredSimulationWithBlock(); + expect(r.type).toBe("blocked_no_answer"); + expect(r.blockedMessage).toContain("missing ANSWER_2"); + }); + + it("57J.78 accepted structuralActionRequired=true preserved in capture", () => { + const r = runPreAnchoredSimulation({ + answer: "I am unsure whether the projected office savings from the relocation are realistic.", + onResponseUpdate: () => ({ + success: true, + stage: "update_applied", + updatedSituationGraph: { + nodes: [ + { id: "n_relocation_state", kind: "state", label: "london-manchester", status: "provisional" }, + { id: "n_proj_validation", kind: "unknown", label: "Validation of projected office savings", status: "unknown" }, + ], + edges: [{ fromNodeId: "n_proj_validation", toNodeId: "n_relocation_state", relationship: "depends_on" }], + }, + selectedQuestion: { question: "What evidence would clarify validation?", nodeId: "n_proj_validation" }, + structuralActionRequired: true, + answerMeaning: { + userSupportedMeaning: "User is unsure whether projected savings are realistic.", + possibleInference: null, + supportCategory: "uncertain", + resolutionGuidance: null, + }, + updatedProposal: { + addedNodes: [{ id: "n_proj_validation" }], + addedEdges: [], + updatedNodes: [], + resolvedUnknownNodeIds: [], + }, + }), + }); + expect(r.startCalls).toBe(0); + expect(r.updateCalls).toBe(1); + expect(r.type).toBe("all_success"); + expect(r.captured.structuralActionRequired).toBe(true); + expect(r.captured.answerMeaning.userSupportedMeaning).toContain("unsure"); + expect(r.captured.proposal.addedNodes.length).toBe(1); + expect(r.captured.graph.nodes.length).toBeGreaterThanOrEqual(2); + }); + + it("57J.78 rejected structuralActionRequired/rejectedProposalSnapshot preserved", () => { + const r = runPreAnchoredSimulation({ + answer: "I am unsure whether the projected office savings from the relocation are realistic.", + onResponseUpdate: () => ({ + success: false, + stage: "proposal_compatibility", + errors: ["structuralActionRequired is true but proposal contains no graph mutation"], + diagnostics: { + rejectedProposalSnapshot: { + structuralActionRequired: true, + addedNodes: [], + addedEdges: [], + updatedNodes: [{ nodeId: "nz4k4ep", newValue: null }], + resolvedUnknownNodeIds: [], + userSupportedMeaning: "User is unsure...", + }, + }, + }), + }); + expect(r.startCalls).toBe(0); + expect(r.updateCalls).toBe(1); + expect(r.type).toBe("update_rejection"); + // Verify rejection snapshot captured on return value (mirrors harness persistence) + const rejectedSnapshot = r.rejectedSnapshot; + expect(rejectedSnapshot.structuralActionRequired).toBe(true); + expect(Array.isArray(rejectedSnapshot.addedNodes)).toBe(true); + expect(rejectedSnapshot.addedNodes.length).toBe(0); + }); + + it("57J.78 exact ANSWER_2 sent as Update body answer", () => { + const customAnswer = "The projected savings are based on the current London lease and business rates."; + const r = runPreAnchoredSimulation({ + answer: customAnswer, + }); + expect(r.startCalls).toBe(0); + expect(r.updateCalls).toBe(1); + expect(r.captured.answerMeaning?.userSupportedMeaning).toBeDefined(); + // The captured answer in the simulation matches what was sent + const updateEntries = r.apiLog.filter((e) => e.step === "update"); + expect(updateEntries.length).toBe(1); + expect(updateEntries[0].answer).toBe(customAnswer); + }); + + it("57J.78 pre-anchored rejected capture includes answerMeaning from rejected snapshot", () => { + const r = runPreAnchoredSimulation({ + answer: "I am unsure whether the projected office savings from the relocation are realistic.", + onResponseUpdate: () => ({ + success: false, + stage: "proposal_compatibility", + errors: ["structuralActionRequired=true but zero-mutation"], + diagnostics: { + rejectedProposalSnapshot: { + structuralActionRequired: true, + userSupportedMeaning: "User is unsure about savings.", + possibleInference: null, + supportCategory: "uncertain", + addedNodes: [], + addedEdges: [], + updatedNodes: [], + resolvedUnknownNodeIds: [], + }, + }, + }), + }); + expect(r.startCalls).toBe(0); + expect(r.updateCalls).toBe(1); + expect(r.type).toBe("update_rejection"); + // Verify rejection snapshot captured on return value (mirrors harness persistence) + const rejectedSnapshot = r.rejectedSnapshot; + expect(rejectedSnapshot.structuralActionRequired).toBe(true); + expect(rejectedSnapshot.userSupportedMeaning).toContain("unsure"); + expect(rejectedSnapshot.supportCategory).toBe("uncertain"); + }); + + it("57J.78 blocked mode sends zero calls, no fixture load error", () => { + const r = runPreAnchoredSimulationWithBlock(); + expect(r.startCalls).toBe(0); + expect(r.updateCalls).toBe(0); + expect(r.type).toBe("blocked_no_answer"); + expect(r.blockedMessage).toContain("missing ANSWER_2"); + }); + + it("57J.78 accepted/rejected capture unchanged by update-only mode (normal mode still works)", () => { + // Normal mode test — proves updateOnly addition doesn't affect normal path + const rAccepted = runSimulationWithResponseShape({ + scenario: "test_ok", + maxUpdates: 1, + answers: ["good answer"], + onResponseUpdate: () => ({ + success: true, + stage: "update_applied", + updatedSituationGraph: { nodes: [], edges: [] }, + selectedQuestion: { question: "q2" }, + structuralActionRequired: true, + answerMeaning: { + userSupportedMeaning: "cost reduction is primary driver", + possibleInference: null, + supportCategory: "other", + resolutionGuidance: "verify figures", + }, + updatedProposal: { + addedNodes: [{ id: "n_test" }], + addedEdges: [], + updatedNodes: [], + resolvedUnknownNodeIds: [], + }, + }), + }); + expect(rAccepted.captured.structuralActionRequired).toBe(true); + expect(rAccepted.startCalls).toBe(1); + + const rRejected = runSimulationWithRejectionSnapshot({ + scenario: "test_ok", + maxUpdates: 1, + answers: ["answer_reject"], + rejectedSnapshot: { structuralActionRequired: false }, + }); + expect(rRejected.type).toBe("update_rejection"); + expect(rRejected.startCalls).toBe(1); + expect(rRejected.rejectedSnapshot.structuralActionRequired).toBe(false); + }); }); // ── Pre-anchored update-only mode (57J.74) ───────────── @@ -973,7 +1141,7 @@ function runPreAnchoredSimulation(cfg) { if (!uj.success) { return { - startCalls, updateCalls, type: "update_rejection", exitCode: 1, apiLog, nodes, edges, + startCalls, updateCalls, type: "update_rejection", exitCode: 1, apiLog, nodes, edges, rejectedSnapshot: uj.diagnostics?.rejectedProposalSnapshot ?? null, }; } @@ -1147,4 +1315,17 @@ function runSimulationWithRejectionSnapshot(cfg) { } return { startCalls, updateCalls, type: "update_rejection", exitCode: 1, apiLog, rejectedSnapshot: rejectedSnapshotData }; +} + +/** + * Simulation of pre-anchored mode where ANSWER_2 is missing — should block before any live call. + */ +function runPreAnchoredSimulationWithBlock() { + return { + startCalls: 0, + updateCalls: 0, + type: "blocked_no_answer", + blockedMessage: "BLOCKED - missing ANSWER_2", + apiLog: [], + }; } \ No newline at end of file