fix(reasoning): enforce confirmation-gated decision closure

- reconcileDecisionClosureOwnership normaliser between reconciliation and validation (Boundary B)
- Strips terminal parent updates without explicit user confirmation; preserves all other proposal work
- Strips parent from resolvedUnknownNodeIds bookkeeping on no-confirmation strip
- Restores reconciler-forced resolved→unknown for synthetic updates too
- Prevents hybrid unknown+value states by nulling newValue in all stripping paths
- No-op update created when reconciler synthesized the entry to prevent downstream errors

Prompt:
- Rule #143 rewritten from evidence-sufficiency to explicit-confirmation gate
- Directs model to use possibleInference for directional conclusions when confirmation absent

Regression preservation:
- 60B.43 lifecycle invariant restored via explicit confirmation phrases in fixture answers
- 60B.49 reconciliation auto-add invariant restored under confirmed closure flow
- Test apparatus fixed: structuralActionRequired required with userSupportedMeaning (validator constraint)

New coverage:
- 10 tests for all 60B.79/80 coverage requirements
- 5 prompt alignment tests for Rule #143
This commit is contained in:
2026-08-15 11:34:39 +01:00
parent 4988159986
commit b181c3ea75
4 changed files with 862 additions and 12 deletions
+649 -11
View File
@@ -4517,7 +4517,7 @@ describe("60B.43 — terminal post-mutation target is cleared after valid decisi
newStatus: "resolved",
previousValue: null,
newValue:
"Prospective enterprise customer confirmed they will sign if we launch this year.",
"Prospective enterprise customer confirmed they will sign if we launch this year. No other material uncertainty remains.",
reason: "The user directly answered the active customer-signing unknown.",
},
{
@@ -4543,6 +4543,7 @@ describe("60B.43 — terminal post-mutation target is cleared after valid decisi
"Regression case: proposal still points at the decision node even though it becomes known in the same turn.",
},
},
answer: "no remaining material uncertainty",
});
expect(result.success).toBe(true);
@@ -4575,7 +4576,7 @@ describe("60B.43 — terminal post-mutation target is cleared after valid decisi
previousStatus: "unknown",
newStatus: "resolved",
previousValue: null,
newValue: "Customer signing confirmed.",
newValue: "Customer signing confirmed. No other material uncertainty remains.",
reason: "The active customer-signing uncertainty is resolved.",
},
{
@@ -4598,9 +4599,17 @@ describe("60B.43 — terminal post-mutation target is cleared after valid decisi
reason: "This target becomes terminal and must be discarded post-mutation.",
},
},
answer: "no remaining material uncertainty",
});
expect(result.success).toBe(true);
// With explicit confirmation, decision closes normally; fallback to next unresolved unknown
// so fallbackUnknown remains active; selectedQuestion falls back correctly.
const productDecisionNode = result.updatedSituationGraph.nodes.find(
(n) => n.id === ids.productLaunchDecision,
);
// With explicit confirmation, decision closes normally; fallback to next unresolved unknown
expect(productDecisionNode?.status).toBe("known");
expect(result.updatedSituationGraph.activeUnknownNodeId).toBe(
ids.fallbackUnknown,
);
@@ -4737,7 +4746,7 @@ describe("60B.49 — reconciles updated-to-resolved unknown into resolvedUnknown
previousStatus: "unknown",
newStatus: "resolved",
previousValue: null,
newValue: "Customer confirmed they will not sign this year.",
newValue: "Customer confirmed they will not sign this year. No other material uncertainty remains.",
reason: "Negative outcome resolves the customer factor.",
},
{
@@ -4760,23 +4769,19 @@ describe("60B.49 — reconciles updated-to-resolved unknown into resolvedUnknown
reason: "Closure-shaped proposal contract.",
},
},
answer: "no remaining material uncertainty",
});
expect(result.success).toBe(true);
expect(result.updatedSituationGraph.resolvedNodeIds).toEqual(
expect.arrayContaining([
enterpriseCustomerSigning.id,
productLaunchDecision.id,
]),
);
// With explicit confirmation, parent closure proceeds normally:
expect(
result.updatedSituationGraph.resolvedNodeIds.filter(
(id) => id === productLaunchDecision.id,
(id) => id === enterpriseCustomerSigning.id,
),
).toHaveLength(1);
expect(
result.updatedSituationGraph.resolvedNodeIds.filter(
(id) => id === enterpriseCustomerSigning.id,
(id) => id === productLaunchDecision.id,
),
).toHaveLength(1);
expect(
@@ -5783,3 +5788,636 @@ describe("60B.64 — explicit decision sufficiency closure", () => {
});
});
// ── 60B.80 — confirmation-gated model closure ownership ─────────────
describe("60B.80 — confirmation-gated model closure ownership", () => {
function makeClosureProposalFixture() {
const decision = makeNode({
id: "n_product_launch_decision",
label: "Which option leaves us better off overall?",
description: "Uncertainty about which product-launch timing provides superior net value.",
kind: "unknown",
status: "unknown",
confidence: "medium",
});
const optA = makeNode({
id: "opt_launch_this_year",
label: "Launch this year",
description: "Launch the new software product this year.",
kind: "option",
status: "known",
confidence: "high",
});
const optB = makeNode({
id: "opt_wait_twelve_months",
label: "Wait twelve months",
description: "Wait twelve months before launching the product.",
kind: "option",
status: "known",
confidence: "high",
});
const customerFactor = makeNode({
id: "n_enterprise_customer_signing",
label: "Prospective enterprise customer signing status",
description: "Unknown whether one prospective enterprise customer will sign.",
kind: "unknown",
status: "unknown",
confidence: "medium",
});
const marketFactor = makeNode({
id: "n_market_timing_factor",
label: "Competing product announcement timing",
description: "Unknown whether a competitor will announce in Q3.",
kind: "unknown",
status: "unknown",
confidence: "medium",
});
const nodes = [decision, optA, optB, customerFactor, marketFactor];
const edges = [
makeEdge({
id: "e-opt-a-to-dec",
fromNodeId: optA.id,
toNodeId: decision.id,
relationship: "contained_in",
confidence: "high",
}),
makeEdge({
id: "e-opt-b-to-dec",
fromNodeId: optB.id,
toNodeId: decision.id,
relationship: "contained_in",
confidence: "high",
}),
makeEdge({
id: "e-customer-to-optA",
fromNodeId: customerFactor.id,
toNodeId: optA.id,
relationship: "may_cause",
confidence: "medium",
}),
makeEdge({
id: "e-market-to-dec",
fromNodeId: marketFactor.id,
toNodeId: decision.id,
relationship: "may_cause",
confidence: "medium",
}),
];
const graph = makeGraph({
centralStatement: "Evaluating two product-launch timing options.",
nodes,
edges,
activeUnknownNodeId: customerFactor.id,
resolvedNodeIds: [],
currentSummary: "Customer signing is the active uncertainty.",
});
return { graph, ids: { decision: decision.id, optA: optA.id, optB: optB.id, customerFactor: customerFactor.id, marketFactor: marketFactor.id } };
}
// ── Test 1 — resolved/null parent closure stripped (no confirmation) ──
it("Test 1 — resolved/null parent closure stripped", () => {
const { graph, ids } = makeClosureProposalFixture();
const result = applyValidatedProposal({
situationGraph: graph,
proposal: {
updatedNodes: [
{
nodeId: ids.customerFactor,
previousStatus: "unknown",
newStatus: "resolved",
previousValue: null,
newValue: "Customer will not sign if we launch this year.",
reason: "Active unknown resolved.",
},
{
nodeId: ids.decision,
previousStatus: "unknown",
newStatus: "resolved",
previousValue: null,
newValue: null,
reason: "All factors resolved.",
},
],
addedNodes: [],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [ids.customerFactor, ids.decision],
affectedNodeIds: [ids.decision],
},
answer: "Customer will not sign",
});
expect(result.success).toBe(true);
const customer = result.updatedSituationGraph.nodes.find((n) => n.id === ids.customerFactor);
const decision = result.updatedSituationGraph.nodes.find((n) => n.id === ids.decision);
// Customer resolution preserved
expect(customer?.status).toBe("resolved");
// Decision closure removed
expect(decision?.status).toBe("unknown");
// Decision removed from resolvedUnknownNodeIds — consistency check via graph
const updatedProposal = result.updatedSituationGraph;
expect(updatedProposal.resolvedNodeIds).not.toContain(ids.decision);
expect(updatedProposal.resolvedNodeIds).toContain(ids.customerFactor);
});
// ── Test 2 — known/directional parent closure stripped ──
it("Test 2 — known/directional parent closure stripped", () => {
const { graph, ids } = makeClosureProposalFixture();
const result = applyValidatedProposal({
situationGraph: graph,
proposal: {
updatedNodes: [
{
nodeId: ids.customerFactor,
previousStatus: "unknown",
newStatus: "resolved",
previousValue: null,
newValue: "Customer confirmed.",
reason: "Resolved.",
},
{
nodeId: ids.decision,
previousStatus: "unknown",
newStatus: "known",
previousValue: null,
newValue: "launch this year",
reason: "Directional conclusion.",
},
],
addedNodes: [],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [ids.customerFactor, ids.decision],
affectedNodeIds: [ids.decision],
},
answer: "Customer confirmed",
});
expect(result.success).toBe(true);
const customer = result.updatedSituationGraph.nodes.find((n) => n.id === ids.customerFactor);
const decision = result.updatedSituationGraph.nodes.find((n) => n.id === ids.decision);
// Customer resolution preserved
expect(customer?.status).toBe("resolved");
// Parent terminal update removed entirely
expect(decision?.status).toBe("unknown");
// Directional value NOT retained on unknown decision
expect(decision?.value).toBeNull();
});
// ── Test 3 — resolved/directional parent closure stripped ──
it("Test 3 — resolved/directional parent closure stripped", () => {
const { graph, ids } = makeClosureProposalFixture();
const result = applyValidatedProposal({
situationGraph: graph,
proposal: {
updatedNodes: [
{
nodeId: ids.decision,
previousStatus: "unknown",
newStatus: "resolved",
previousValue: null,
newValue: "launch this year",
reason: "Resolved directionally.",
},
],
addedNodes: [],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [ids.decision],
affectedNodeIds: [ids.decision],
},
answer: "Launch is better",
});
expect(result.success).toBe(true);
const decision = result.updatedSituationGraph.nodes.find((n) => n.id === ids.decision);
// Terminal parent update removed
expect(decision?.status).toBe("unknown");
// No hybrid unknown + directional value
expect(decision?.value).toBeNull();
});
// ── Test 4 — explicit confirmation allows closure ──
it("Test 4 — explicit confirmation allows closure", () => {
const { graph, ids } = makeClosureProposalFixture();
const result = applyValidatedProposal({
situationGraph: graph,
proposal: {
updatedNodes: [
{
nodeId: ids.customerFactor,
previousStatus: "unknown",
newStatus: "resolved",
previousValue: null,
newValue: "Customer confirmed.",
reason: "Resolved.",
},
{
nodeId: ids.decision,
previousStatus: "unknown",
newStatus: "known",
previousValue: null,
newValue: "launch this year",
reason: "All factors resolved.",
},
],
addedNodes: [],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [ids.customerFactor, ids.decision],
affectedNodeIds: [ids.decision],
},
answer: "There are no other material uncertainties remain.",
});
expect(result.success).toBe(true);
const customer = result.updatedSituationGraph.nodes.find((n) => n.id === ids.customerFactor);
const decision = result.updatedSituationGraph.nodes.find((n) => n.id === ids.decision);
// Explicit confirmation permits closure: normalisation strips model's terminal
// update, then deterministic gate re-closes the decision because confirmation is present.
expect(customer?.status).toBe("resolved");
expect(decision?.status).toBe("resolved");
});
// ── Test 5 — ordinary child unknown resolution unaffected ──
it("Test 5 — ordinary child unknown resolution unaffected", () => {
const { graph, ids } = makeClosureProposalFixture();
const result = applyValidatedProposal({
situationGraph: graph,
proposal: {
updatedNodes: [
{
nodeId: ids.marketFactor,
previousStatus: "unknown",
newStatus: "resolved",
previousValue: null,
newValue: "No competitor announcement expected.",
reason: "Resolved by evidence.",
},
{
nodeId: ids.decision,
previousStatus: "unknown",
newStatus: "known",
previousValue: null,
newValue: "launch this year",
reason: "Terminal closure.",
},
],
addedNodes: [],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [ids.marketFactor, ids.decision],
affectedNodeIds: [ids.decision],
},
answer: "No competitor announcement expected",
});
expect(result.success).toBe(true);
const market = result.updatedSituationGraph.nodes.find((n) => n.id === ids.marketFactor);
const decision = result.updatedSituationGraph.nodes.find((n) => n.id === ids.decision);
const customer = result.updatedSituationGraph.nodes.find((n) => n.id === ids.customerFactor);
// market is NOT a parent decision (no contained_in from options), so its
// terminal update survives regardless of confirmation absence. Decision IS a parent,
// and without explicit confirmation its closure gets stripped.
// customerFactor is not referenced in this test proposal and stays unknown.
expect(market?.status).toBe("resolved");
expect(decision?.status).toBe("unknown");
});
// ── Test 6 — unrelated option/evidence updates survive ──
it("Test 6 — unrelated option/evidence updates survive", () => {
const { graph, ids } = makeClosureProposalFixture();
const result = applyValidatedProposal({
situationGraph: graph,
proposal: {
updatedNodes: [
{
nodeId: ids.customerFactor,
previousStatus: "unknown",
newStatus: "resolved",
previousValue: null,
newValue: "Customer confirmed.",
reason: "Resolved.",
},
{
nodeId: ids.decision,
previousStatus: "unknown",
newStatus: "known",
previousValue: null,
newValue: "launch this year",
reason: "Terminal closure.",
},
],
addedNodes: [],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [ids.customerFactor, ids.decision],
affectedNodeIds: [ids.decision],
},
answer: "Customer confirmed",
});
expect(result.success).toBe(true);
const decision = result.updatedSituationGraph.nodes.find((n) => n.id === ids.decision);
const customer = result.updatedSituationGraph.nodes.find((n) => n.id === ids.customerFactor);
// Customer resolution preserved
expect(customer?.status).toBe("resolved");
// Decision closure stripped (no explicit confirmation phrase in answer)
expect(decision?.status).toBe("unknown");
});
// ── Test 7 — answerMeaning survives normalisation ──
it("Test 7 — answerMeaning survives normalisation", () => {
const { graph, ids } = makeClosureProposalFixture();
const result = applyValidatedProposal({
situationGraph: graph,
proposal: {
updatedNodes: [
{
nodeId: ids.customerFactor,
previousStatus: "unknown",
newStatus: "resolved",
previousValue: null,
newValue: "Customer confirmed.",
reason: "Resolved.",
},
{
nodeId: ids.decision,
previousStatus: "unknown",
newStatus: "known",
previousValue: null,
newValue: "launch this year",
reason: "Terminal closure.",
},
],
answerMeaning: {
userSupportedMeaning: "Customer confirmed they will not sign.",
supportCategory: "other",
},
structuralActionRequired: true,
addedNodes: [],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [ids.customerFactor, ids.decision],
affectedNodeIds: [ids.decision],
},
answer: "Customer confirmed",
});
expect(result.success).toBe(true);
const decision = result.updatedSituationGraph.nodes.find((n) => n.id === ids.decision);
const customer = result.updatedSituationGraph.nodes.find((n) => n.id === ids.customerFactor);
// Decision closure stripped (no explicit confirmation phrase in answer)
expect(decision?.status).toBe("unknown");
// Customer resolution preserved
expect(customer?.status).toBe("resolved");
});
// ── Test 8 — resolved bookkeeping remains consistent ──
it("Test 8 — resolved bookkeeping remains consistent", () => {
const { graph, ids } = makeClosureProposalFixture();
const result = applyValidatedProposal({
situationGraph: graph,
proposal: {
updatedNodes: [
{
nodeId: ids.customerFactor,
previousStatus: "unknown",
newStatus: "resolved",
previousValue: null,
newValue: "Customer confirmed.",
reason: "Resolved.",
},
{
nodeId: ids.decision,
previousStatus: "unknown",
newStatus: "resolved",
previousValue: null,
newValue: null,
reason: "Parent closure.",
},
],
addedNodes: [],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [ids.customerFactor, ids.decision],
affectedNodeIds: [ids.decision],
},
answer: "Customer confirmed",
});
expect(result.success).toBe(true);
const decision = result.updatedSituationGraph.nodes.find((n) => n.id === ids.decision);
const customer = result.updatedSituationGraph.nodes.find((n) => n.id === ids.customerFactor);
const resolvedIds = result.updatedSituationGraph.resolvedNodeIds;
// No parent in resolvedUnknownNodeIds after stripped closure
expect(resolvedIds).not.toContain(ids.decision);
// Customer remains present
expect(resolvedIds).toContain(ids.customerFactor);
// Decision remains unknown
expect(decision?.status).toBe("unknown");
});
// ── Test 9 — State B becomes reachable ──
it("Test 9 — State B sufficiency question path reached", () => {
const decision = makeNode({
id: "n_product_launch_decision",
label: "Which option leaves us better off overall?",
description: "Uncertainty about which product-launch timing provides superior net value.",
kind: "unknown",
status: "unknown",
confidence: "medium",
});
const optA = makeNode({
id: "opt_launch_this_year",
label: "Launch this year",
description: "Launch the new software product this year.",
kind: "option",
status: "known",
confidence: "high",
});
const optB = makeNode({
id: "opt_wait_twelve_months",
label: "Wait twelve months",
description: "Wait twelve months before launching.",
kind: "option",
status: "known",
confidence: "high",
});
const customerFactor = makeNode({
id: "n_customer_signing",
label: "Enterprise customer signing status",
description: "Unknown whether enterprise customer will sign.",
kind: "unknown",
status: "unknown",
confidence: "medium",
});
const nodes = [decision, optA, optB, customerFactor];
const edgesArr = [
makeEdge({ id: "e1", fromNodeId: optA.id, toNodeId: decision.id, relationship: "contained_in", confidence: "high" }),
makeEdge({ id: "e2", fromNodeId: optB.id, toNodeId: decision.id, relationship: "contained_in", confidence: "high" }),
makeEdge({ id: "e3", fromNodeId: customerFactor.id, toNodeId: optA.id, relationship: "may_cause", confidence: "medium" }),
];
const graph = makeGraph({
centralStatement: "Evaluating product-launch timing.",
nodes,
edges: edgesArr,
activeUnknownNodeId: decision.id,
resolvedNodeIds: [],
currentSummary: "Decision remains unresolved.",
});
// Model tries to close the parent without explicit confirmation
const result = applyValidatedProposal({
situationGraph: graph,
proposal: {
updatedNodes: [
{
nodeId: customerFactor.id,
previousStatus: "unknown",
newStatus: "resolved",
previousValue: null,
newValue: "Customer signed.",
reason: "Resolved.",
},
{
nodeId: decision.id,
previousStatus: "unknown",
newStatus: "resolved",
previousValue: null,
newValue: null,
reason: "All factors resolved — closing parent.",
},
],
addedNodes: [],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [customerFactor.id, decision.id],
affectedNodeIds: [decision.id],
},
answer: "Customer signed. That resolves it.",
});
expect(result.success).toBe(true);
const decisionNode = result.updatedSituationGraph.nodes.find((n) => n.id === decision.id);
const customer = result.updatedSituationGraph.nodes.find((n) => n.id === customerFactor.id);
// Decision remains unknown (closure stripped)
expect(decisionNode?.status).toBe("unknown");
// activeUnknownNodeId = the unresolved decision
expect(result.updatedSituationGraph.activeUnknownNodeId).toBe(decision.id);
// Customer resolution preserved
expect(customer?.status).toBe("resolved");
});
// ── Test 10 — ordinary decision_threshold unaffected ──
it("Test 10 — ordinary decision_threshold preserved", () => {
const decision = makeNode({
id: "n_regular_decision",
label: "Which vendor to choose?",
description: "Decision between three vendors.",
kind: "unknown",
status: "unknown",
confidence: "medium",
});
const optA = makeNode({
id: "opt_vendor_a",
label: "Vendor A",
description: "Vendor A option.",
kind: "option",
status: "known",
confidence: "high",
});
const remainingFactor = makeNode({
id: "n_pricing_details",
label: "Remaining pricing details unknown",
description: "Need pricing details from Vendor A.",
kind: "unknown",
status: "unknown",
confidence: "medium",
});
const nodes = [decision, optA, remainingFactor];
const edgesArr = [
makeEdge({ id: "e1", fromNodeId: optA.id, toNodeId: decision.id, relationship: "contained_in", confidence: "high" }),
makeEdge({ id: "e2", fromNodeId: remainingFactor.id, toNodeId: optA.id, relationship: "may_cause", confidence: "medium" }),
];
const graph = makeGraph({
centralStatement: "Evaluating vendor options.",
nodes,
edges: edgesArr,
activeUnknownNodeId: decision.id,
resolvedNodeIds: [],
currentSummary: "Pricing details still unknown.",
});
// Model resolves customer-like factor but not the remaining material factor
const result = applyValidatedProposal({
situationGraph: graph,
proposal: {
updatedNodes: [
{
nodeId: optA.id,
previousStatus: "known",
newStatus: "known",
previousValue: null,
newValue: "Vendor A selected.",
reason: "Directional update only.",
},
],
addedNodes: [],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [],
affectedNodeIds: [decision.id],
},
answer: "We've looked at Vendor A thoroughly.",
});
expect(result.success).toBe(true);
// Decision_threshold_outcome behaviour unchanged — decision stays open
const decisionNode = result.updatedSituationGraph.nodes.find((n) => n.id === decision.id);
expect(decisionNode?.status).toBe("unknown");
});
});
+58
View File
@@ -863,6 +863,64 @@ describe("60B.4 decision materiality rule", () => {
});
});
// ============================================
// 60B.80 — explicit confirmation requirement in prompt
// ============================================
describe("60B.80 — prompt alignment for confirmation-gated closure", () => {
let sufficiencySection;
beforeAll(() => {
const testNode = makeNode({
id: "n-test-decision",
label: "Test Decision",
kind: "state",
status: "supported",
});
const prompt = buildGraphUpdatePrompt({
situationGraph: makeGraph({
centralStatement: "test",
nodes: [testNode],
edges: [],
activeUnknownNodeId: null,
resolvedNodeIds: [],
currentSummary: "Test.",
}),
situationContext: "Test context",
});
sufficiencySection = prompt.split("## Decision Sufficiency Rule")[1].split("## Decision Option Structure Rules")[0];
});
it("explicit confirmation requirement is present in the Decision Sufficiency Rule", () => {
expect(sufficiencySection).toContain("explicitly confirms");
expect(sufficiencySection).toContain("no other material uncertainty remains");
});
it("evidence-only terminal closure rule is replaced by confirmation-gated rule", () => {
// The OLD rule that permitted evidence-only closure is replaced:
expect(sufficiencySection).not.toContain(
"If the currently supported evidence is sufficient to distinguish the options and no such material unresolved factor remains, resolve the existing decision context",
);
// The NEW confirmation requirement is present:
expect(sufficiencySection).toContain("explicit confirmation");
});
it("model must not close parent merely because evidence appears sufficient", () => {
expect(sufficiencySection).toContain("You may not resolve the decision context unless");
expect(sufficiencySection).toContain("explicitly confirms (using their own words)");
});
it("preserves directional conclusion guidance when confirmation absent", () => {
expect(sufficiencySection).toContain("preserve your directional conclusion in possibleInference");
});
it("does NOT still permit evidence-only terminal closure", () => {
// The old "resolve" permission based on evidence sufficiency alone is gone:
const text = sufficiencySection;
expect(text).not.toContain("evidence is sufficient to distinguish the options and no such material unresolved factor remains, resolve");
});
});
// ============================================
// 60B.11 — prompt prerequisite-aware targeting clarity
// ============================================