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
+154
View File
@@ -408,6 +408,139 @@ function reconcileResolutionSemantics(graph, proposal) {
};
}
// ── 60B.80 — decision closure ownership normalisation ──────────────
/**
* Determines whether a node is a parent decision by containment edges
* established in 60B.75 (unknown node with incoming contained_in from options).
*/
function isParentDecision(nodeId, graphNodes) {
const node = graphNodes.find((n) => n.id === nodeId);
if (!node || node.kind !== "unknown") return false;
if (
!(graphNodes[Symbol.for("edges")] || []).some(
(e) => e.relationship === "contained_in" && e.toNodeId === nodeId,
)
) {
// Check via graph edges array passed separately
return false;
}
return true;
}
function hasIncomingContainedInEdge(nodeId, graphEdges) {
return (graphEdges || []).some(
(e) => e.relationship === "contained_in" && e.toNodeId === nodeId,
);
}
/**
* Detects whether a proposal attempts terminal closure of an unresolved
* parent decision without explicit user confirmation. If so, strips ONLY
* the parent terminal update and its resolved bookkeeping, preserving all
* other proposal content (customer/factor resolution, option updates,
* addedNodes/addedEdges, answerMeaning, etc.).
*
* Runs AFTER reconcileResolutionSemantics, BEFORE proposal compatibility
* validation. Receives raw answer to detect confirmation absence.
*
* 60B.79 established: the model must not make a parent decision terminal
* merely because represented evidence appears sufficient. The user's
* explicit confirmation is the sole authority for closing a parent decision.
*/
function reconcileDecisionClosureOwnership(graph, proposal, answer) {
const TERMINAL_STATUSES = ["known", "resolved", "contradicted"];
const changesMade = { strippedUpdates: [], strippedResolvedIds: [] };
// Build set of graph node IDs that are parent decisions (unknown with
// incoming contained_in edges from option nodes). This is the canonical
// decision-context mechanism established in 60B.75 — decisions are not a
// separate kind; they are unknown nodes identified by containment structure.
const parentNodeIds = new Set();
for (const node of graph.nodes || []) {
if (node.kind !== "unknown") continue;
if (!hasIncomingContainedInEdge(node.id, graph.edges)) continue;
// Also verify the node itself is currently unresolved
if (TERMINAL_STATUSES.includes(node.status)) continue;
parentNodeIds.add(node.id);
}
if (parentNodeIds.size === 0) return changesMade;
// Check explicit confirmation on answer (bounded phrase/pattern family)
const hasConfirmation = isUserConfirmationOfNoRemainingUncertainty(answer);
if (hasConfirmation) return changesMade;
// ── Phase A: Strip terminal updates from updatedNodes ────────────
for (const update of proposal.updatedNodes || []) {
if (!parentNodeIds.has(update.nodeId)) continue;
if (!TERMINAL_STATUSES.includes(update.newStatus)) continue;
const idx = proposal.updatedNodes.indexOf(update);
if (idx === -1) continue;
// Revert status to previous (or unknown)
update.newStatus = "unknown";
// Restore original value state — must remove directional newValue
// because 60B.79: hybrid unknown+value on decision is semantically unsafe
update.previousValue ??= update.newValue ?? null;
update.newValue = null;
changesMade.strippedUpdates.push(update.nodeId);
}
// ── Phase B: Strip parent from resolvedUnknownNodeIds ────────────
const updatedNodeIds = new Set(
(proposal.updatedNodes || []).map((u) => u.nodeId),
);
for (let i = proposal.resolvedUnknownNodeIds.length - 1; i >= 0; i--) {
const resolvedId = proposal.resolvedUnknownNodeIds[i];
if (!parentNodeIds.has(resolvedId)) continue;
// Strip from resolved list
proposal.resolvedUnknownNodeIds.splice(i, 1);
changesMade.strippedResolvedIds.push(resolvedId);
// If reconciliation synthesized a forced "resolved" update for this node,
// revert it (it was not model-provided — it was synthetic)
const existingUpdate = proposal.updatedNodes.find(
(u) => u.nodeId === resolvedId,
);
if (existingUpdate && updatedNodeIds.has(resolvedId)) {
// Only revert if the status is "resolved" due to reconciliation
// forcing it (the model might have also proposed it — in which case
// Phase A already stripped it but left newStatus="unknown")
if (existingUpdate.newStatus === "resolved") {
existingUpdate.newStatus = "unknown";
if (existingUpdate.previousValue === undefined) {
existingUpdate.previousValue = existingUpdate.newValue ?? null;
}
existingUpdate.newValue = null;
}
}
// If no explicit update exists for this resolved node, create a minimal
// unknown-preserving update to prevent reconciliation errors downstream
if (!existingUpdate) {
const parentNode = graph.nodes.find((n) => n.id === resolvedId);
if (parentNode) {
proposal.updatedNodes.push({
nodeId: resolvedId,
previousStatus: "unknown",
newStatus: "unknown",
previousValue: parentNode.value ?? null,
newValue: parentNode.value ?? null,
});
}
}
}
return changesMade;
}
function validateSemanticDuplicateUnknowns(graph, proposal) {
const errors = [];
const unresolvedUnknowns = graph.nodes.filter(
@@ -3546,6 +3679,27 @@ export function applyValidatedProposal({
situationGraph,
proposalValidation.data,
);
// ── 60B.80 — normalise terminal parent closure without explicit confirmation
const ownershipChanges = reconcileDecisionClosureOwnership(
situationGraph,
reconciledProposal.proposal,
answer,
);
if (ownershipChanges.strippedResolvedIds.length > 0) {
// Reconciler may have added selectedQuestion = null because the parent
// appeared resolved during reconciliation. If we stripped it, restore
// minimal state so normal downstream question reselection can proceed.
const strippedParentIds = new Set(ownershipChanges.strippedResolvedIds);
if (
reconciledProposal.proposal.selectedQuestion &&
strippedParentIds.has(reconciledProposal.proposal.selectedQuestion.nodeId)
) {
// Allow existing target/question reselection to work naturally
// rather than synthesising question text here.
}
}
const validatedProposal = reconciledProposal.proposal;
const proposalCompatibilityErrors = [];
proposalCompatibilityErrors.push(...reconciledProposal.errors);