import { classifyTaskRisk } from "./intent"; import { assertNoTouch } from "../no_touch"; import { NO_RISK_FLOOR, raiseRisk, scopeRiskFloor, type RiskFloor } from "./risk_floor"; import { archivePath, boundSpecPath, inspectSpecBinding } from "./spec_binding"; import type { ApprovalKind, CompletionDecision, TaskAttestationV3, TaskIntentV1, TaskProjectionV3, TaskRecord, } from "./types"; import { assertKernelInvariantsV3 } from "./validation"; import { refutationIsLive } from "./refutation"; const REQUIRED_ATTESTATIONS: Record = { routine: ["qa"], material: ["qa", "review"], critical: ["qa", "review"], }; function archiveActivePlanningPath(path: string): string | null { const matched = path.match(/^docs\/(plans|specs)\/([^/]+)$/); return matched ? `docs/${matched[1]}/archive/${matched[2]}` : null; } function ownSidecarPaths(intent: TaskIntentV1): Set { const activeIntent = `docs/plans/${intent.task_id}.intent.json`; const excluded = new Set([activeIntent]); const archivedIntent = archiveActivePlanningPath(activeIntent); if (archivedIntent) excluded.add(archivedIntent); const spec = boundSpecPath(intent); if (spec) { excluded.add(spec); excluded.add(archivePath(spec)); } return excluded; } export type TaskDiffInput = string | { diff_hash: string; changed_paths?: readonly string[] }; export function asTaskDiffSnapshot(value: TaskDiffInput): { diff_hash: string; changed_paths?: readonly string[]; } { if (typeof value === "string") return { diff_hash: value }; return { diff_hash: value.diff_hash, changed_paths: value.changed_paths }; } export function resolveProjectedRisk( intent: TaskIntentV1, changedPaths: readonly string[] = [], floor: RiskFloor = NO_RISK_FLOOR, ): TaskIntentV1["risk"] { const risk = raiseRisk(intent.risk, scopeRiskFloor(intent.scope_hint, floor)); // Invalid bindings have no trusted planning-path exclusions. if (!inspectSpecBinding(intent).ok) return classifyTaskRisk(changedPaths, risk, floor); const excluded = ownSidecarPaths(intent); return classifyTaskRisk( changedPaths.filter((path) => !excluded.has(path)), risk, floor, ); } function hasDistinctAuthorityAssignment( required: ApprovalKind[], candidates: TaskAttestationV3[], index = 0, usedActors = new Set(), ): boolean { if (index >= required.length) return true; for (const attestation of candidates.filter((item) => item.kind === required[index])) { if (usedActors.has(attestation.actor_id)) continue; const nextActors = new Set(usedActors); nextActors.add(attestation.actor_id); if (hasDistinctAuthorityAssignment(required, candidates, index + 1, nextActors)) return true; } return false; } export function completionDecision( intent: TaskIntentV1, record: TaskRecord, currentDiffHash: string, currentIntentContentHash: string, changedPaths: readonly string[] = [], floor: RiskFloor = NO_RISK_FLOOR, ): CompletionDecision { assertKernelInvariantsV3(intent, record); assertNoTouch(changedPaths); const freshAttestations = record.attestations.filter( (item) => item.task_revision === intent.revision && item.intent_content_hash === currentIntentContentHash && item.diff_hash === currentDiffHash, ); const freshQaResults = freshAttestations .filter((item) => item.kind === "qa") .flatMap((item) => item.acceptance_results) .filter((item) => item.status === "passed"); const freshAcceptanceIds = intent.acceptance .map((item) => item.id) .filter((id) => freshQaResults.some((result) => result.acceptance_id === id)); const freshAcceptanceSet = new Set(freshAcceptanceIds); const missingAcceptanceIds = intent.acceptance .map((item) => item.id) .filter((id) => !freshAcceptanceSet.has(id)); const staleAttestationIds = record.attestations .filter( (item) => item.task_revision !== intent.revision || item.intent_content_hash !== currentIntentContentHash || item.diff_hash !== currentDiffHash, ) .map((item) => item.id); // A literal-user residual-risk acceptance stands in for the Review // approval only while it names this exact intent revision, intent hash and // diff, and every finding it accepted is still accepted. QA, acceptance and // every other obligation are unaffected, and any later change makes it stale. const acceptance = record.residual_risk_acceptance; const residualRiskAccepted = acceptance !== undefined && acceptance.intent_revision === intent.revision && acceptance.intent_content_hash === currentIntentContentHash && acceptance.diff_hash === currentDiffHash && acceptance.finding_ids.every((id) => record.findings.some((item) => item.id === id && item.status === "accepted_risk"), ); const requiredKinds = REQUIRED_ATTESTATIONS[ resolveProjectedRisk(intent, changedPaths, floor) ].filter((kind) => !(residualRiskAccepted && kind === "review")); const candidates = freshAttestations.filter((item) => requiredKinds.includes(item.kind)); const missingApprovalKinds = requiredKinds.filter( (kind) => !candidates.some((attestation) => attestation.kind === kind), ); const separationFailure = missingApprovalKinds.length === 0 && !hasDistinctAuthorityAssignment(requiredKinds, candidates); const repeatedActors = new Set(); if (separationFailure) { for (const attestation of candidates) { const kinds = new Set( candidates .filter((candidate) => candidate.actor_id === attestation.actor_id) .map((candidate) => candidate.kind), ); if (kinds.size > 1) repeatedActors.add(attestation.actor_id); } } const independenceViolations = candidates .filter((item) => repeatedActors.has(item.actor_id)) .map((item) => item.id); // A refutation is live only while its bound QA attestation is still fresh // for this diff and intent hash. When it goes stale the refuted finding // counts as blocking again; nothing stored is rewritten by that // invalidation, so the append-only invariant survives. The identity is the // one `freshAttestations` filters on, including the caller's current intent // hash, so a drifted intent revives the gate with the attestation. const refutationState = { intent_revision: intent.revision, intent_content_hash: currentIntentContentHash, diff_hash: currentDiffHash, }; const blockingFindingIds = record.findings .filter( (item) => item.kind === "blocking" && (item.status === "open" || (item.status === "refuted" && !refutationIsLive(item, record.attestations, refutationState))), ) .map((item) => item.id); const unresolvedUserDecisionIds = record.findings .filter((item) => item.status === "open" && item.kind === "unresolved_user_decision") .map((item) => item.id); const replanRequiredIds = record.findings .filter((item) => item.status === "open" && item.kind === "replan_required") .map((item) => item.id); return { complete: // An invalid Spec binding is never complete, whatever the project's floor says. inspectSpecBinding(intent).ok && missingAcceptanceIds.length === 0 && missingApprovalKinds.length === 0 && blockingFindingIds.length === 0 && unresolvedUserDecisionIds.length === 0 && replanRequiredIds.length === 0 && independenceViolations.length === 0, fresh_acceptance_ids: freshAcceptanceIds, missing_acceptance_ids: missingAcceptanceIds, stale_attestation_ids: staleAttestationIds, missing_approval_kinds: missingApprovalKinds, blocking_finding_ids: blockingFindingIds, unresolved_user_decision_ids: unresolvedUserDecisionIds, replan_required_ids: replanRequiredIds, independence_violations: independenceViolations, }; } export function projectTask( intent: TaskIntentV1, record: TaskRecord, currentDiffHash: string, currentIntentContentHash: string, changedPaths: readonly string[] = [], floor: RiskFloor = NO_RISK_FLOOR, ): TaskProjectionV3 { if (record.lifecycle === "active" && !inspectSpecBinding(intent).ok) { assertKernelInvariantsV3(intent, record); return { contract: "assurance_kernel/projection/v3", task_id: record.task_id, intent_revision: intent.revision, lifecycle: record.lifecycle, artifact_state: record.artifact_state, blocked: true, next_obligation: "revise_intent", complete: false, fresh_acceptance_ids: [], missing_acceptance_ids: intent.acceptance.map((item) => item.id), stale_attestation_ids: record.attestations.map((item) => item.id), missing_approval_kinds: REQUIRED_ATTESTATIONS[resolveProjectedRisk(intent, changedPaths, floor)], blocking_finding_ids: record.findings.filter((item) => item.kind === "blocking" && item.status !== "resolved").map((item) => item.id), unresolved_user_decision_ids: record.findings.filter((item) => item.kind === "unresolved_user_decision" && item.status === "open").map((item) => item.id), replan_required_ids: record.findings.filter((item) => item.kind === "replan_required" && item.status === "open").map((item) => item.id), independence_violations: [], }; } const decision = completionDecision( intent, record, currentDiffHash, currentIntentContentHash, changedPaths, floor, ); const blocked = decision.blocking_finding_ids.length > 0 || decision.unresolved_user_decision_ids.length > 0 || decision.replan_required_ids.length > 0 || decision.independence_violations.length > 0; let nextObligation: TaskProjectionV3["next_obligation"] = "none"; if (record.lifecycle === "active") { if (decision.unresolved_user_decision_ids.length > 0) { nextObligation = "resolve_user_decision"; } else if (decision.replan_required_ids.length > 0) { nextObligation = "revise_intent"; } else if (decision.blocking_finding_ids.length > 0 || decision.independence_violations.length > 0) { nextObligation = "resolve_findings"; } else if (record.artifact_state === "active") { nextObligation = "submit_assurance"; } else if (decision.missing_acceptance_ids.length > 0 || decision.missing_approval_kinds.includes("qa")) { nextObligation = "run_qa"; } else if (decision.missing_approval_kinds.includes("review")) { nextObligation = "run_review"; } else if (decision.complete) { nextObligation = "complete"; } } return { contract: "assurance_kernel/projection/v3", task_id: record.task_id, intent_revision: record.intent_snapshot.revision, lifecycle: record.lifecycle, artifact_state: record.artifact_state, blocked, next_obligation: nextObligation, ...decision, }; }