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);
+1 -1
View File
@@ -140,7 +140,7 @@ An unresolved decision between options should not remain open merely because som
Keep a decision context unresolved only when you can identify a specific unresolved factor that could materially change which option is preferred.
If the currently supported evidence is sufficient to distinguish the options and no such material unresolved factor remains, resolve the existing decision context and do not ask a generic continuation question.
You may not resolve the decision context unless the user explicitly confirms (using their own words) that no other material uncertainty remains. If evidence appears sufficient but explicit confirmation is absent, preserve your directional conclusion in possibleInference and allow the system to ask the sufficiency confirmation / discovery question rather than closing the parent decision.
## Decision Option Structure Rules
When the user presents mutually exclusive candidate actions for one unresolved choice: