diff --git a/lib/graph/apply-proposal.js b/lib/graph/apply-proposal.js
index 1d0a233..c92ca83 100644
--- a/lib/graph/apply-proposal.js
+++ b/lib/graph/apply-proposal.js
@@ -87,6 +87,45 @@ function validateAddedUnknowns(graph, proposal) {
.filter((node) => node.kind !== "unknown")
.map((node) => node.id),
]);
+ const proposalNodeById = buildNodeById(graph, proposal.addedNodes);
+
+ function hasExplicitNodeReference(fromNode, toNodeId) {
+ if (!fromNode || !toNodeId) return false;
+
+ return (
+ fromNode.parentId === toNodeId ||
+ fromNode.dependsOn.includes(toNodeId) ||
+ fromNode.affects.includes(toNodeId) ||
+ fromNode.childIds.includes(toNodeId)
+ );
+ }
+
+ function hasExplicitAnswerDerivedRelationship(unknownNode) {
+ const connectedEdge = proposal.addedEdges.find(
+ (edge) =>
+ (edge.fromNodeId === unknownNode.id &&
+ answerDerivedNodeIds.has(edge.toNodeId)) ||
+ (edge.toNodeId === unknownNode.id &&
+ answerDerivedNodeIds.has(edge.fromNodeId)),
+ );
+
+ if (connectedEdge) {
+ return true;
+ }
+
+ for (const answerDerivedNodeId of answerDerivedNodeIds) {
+ const answerDerivedNode = proposalNodeById.get(answerDerivedNodeId);
+
+ if (
+ hasExplicitNodeReference(unknownNode, answerDerivedNodeId) ||
+ hasExplicitNodeReference(answerDerivedNode, unknownNode.id)
+ ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
for (const unknownNode of addedUnknowns) {
const meaningKeys = [
@@ -128,15 +167,7 @@ function validateAddedUnknowns(graph, proposal) {
);
}
- const connectedEdge = proposal.addedEdges.find(
- (edge) =>
- (edge.fromNodeId === unknownNode.id &&
- answerDerivedNodeIds.has(edge.toNodeId)) ||
- (edge.toNodeId === unknownNode.id &&
- answerDerivedNodeIds.has(edge.fromNodeId)),
- );
-
- if (!connectedEdge) {
+ if (!hasExplicitAnswerDerivedRelationship(unknownNode)) {
errors.push(
`New unknown must be explicitly related to an answer-derived node: "${unknownNode.id}"`,
);
diff --git a/scripts/reproduce-commercial-value-update.mjs b/scripts/reproduce-commercial-value-update.mjs
new file mode 100644
index 0000000..d789f05
--- /dev/null
+++ b/scripts/reproduce-commercial-value-update.mjs
@@ -0,0 +1,97 @@
+import { mkdir, writeFile } from "node:fs/promises";
+
+const BASE_URL =
+ process.env.CONFIDENCE_ENGINE_BASE_URL || "http://127.0.0.1:3000";
+const OUTPUT_DIR = "tests-results/commercial-value-update";
+
+const scenario = "I think therefore I am";
+const answer =
+ "Deciding whether to build the Confidence Engine due to uncertainty about its commercial value.";
+
+async function postJson(path, body) {
+ const response = await fetch(`${BASE_URL}${path}`, {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ },
+ body: JSON.stringify(body),
+ });
+
+ const json = await response.json();
+ return { status: response.status, json };
+}
+
+function printLine(label, value) {
+ const rendered = value === undefined ? null : value;
+ console.log(`${label}: ${JSON.stringify(rendered)}`);
+}
+
+async function main() {
+ await mkdir(OUTPUT_DIR, { recursive: true });
+
+ const startResult = await postJson("/api/cases/start", { scenario });
+ await writeFile(
+ `${OUTPUT_DIR}/start-response.json`,
+ JSON.stringify(startResult, null, 2),
+ );
+
+ const selectedQuestion = startResult.json?.selectedQuestion?.question || null;
+
+ let updateResult = {
+ status: null,
+ json: {
+ success: false,
+ stage: "request_construction",
+ errors: ["Missing selected question from start response"],
+ },
+ };
+
+ if (startResult.json?.success && selectedQuestion) {
+ updateResult = await postJson("/api/cases/update", {
+ situationGraph: startResult.json.situationGraph,
+ previousQuestion: selectedQuestion,
+ answer,
+ });
+ }
+
+ await writeFile(
+ `${OUTPUT_DIR}/update-response.json`,
+ JSON.stringify(updateResult, null, 2),
+ );
+
+ printLine("start success", startResult.json?.success ?? false);
+ printLine("update success", updateResult.json?.success ?? false);
+ printLine("update stage", updateResult.json?.stage ?? null);
+ printLine(
+ "proposal added nodes",
+ updateResult.json?.proposal?.addedNodes?.map((node) => node.id) ?? null,
+ );
+ printLine(
+ "proposal added edges",
+ updateResult.json?.proposal?.addedEdges?.map((edge) => ({
+ id: edge.id,
+ fromNodeId: edge.fromNodeId,
+ toNodeId: edge.toNodeId,
+ relationship: edge.relationship,
+ })) ?? null,
+ );
+ printLine(
+ "proposal resolved unknown IDs",
+ updateResult.json?.proposal?.resolvedUnknownNodeIds ??
+ updateResult.json?.resolvedUnknownNodeIds ??
+ null,
+ );
+ printLine(
+ "errors",
+ updateResult.json?.errors ??
+ updateResult.json?.proposalErrors ??
+ updateResult.json?.graphValidationErrors ??
+ updateResult.json?.validationErrors ??
+ null,
+ );
+}
+
+main().catch((error) => {
+ console.error(error instanceof Error ? error.message : String(error));
+ process.exitCode = 1;
+});
diff --git a/tests/graph/apply-proposal.test.js b/tests/graph/apply-proposal.test.js
index bc68e5f..f43fe1a 100644
--- a/tests/graph/apply-proposal.test.js
+++ b/tests/graph/apply-proposal.test.js
@@ -623,6 +623,208 @@ describe("applyValidatedProposal", () => {
);
});
+ it("accepts a newly added unknown explicitly linked through answer-derived node fields", () => {
+ const { graph, ids } = makeApplicationFixture();
+
+ const proposal = {
+ addedNodes: [
+ makeNode({
+ id: "n-answer-context",
+ label: "Build Confidence Engine decision",
+ description: "Decision context introduced by the answer.",
+ kind: "state",
+ status: "supported",
+ confidence: "medium",
+ childIds: ["n-commercial-value"],
+ }),
+ makeNode({
+ id: "n-commercial-value",
+ label: "Commercial value definition",
+ description:
+ "Need commercial value definition because the decision depends on it.",
+ kind: "unknown",
+ status: "unknown",
+ confidence: "high",
+ dependsOn: ["n-answer-context"],
+ }),
+ ],
+ updatedNodes: [
+ {
+ nodeId: ids.complaintRateUnknown,
+ previousStatus: "unknown",
+ newStatus: "resolved",
+ previousValue: null,
+ newValue: "Decision whether to build Confidence Engine",
+ reason: "The answer resolves the original context unknown.",
+ },
+ ],
+ addedEdges: [],
+ removedEdgeIds: [],
+ resolvedUnknownNodeIds: [ids.complaintRateUnknown],
+ affectedNodeIds: [],
+ selectedQuestion: {
+ nodeId: "n-commercial-value",
+ question: "How should commercial value be defined for this decision?",
+ reason: "A consequential unknown remains unresolved.",
+ },
+ };
+
+ const result = applyValidatedProposal({ situationGraph: graph, proposal });
+
+ expect(result.success).toBe(true);
+ expect(result.selectedQuestion?.nodeId).toBe("n-commercial-value");
+ });
+
+ it("rejects a newly added unknown linked only to the original unresolved node when that node is not answer-derived", () => {
+ const { graph, ids } = makeApplicationFixture();
+
+ const result = applyValidatedProposal({
+ situationGraph: graph,
+ proposal: {
+ addedNodes: [
+ makeNode({
+ id: "n-commercial-value",
+ label: "Commercial value definition",
+ description:
+ "Need commercial value definition because the decision depends on it.",
+ kind: "unknown",
+ status: "unknown",
+ confidence: "high",
+ dependsOn: [ids.complaintRateUnknown],
+ }),
+ ],
+ updatedNodes: [],
+ addedEdges: [
+ makeEdge({
+ id: "e-legacy-unknown-commercial-value",
+ fromNodeId: ids.complaintRateUnknown,
+ toNodeId: "n-commercial-value",
+ relationship: "depends_on",
+ confidence: "medium",
+ description: "Links only to the original unresolved unknown.",
+ }),
+ ],
+ removedEdgeIds: [],
+ resolvedUnknownNodeIds: [],
+ affectedNodeIds: [],
+ selectedQuestion: {
+ nodeId: "n-commercial-value",
+ question: "How should commercial value be defined for this decision?",
+ reason: "A consequential unknown remains unresolved.",
+ },
+ },
+ });
+
+ expect(result.success).toBe(false);
+ expect(result.errors.join(" ")).toContain(
+ "explicitly related to an answer-derived node",
+ );
+ });
+
+ it("rejects a floating emergent unknown with no explicit relationship", () => {
+ const { graph } = makeApplicationFixture();
+
+ const result = applyValidatedProposal({
+ situationGraph: graph,
+ proposal: {
+ addedNodes: [
+ makeNode({
+ id: "n-floating",
+ label: "Floating unknown",
+ description: "Need this because it matters to the decision.",
+ kind: "unknown",
+ status: "unknown",
+ confidence: "medium",
+ }),
+ ],
+ updatedNodes: [],
+ addedEdges: [],
+ removedEdgeIds: [],
+ resolvedUnknownNodeIds: [],
+ affectedNodeIds: [],
+ selectedQuestion: {
+ nodeId: "n-floating",
+ question: "What would resolve Floating unknown?",
+ reason: "Test case for floating unknown rejection.",
+ },
+ },
+ });
+
+ expect(result.success).toBe(false);
+ expect(result.errors.join(" ")).toContain(
+ "explicitly related to an answer-derived node",
+ );
+ });
+
+ it("accepts the reported live-shaped commercial-value proposal when the linkage is explicit in node references", () => {
+ const { graph, ids } = makeApplicationFixture();
+
+ const proposal = {
+ addedNodes: [
+ makeNode({
+ id: "answer_context_build",
+ label: "Build Confidence Engine decision context",
+ description:
+ "The answer introduces a concrete decision about whether to build Confidence Engine.",
+ kind: "state",
+ status: "known",
+ confidence: "high",
+ dependsOn: [ids.complaintRateUnknown, "nu_commercial_val"],
+ childIds: ["nu_commercial_val"],
+ affects: ["nu_commercial_val"],
+ }),
+ makeNode({
+ id: "nu_commercial_val",
+ label: "Commercial viability assessment of Confidence Engine",
+ description:
+ "The commercial viability of Confidence Engine remains unknown because resolving it is needed to decide whether building it is justified.",
+ kind: "unknown",
+ status: "unknown",
+ confidence: "medium",
+ dependsOn: ["answer_context_build"],
+ childIds: ["answer_context_build"],
+ }),
+ ],
+ updatedNodes: [
+ {
+ nodeId: ids.complaintRateUnknown,
+ previousStatus: "unknown",
+ newStatus: "resolved",
+ previousValue: null,
+ newValue:
+ "Deciding whether to build the Confidence Engine due to uncertainty about its commercial value.",
+ reason: "The answer resolves the original context unknown.",
+ },
+ ],
+ addedEdges: [],
+ removedEdgeIds: [],
+ resolvedUnknownNodeIds: [ids.complaintRateUnknown],
+ affectedNodeIds: [
+ ids.complaintRateUnknown,
+ "answer_context_build",
+ "nu_commercial_val",
+ ],
+ selectedQuestion: {
+ nodeId: "nu_commercial_val",
+ question:
+ "How should commercial viability be defined for this decision?",
+ reason: "A foundational commercial-value unknown remains unresolved.",
+ },
+ };
+
+ const result = applyValidatedProposal({ situationGraph: graph, proposal });
+
+ expect(result.success).toBe(true);
+ expect(result.updatedSituationGraph.resolvedNodeIds).toContain(
+ ids.complaintRateUnknown,
+ );
+ expect(
+ result.updatedSituationGraph.nodes.some(
+ (node) => node.id === "nu_commercial_val",
+ ),
+ ).toBe(true);
+ });
+
it("rejects selected question referencing resolved node", () => {
const { graph, proposal, ids } = makeApplicationFixture();
diff --git a/tests/ui/scenario-form.test.jsx b/tests/ui/scenario-form.test.jsx
index f0f39fe..0a29573 100644
--- a/tests/ui/scenario-form.test.jsx
+++ b/tests/ui/scenario-form.test.jsx
@@ -438,6 +438,26 @@ describe("graph-backed UI rendering", () => {
expect(html).toContain("Commercial value definition");
});
+ it("successful update renders prior and new state together", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+
+ expect(html).toContain("Previous active unknown");
+ expect(html).toContain("Resolved unknowns");
+ expect(html).toContain("Newly surfaced unknowns");
+ expect(html).toContain("New active unknown");
+ expect(html).toContain("Next question");
+ expect(html).toContain(
+ "How should commercial value be defined for this decision?",
+ );
+ });
+
it("situation graph marks newly surfaced and active unknowns", () => {
const html = renderToStaticMarkup(
{
expect(html).toContain("bad proposal");
});
+ it("failed update does not fabricate history", () => {
+ const html = renderToStaticMarkup(
+ <>
+
+
+ >,
+ );
+
+ expect(html).toContain("Update error: Update case failed");
+ expect(html).toContain("nu_commercial_val");
+ expect(html).not.toContain("Previous active unknown");
+ expect(html).not.toContain("Resolved unknowns");
+ expect(html).not.toContain("Newly surfaced unknowns");
+ expect(html).not.toContain("New active unknown");
+ expect(html).not.toContain("Proposal details");
+ });
+
it("proposal details remain collapsible", () => {
const html = renderToStaticMarkup(
,