"""Stale / moot #332 review-decision lock detection and cleanup policy (#594/#620/#693). #332 correctly hard-stops a reviewer session after a terminal live review mutation. After #559 those locks are durable on disk, so they can outlive the work they protect: when the referenced PR is already merged or closed, no same-PR merge sequence remains, yet the durable ledger still blocks unrelated new terminal reviews. #620 scopes the terminal boundary by **reviewed head SHA**: a prior REQUEST_CHANGES (or APPROVE) on head A must not block a fresh formal decision on head B of the **same open PR**. Same-head duplicates remain fail-closed. Open-PR lock *cleanup* remains forbidden unless the PR is truly merged/closed (#594); new-head re-review is allowed without deleting the durable lock. #693 scopes the terminal boundary by **PR number**: a terminal on open PR A must not block a fresh formal decision on open PR B on the same durable profile lock (cross-PR isolation). Correction authorization is scoped to the named prior review's PR/head and cannot unlock a different PR. This module is pure policy (no Gitea I/O). The MCP tool ``gitea_cleanup_stale_review_decision_lock`` fetches live PR state, calls these helpers, and only then clears durable state when cleanup is allowed. Manual deletion of ``~/.cache/gitea-tools/session-state/*`` is never the canonical path. """ from __future__ import annotations from datetime import datetime, timezone from typing import Any # Keep in sync with gitea_mcp_server._TERMINAL_REVIEW_ACTIONS. TERMINAL_REVIEW_ACTIONS = frozenset({"approve", "request_changes"}) def normalize_head_sha(value: Any) -> str | None: """Normalize a git SHA for equality comparison; None if empty/invalid.""" if value is None: return None text = str(value).strip().lower() if not text: return None return text def heads_equal(a: Any, b: Any) -> bool: na = normalize_head_sha(a) nb = normalize_head_sha(b) if not na or not nb: return False return na == nb def mutation_head_sha(mutation: dict | None, lock: dict | None = None) -> str | None: """Head SHA that bounds a live mutation (#620). Prefers fields recorded on the mutation itself. For *legacy* mutations that predate head-scoping, falls back to the lock's ``ready_expected_head_sha`` only when that ready binding is for the same PR number (the frozen durable PR #619-style ledger). """ if not isinstance(mutation, dict): return None for key in ("head_sha", "expected_head_sha", "reviewed_head_sha"): head = normalize_head_sha(mutation.get(key)) if head: return head if not isinstance(lock, dict): return None if lock.get("ready_pr_number") != mutation.get("pr_number"): return None return normalize_head_sha(lock.get("ready_expected_head_sha")) def last_terminal_mutation(lock: dict | None) -> dict | None: """Return the last terminal live mutation on *lock*, or None.""" if not lock: return None terminals = [ m for m in (lock.get("live_mutations") or []) if isinstance(m, dict) and m.get("action") in TERMINAL_REVIEW_ACTIONS ] return terminals[-1] if terminals else None def correction_applies( lock: dict | None, *, pr_number: int | None = None, expected_head_sha: str | None = None, ) -> bool: """True when operator correction is active and scoped to the target (#693). Global ``correction_authorized`` alone is not enough: correction must name the same PR (and head when recorded) as the decision being re-opened. Missing scope fields on legacy locks fail closed (do not apply). """ if not lock or not lock.get("correction_authorized"): return False corr_pr = lock.get("correction_pr_number") if corr_pr is None: # Legacy unscoped correction — refuse as generic unlock (#693). return False if pr_number is not None and int(corr_pr) != int(pr_number): return False corr_head = normalize_head_sha(lock.get("correction_head_sha")) target = normalize_head_sha(expected_head_sha) if corr_head and target and not heads_equal(corr_head, target): return False return True def prior_live_mutations_block_boundary( lock: dict | None, *, pr_number: int, expected_head_sha: str | None, ) -> bool: """True when prior live mutations block a new decision for PR+head (#620/#693). Historical terminals on a **different head of the same PR** do not block. Terminals on a **different PR** do not block (#693 cross-PR isolation). Same PR + same head (or same PR without a comparable head SHA) blocks unless a scoped correction applies. Head resolution uses ``mutation_head_sha(m, lock)`` so pre-#620 ledgers that only stored ``ready_expected_head_sha`` still compare correctly (PR #619-style). """ if not lock: return False if correction_applies( lock, pr_number=pr_number, expected_head_sha=expected_head_sha ): return False prior = list(lock.get("live_mutations") or []) if not prior: return False target = normalize_head_sha(expected_head_sha) for m in prior: if not isinstance(m, dict): return True m_pr = m.get("pr_number") if m_pr is None: return True # fail closed — unscoped mutation if int(m_pr) != int(pr_number): # #693: foreign-PR history on the shared durable lock does not block. continue m_head = mutation_head_sha(m, lock) if ( target and m_head and not heads_equal(m_head, target) ): # Same open PR, different reviewed head — historical boundary only. continue return True return False def terminal_boundary_allows_fresh_decision( lock: dict | None, *, pr_number: int, expected_head_sha: str | None, operation: str, ) -> bool: """Whether #332 hard-stop should yield for a new PR+head boundary (#620/#693). For ``mark_ready`` / ``review`` / ``resume``: * **same PR, different head** — allowed (#620) * **different PR than last terminal** — allowed (#693 cross-PR isolation) * **same PR, same head** — not allowed (unless scoped correction) Merge is never reopened by head change or cross-PR isolation (approved head merge path is separate). """ if operation not in ("mark_ready", "review", "resume"): return False if not lock: return False if correction_applies( lock, pr_number=pr_number, expected_head_sha=expected_head_sha ): return True last = last_terminal_mutation(lock) if last is None: return True last_pr = last.get("pr_number") if last_pr is None: return False if int(last_pr) != int(pr_number): # #693: last terminal belongs to another PR — do not hard-stop this PR. return True locked_head = mutation_head_sha(last, lock) target = normalize_head_sha(expected_head_sha) if not locked_head or not target: return False return not heads_equal(locked_head, target) def backfill_terminal_heads_from_ready(lock: dict) -> dict: """Stamp head_sha onto legacy terminal mutations before overwriting ready_*. Called when marking a fresh decision on a new head so historical rows keep their original boundary after ``ready_expected_head_sha`` advances (#620). """ if not isinstance(lock, dict): return lock ready_pr = lock.get("ready_pr_number") ready_head = normalize_head_sha(lock.get("ready_expected_head_sha")) if ready_pr is None or not ready_head: return lock mutations = list(lock.get("live_mutations") or []) changed = False for m in mutations: if not isinstance(m, dict): continue if m.get("action") not in TERMINAL_REVIEW_ACTIONS: continue if m.get("pr_number") != ready_pr: continue if mutation_head_sha(m): continue m["head_sha"] = ready_head changed = True if changed: lock["live_mutations"] = mutations return lock def classify_pr_live_state(pr_live: dict | None) -> dict[str, Any]: """Normalize a Gitea PR payload into merge/closed flags.""" pr = pr_live or {} merged = bool(pr.get("merged") or pr.get("merged_at")) state = (pr.get("state") or "").strip().lower() or None closed = state == "closed" head = pr.get("head") if isinstance(pr.get("head"), dict) else {} head_sha = normalize_head_sha( pr.get("head_sha") or pr.get("head_commit_sha") or head.get("sha") ) return { "pr_state": state, "pr_merged": merged, "pr_merged_or_closed": merged or closed, "merge_commit_sha": pr.get("merge_commit_sha") or pr.get("merged_commit_sha"), "current_pr_head_sha": head_sha, } def lock_summary(lock: dict | None) -> dict[str, Any] | None: """LLM-safe summary of a decision lock (no secrets).""" if lock is None: return None last = last_terminal_mutation(lock) mutations = list(lock.get("live_mutations") or []) last_head = mutation_head_sha(last, lock) if last else None return { "task": lock.get("task"), "remote": lock.get("remote"), "org": lock.get("org") or lock.get("ready_org"), "repo": lock.get("repo") or lock.get("ready_repo"), "profile_identity": lock.get("profile_identity") or lock.get("session_profile_lock") or lock.get("session_profile"), "session_profile": lock.get("session_profile"), "ready_pr_number": lock.get("ready_pr_number"), "ready_action": lock.get("ready_action"), "ready_expected_head_sha": normalize_head_sha( lock.get("ready_expected_head_sha") ), "live_mutations_count": len(mutations), "last_terminal": ( { "pr_number": last.get("pr_number"), "action": last.get("action"), "review_id": last.get("review_id"), "head_sha": last_head, } if last else None ), "locked_head_sha": last_head, "correction_authorized": bool(lock.get("correction_authorized")), "updated_at": lock.get("updated_at") or lock.get("recorded_at"), } def assess_stale_review_decision_lock( lock: dict | None, *, pr_live: dict | None = None, pr_lookup_error: str | None = None, active_profile_identity: str | None = None, current_pr_head_sha: str | None = None, ) -> dict[str, Any]: """Assess whether a durable review-decision lock is stale/moot (#594/#620). *pr_live* must be the live Gitea payload for the **last terminal mutation's PR**. When no lock / no terminal mutation exists, cleanup is not applicable. When the PR is still open (or lookup fails), cleanup is forbidden (#594). Open PR + different live head reports ``stale_by_head`` / ``fresh_review_on_current_head_allowed`` (#620) without enabling cleanup. """ summary = lock_summary(lock) result: dict[str, Any] = { "has_lock": lock is not None, "lock_summary": summary, "last_terminal_pr": None, "last_terminal_action": None, "locked_head_sha": None, "current_pr_head_sha": normalize_head_sha(current_pr_head_sha), "stale_by_head": False, "fresh_review_on_current_head_allowed": False, "is_moot": False, "cleanup_allowed": False, "profile_match": True, "pr_state": None, "pr_merged": False, "pr_merged_or_closed": False, "merge_commit_sha": None, "reasons": [], "recovery_tool": "gitea_cleanup_stale_review_decision_lock", } if lock is None: result["reasons"].append("no review decision lock present") return result # Profile identity gate (caller may re-check with env; pure assess still # reports mismatch when active identity is supplied). stored = ( (lock.get("profile_identity") or lock.get("session_profile_lock") or "") .strip() ) active = (active_profile_identity or "").strip() if active and stored and active != stored: result["profile_match"] = False result["reasons"].append( "review decision lock profile identity does not match active " f"profile '{active}' (fail closed, #594)" ) return result last = last_terminal_mutation(lock) if last is None: result["reasons"].append( "review decision lock has no terminal live mutations; nothing to clear" ) return result pr_number = last.get("pr_number") action = last.get("action") locked_head = mutation_head_sha(last, lock) result["last_terminal_pr"] = pr_number result["last_terminal_action"] = action result["locked_head_sha"] = locked_head if pr_number is None: result["reasons"].append( "last terminal mutation missing pr_number; refuse cleanup (fail closed)" ) return result if pr_lookup_error: result["reasons"].append( f"could not load live state for PR #{pr_number}: {pr_lookup_error} " "(fail closed, #594)" ) return result if pr_live is None: result["reasons"].append( f"live PR state required for terminal PR #{pr_number} before cleanup " "(fail closed, #594)" ) return result live = classify_pr_live_state(pr_live) current_head = normalize_head_sha( current_pr_head_sha or live.get("current_pr_head_sha") ) result.update( { "pr_state": live["pr_state"], "pr_merged": live["pr_merged"], "pr_merged_or_closed": live["pr_merged_or_closed"], "merge_commit_sha": live["merge_commit_sha"], "current_pr_head_sha": current_head, } ) if not live["pr_merged_or_closed"]: stale_by_head = bool( locked_head and current_head and not heads_equal(locked_head, current_head) ) result["stale_by_head"] = stale_by_head # Fresh formal decision is allowed on the new head without cleanup (#620). result["fresh_review_on_current_head_allowed"] = stale_by_head if stale_by_head: result["reasons"].append( f"terminal {action} on PR #{pr_number} is bound to head " f"{locked_head[:12]}…; live head is {current_head[:12]}… — " "fresh formal review on current head is allowed without " "cleanup (fail closed for cleanup, #620); #332 same-head " "duplicate protection remains" ) else: result["reasons"].append( f"terminal review mutation on open PR #{pr_number} is still active " f"({action}); #332 hard-stop remains — cleanup forbidden (#594)" ) return result # Merged or closed: lock is moot. Same-PR merge continuation is impossible. result["is_moot"] = True result["cleanup_allowed"] = True result["stale_by_head"] = False result["fresh_review_on_current_head_allowed"] = False result["reasons"].append( f"terminal mutation ({action} on PR #{pr_number}) is moot: PR is " f"{'merged' if live['pr_merged'] else 'closed'}; canonical cleanup allowed (#594)" ) return result def build_cleanup_audit_record( *, assessment: dict[str, Any], actor_username: str | None, profile_name: str | None, applied: bool, ) -> dict[str, Any]: """Structured durable audit payload for a cleanup attempt.""" last = (assessment.get("lock_summary") or {}).get("last_terminal") or {} return { "event": "stale_review_decision_lock_cleanup", "issue_ref": "#594", "applied": bool(applied), "timestamp": datetime.now(timezone.utc).isoformat(), "actor_username": actor_username, "profile_name": profile_name, "last_terminal_pr": assessment.get("last_terminal_pr") or last.get("pr_number"), "last_terminal_action": assessment.get("last_terminal_action") or last.get("action"), "pr_state": assessment.get("pr_state"), "pr_merged": assessment.get("pr_merged"), "pr_merged_or_closed": assessment.get("pr_merged_or_closed"), "merge_commit_sha": assessment.get("merge_commit_sha"), "prior_live_mutations_count": ( (assessment.get("lock_summary") or {}).get("live_mutations_count") ), "prior_profile_identity": ( (assessment.get("lock_summary") or {}).get("profile_identity") ), "cleanup_allowed": assessment.get("cleanup_allowed"), "is_moot": assessment.get("is_moot"), "reasons": list(assessment.get("reasons") or []), } def format_cleanup_audit_comment(audit: dict[str, Any]) -> str: """Markdown body for a durable Gitea audit comment after cleanup.""" status = "APPLIED" if audit.get("applied") else "ASSESSED (not applied)" lines = [ "## Stale #332 review-decision lock cleanup (#594)", "", f"Status: **{status}**", "", f"- actor: `{audit.get('actor_username')}`", f"- profile: `{audit.get('profile_name')}`", f"- timestamp: `{audit.get('timestamp')}`", f"- last terminal: `{audit.get('last_terminal_action')}` on " f"PR #{audit.get('last_terminal_pr')}", f"- PR state: `{audit.get('pr_state')}` " f"(merged={audit.get('pr_merged')})", f"- merge_commit_sha: `{audit.get('merge_commit_sha')}`", f"- prior live_mutations_count: " f"`{audit.get('prior_live_mutations_count')}`", f"- prior profile_identity: `{audit.get('prior_profile_identity')}`", "", "Manual deletion of session-state files is **not** the workflow.", "This path only clears a lock when the referenced PR is merged/closed.", ] return "\n".join(lines) # --- #693 classification / recovery / correction audit ----------------------- # Deterministic classification labels for diagnostics and recovery routing. CLASS_NO_LOCK = "no_lock" CLASS_READY_FOR_TARGET = "ready_for_target" CLASS_IN_PROGRESS_SAME_HEAD = "in_progress_same_head" CLASS_TERMINAL_ACTIVE_SAME_HEAD = "terminal_active_same_head" CLASS_STALE_SUPERSEDED_HEAD = "stale_superseded_head" CLASS_FOREIGN_PR_TERMINAL = "foreign_pr_terminal" CLASS_MOOT_MERGED_CLOSED = "moot_merged_closed" CLASS_CORRECTION_ACTIVE = "correction_active" CLASS_SESSION_OR_PROFILE_MISMATCH = "session_or_profile_mismatch" CLASS_AMBIGUOUS = "ambiguous" def classify_review_decision_lock( lock: dict | None, *, target_pr_number: int | None = None, target_head_sha: str | None = None, pr_live: dict | None = None, pr_lookup_error: str | None = None, active_profile_identity: str | None = None, session_blockers: list[str] | None = None, ) -> dict[str, Any]: """Deterministic review-decision-lock classification (#693). Returns classification, exact next_action, owner/evidence fields, and whether mark_final / cleanup / correction are appropriate. """ summary = lock_summary(lock) target_head = normalize_head_sha(target_head_sha) result: dict[str, Any] = { "classification": CLASS_NO_LOCK, "has_lock": lock is not None, "lock_summary": summary, "target_pr_number": target_pr_number, "target_head_sha": target_head, "last_terminal_pr": None, "last_terminal_action": None, "locked_head_sha": None, "owner_profile_identity": (summary or {}).get("profile_identity"), "session_profile": (summary or {}).get("session_profile"), "updated_at": (summary or {}).get("updated_at"), "correction_authorized": bool((lock or {}).get("correction_authorized")), "correction_pr_number": (lock or {}).get("correction_pr_number"), "correction_head_sha": normalize_head_sha( (lock or {}).get("correction_head_sha") ), "mark_final_allowed": True, "cleanup_allowed": False, "correction_eligible": False, "next_action": "gitea_resolve_task_capability(task='review_pr') then mark_final", "reasons": [], "evidence": {}, } if lock is None: result["reasons"].append("no review decision lock present") return result session_blockers = list(session_blockers or []) if session_blockers: result["classification"] = CLASS_SESSION_OR_PROFILE_MISMATCH result["mark_final_allowed"] = False result["next_action"] = ( "reconnect matching prgs-reviewer session or wait for lock TTL; " "do not use gitea_authorize_review_correction as unlock; " "do not delete session-state files" ) result["reasons"].extend(session_blockers) return result stored = ( (lock.get("profile_identity") or lock.get("session_profile_lock") or "") .strip() ) active = (active_profile_identity or "").strip() if active and stored and active != stored: result["classification"] = CLASS_SESSION_OR_PROFILE_MISMATCH result["mark_final_allowed"] = False result["next_action"] = ( f"switch to profile '{stored}' or wait for moot cleanup; " "do not use correction authorization as a generic unlock" ) result["reasons"].append( f"lock profile '{stored}' does not match active '{active}'" ) return result last = last_terminal_mutation(lock) if last is None: ready_pr = lock.get("ready_pr_number") ready_head = normalize_head_sha(lock.get("ready_expected_head_sha")) if ( target_pr_number is not None and ready_pr == target_pr_number and ready_head and target_head and heads_equal(ready_head, target_head) and lock.get("final_review_decision_ready") ): result["classification"] = CLASS_IN_PROGRESS_SAME_HEAD result["mark_final_allowed"] = True # idempotent re-mark result["next_action"] = ( "gitea_submit_pr_review with final_review_decision_ready=true " "for the ready PR/head" ) result["reasons"].append( "final decision already marked ready for this PR/head (idempotent)" ) return result result["classification"] = CLASS_READY_FOR_TARGET result["next_action"] = "gitea_mark_final_review_decision then submit" result["reasons"].append("no terminal live mutations; mark_final allowed") return result last_pr = last.get("pr_number") last_action = last.get("action") locked_head = mutation_head_sha(last, lock) result["last_terminal_pr"] = last_pr result["last_terminal_action"] = last_action result["locked_head_sha"] = locked_head result["evidence"] = { "last_review_id": last.get("review_id"), "live_mutations_count": len(lock.get("live_mutations") or []), } if correction_applies( lock, pr_number=target_pr_number, expected_head_sha=target_head ): result["classification"] = CLASS_CORRECTION_ACTIVE result["mark_final_allowed"] = True result["next_action"] = ( "gitea_mark_final_review_decision for the correction-scoped PR/head, " "then submit once" ) result["reasons"].append( f"scoped correction active for PR #{lock.get('correction_pr_number')}" ) return result # Live state of last terminal PR when provided. live = None if pr_lookup_error: result["classification"] = CLASS_AMBIGUOUS result["mark_final_allowed"] = False result["next_action"] = ( "retry diagnosis after live PR state is available; " "fail closed under ambiguous evidence" ) result["reasons"].append(f"live PR lookup failed: {pr_lookup_error}") return result if pr_live is not None: live = classify_pr_live_state(pr_live) result["evidence"]["last_terminal_pr_state"] = live.get("pr_state") result["evidence"]["last_terminal_pr_merged"] = live.get("pr_merged") if live.get("pr_merged_or_closed"): result["classification"] = CLASS_MOOT_MERGED_CLOSED result["cleanup_allowed"] = True result["mark_final_allowed"] = target_pr_number is not None and ( int(last_pr) != int(target_pr_number) if last_pr is not None and target_pr_number is not None else True ) result["next_action"] = ( "gitea_cleanup_stale_review_decision_lock(apply=true, " f"expected_terminal_pr={last_pr}) then mark_final on the target PR" ) result["reasons"].append( f"terminal on PR #{last_pr} is moot (merged/closed); cleanup allowed" ) return result if target_pr_number is None: result["classification"] = CLASS_AMBIGUOUS result["mark_final_allowed"] = False result["next_action"] = "re-run diagnosis with target_pr_number" result["reasons"].append("target_pr_number required for open-PR classification") return result if last_pr is not None and int(last_pr) != int(target_pr_number): # Cross-PR isolation: foreign terminal is history, not a block. result["classification"] = CLASS_FOREIGN_PR_TERMINAL result["mark_final_allowed"] = True result["correction_eligible"] = False # must not use correction to "unlock" result["next_action"] = ( f"gitea_mark_final_review_decision(pr_number={target_pr_number}, ...) " f"— foreign terminal on PR #{last_pr} does not block (#693); " "do not call gitea_authorize_review_correction for cross-PR unlock" ) result["reasons"].append( f"last terminal is {last_action} on foreign open/closed PR #{last_pr}; " f"target PR #{target_pr_number} is isolated (#693)" ) return result # Same PR as target. if ( locked_head and target_head and not heads_equal(locked_head, target_head) ): result["classification"] = CLASS_STALE_SUPERSEDED_HEAD result["mark_final_allowed"] = True result["next_action"] = ( f"gitea_mark_final_review_decision with expected_head_sha=" f"{target_head} (#620 same-PR new head)" ) result["reasons"].append( f"terminal {last_action} on head {locked_head[:12]}…; target head " f"{target_head[:12]}… — fresh formal review allowed without cleanup" ) return result # Same PR + same head (or missing head comparison) — active terminal. result["classification"] = CLASS_TERMINAL_ACTIVE_SAME_HEAD result["mark_final_allowed"] = False result["correction_eligible"] = True result["next_action"] = ( "if the prior verdict was mistaken: gitea_authorize_review_correction " f"with prior_review_id={last.get('review_id')}, " f"prior_review_state={last_action}, target_pr_number={target_pr_number}, " "operator_authorized=true, then re-mark once; " "if the prior verdict is correct: stop and hand off (do not duplicate)" ) result["reasons"].append( f"terminal {last_action} already recorded for PR #{target_pr_number} " f"at this head; same-head duplicate blocked (#332/#620)" ) return result def build_precise_mark_final_failure( classification: dict[str, Any], ) -> dict[str, Any]: """One precise recovery instruction + durable issue handoff payload (#693 AC4/8).""" cls = classification.get("classification") next_action = classification.get("next_action") or ( "stop and create a durable Gitea issue with lock evidence" ) reasons = list(classification.get("reasons") or []) reasons.append(f"exact_next_action: {next_action}") handoff = { "required": cls in ( CLASS_AMBIGUOUS, CLASS_SESSION_OR_PROFILE_MISMATCH, CLASS_TERMINAL_ACTIVE_SAME_HEAD, ) and not classification.get("mark_final_allowed"), "title_hint": ( "Review-decision-lock recovery failed during formal review" ), "classification": cls, "exact_next_action": next_action, "owner_profile_identity": classification.get("owner_profile_identity"), "last_terminal_pr": classification.get("last_terminal_pr"), "last_terminal_action": classification.get("last_terminal_action"), "locked_head_sha": classification.get("locked_head_sha"), "target_pr_number": classification.get("target_pr_number"), "target_head_sha": classification.get("target_head_sha"), "evidence": classification.get("evidence") or {}, "instruction": ( "If recovery cannot complete with the exact_next_action above, " "create or update a durable Gitea issue with this payload and stop; " "do not delete session-state files; do not misuse correction as unlock." ), } return { "reasons": reasons, "classification": cls, "exact_next_action": next_action, "durable_issue_handoff": handoff, } def build_correction_audit_record( *, prior_review_id: int | None, prior_review_state: str | None, target_pr_number: int | None, target_head_sha: str | None, reason: str | None, actor_username: str | None, profile_name: str | None, authorized: bool, ) -> dict[str, Any]: """Structured durable audit for review-correction authorization (#693).""" return { "event": "review_correction_authorization", "issue_ref": "#693", "authorized": bool(authorized), "timestamp": datetime.now(timezone.utc).isoformat(), "actor_username": actor_username, "profile_name": profile_name, "prior_review_id": prior_review_id, "prior_review_state": prior_review_state, "target_pr_number": target_pr_number, "target_head_sha": normalize_head_sha(target_head_sha), "reason": reason, "scope": "same_pr_head_only", } def format_correction_audit_comment(audit: dict[str, Any]) -> str: """Markdown body for a thread-visible correction authorization audit.""" status = "AUTHORIZED" if audit.get("authorized") else "DENIED" lines = [ "## Review correction authorization audit (#693)", "", f"Status: **{status}**", "", f"- actor: `{audit.get('actor_username')}`", f"- profile: `{audit.get('profile_name')}`", f"- timestamp: `{audit.get('timestamp')}`", f"- prior_review_id: `{audit.get('prior_review_id')}`", f"- prior_review_state: `{audit.get('prior_review_state')}`", f"- target_pr: `#{audit.get('target_pr_number')}`", f"- target_head_sha: `{audit.get('target_head_sha')}`", f"- reason: {audit.get('reason')}", f"- scope: `{audit.get('scope')}` (cannot unlock a different PR)", "", "This is **not** a generic decision-lock unlock. Cross-PR recovery " "uses isolation (#693) or moot cleanup (#594), never correction.", ] return "\n".join(lines) def assess_open_pr_decision_lock_recovery( lock: dict | None, *, target_pr_number: int, target_head_sha: str | None, last_terminal_pr_live: dict | None = None, pr_lookup_error: str | None = None, active_profile_identity: str | None = None, session_blockers: list[str] | None = None, controller_recovery_authorized: bool = False, ) -> dict[str, Any]: """Assess guarded recovery for decision locks affecting an open target PR (#693). Recovery here means *workflow recovery routing*, not necessarily lock wipe. Foreign-PR terminals are recoverable by isolation (mark_final allowed). Moot terminals use #594 cleanup. Same-head active terminals refuse wipe. """ classification = classify_review_decision_lock( lock, target_pr_number=target_pr_number, target_head_sha=target_head_sha, pr_live=last_terminal_pr_live, pr_lookup_error=pr_lookup_error, active_profile_identity=active_profile_identity, session_blockers=session_blockers, ) cls = classification["classification"] recovery_allowed = False recovery_mode = None if cls == CLASS_FOREIGN_PR_TERMINAL: recovery_allowed = True recovery_mode = "cross_pr_isolation" elif cls == CLASS_STALE_SUPERSEDED_HEAD: recovery_allowed = True recovery_mode = "same_pr_new_head" elif cls == CLASS_MOOT_MERGED_CLOSED: recovery_allowed = True recovery_mode = "moot_cleanup" elif cls == CLASS_READY_FOR_TARGET: recovery_allowed = True recovery_mode = "none_required" elif cls == CLASS_CORRECTION_ACTIVE: recovery_allowed = True recovery_mode = "scoped_correction" elif cls == CLASS_TERMINAL_ACTIVE_SAME_HEAD: recovery_allowed = False recovery_mode = "correction_only_if_mistake" else: recovery_allowed = False recovery_mode = "fail_closed" if recovery_mode == "moot_cleanup" and not controller_recovery_authorized: # Cleanup still needs apply + capability; assess reports path. pass return { **classification, "recovery_allowed": recovery_allowed, "recovery_mode": recovery_mode, "controller_recovery_authorized": bool(controller_recovery_authorized), "manual_file_delete_forbidden": True, "correction_as_generic_unlock_forbidden": True, }