"""Two-comment workflow validation for Controller Handoff + Thread State Ledger (#507). Validates the paired reporting pattern without replacing the CTH umbrella (#505) or the broader lifecycle ledger (#494/#495). """ from __future__ import annotations import re from typing import Any CONTROLLER_HANDOFF_TAG = "[CONTROLLER HANDOFF]" THREAD_STATE_LEDGER_TAG = "[THREAD STATE LEDGER]" BLOCKER_CLASSIFICATIONS = frozenset({ "code blocker", "test blocker", "merge conflict", "stale head", "permission/capability blocker", "environment/tooling blocker", "process/rule blocker", "queue/lease blocker", "duplicate/canonicalization blocker", "no blocker", }) _PRECISE_APPROVE_PHRASES = ( "approve verdict prepared locally", "approved review posted to gitea", "request_changes posted to gitea", ) _PRECISE_MERGE_PHRASES = ( "merge performed", "merge not performed", ) _PRECISE_SERVER_PHRASES = ( "server-side state changed", "no server-side state changed", ) _AMBIGUOUS_STANDALONE_TERMS = ( "approved", "ready", "queued", "blocked", "done", "merged", "submitted", "validated", ) _HANDOFF_TAG_RE = re.compile(r"\[CONTROLLER\s+HANDOFF\]", re.IGNORECASE) _LEDGER_TAG_RE = re.compile(r"\[THREAD\s+STATE\s+LEDGER\]", re.IGNORECASE) _LEGACY_HANDOFF_RE = re.compile(r"^##\s*Controller Handoff\s*$", re.I | re.M) _MUTATION_LEDGER_RE = re.compile( r"server-side mutation ledger\s*:", re.IGNORECASE, ) _BLOCKER_CLASS_RE = re.compile( r"blocker\s+classification\s*:\s*(.+)", re.IGNORECASE, ) _LEDGER_REQUIRED_SECTIONS = ( "what is true now", "what changed", "what is blocked", "who/what acts next", ) _LEDGER_REQUIRED_FIELDS = ( "server-side decision state", "local verdict/state", "next actor", "required action", ) def _lower(text: str) -> str: return (text or "").lower() def has_controller_handoff_marker(text: str) -> bool: return bool(_HANDOFF_TAG_RE.search(text or "")) def has_thread_state_ledger_marker(text: str) -> bool: return bool(_LEDGER_TAG_RE.search(text or "")) def _has_tagged_handoff(text: str) -> bool: return has_controller_handoff_marker(text) def _has_tagged_ledger(text: str) -> bool: return has_thread_state_ledger_marker(text) def _has_legacy_handoff(text: str) -> bool: return bool(_LEGACY_HANDOFF_RE.search(text or "")) def _section_after_tag(text: str, tag_pattern: re.Pattern[str]) -> str | None: text = text or "" match = tag_pattern.search(text) if not match: return None start = match.start() other_tags = ( _HANDOFF_TAG_RE, _LEDGER_TAG_RE, ) end = len(text) for other in other_tags: if other.pattern == tag_pattern.pattern: continue later = other.search(text, match.end()) if later: end = min(end, later.start()) return text[start:end] def _extract_mutation_ledger(text: str) -> str: lines = (text or "").splitlines() capture = False chunks: list[str] = [] for line in lines: stripped = line.strip().lower() if "server-side mutation ledger" in stripped: capture = True continue if capture: if stripped.startswith("local-only") or stripped.startswith("blockers:"): break chunks.append(line) return "\n".join(chunks) def _ambiguous_term_violations(text: str, *, scoped: bool) -> list[str]: if not scoped: return [] reasons: list[str] = [] lower = _lower(text) for term in _AMBIGUOUS_STANDALONE_TERMS: if not re.search(rf"\b{re.escape(term)}\b", lower): continue if term == "approved": if any(p in lower for p in _PRECISE_APPROVE_PHRASES): continue if "review decision:" in lower and "approve" in lower: continue if "approval_at_current_head" in lower: continue elif term == "merged": if any(p in lower for p in _PRECISE_MERGE_PHRASES): continue if "merge result:" in lower: continue elif term == "blocked": if any(c in lower for c in BLOCKER_CLASSIFICATIONS): continue if "blocker classification:" in lower: continue reasons.append( f"ambiguous standalone term '{term}' without precise state language" ) return reasons def _mutation_contradiction_reasons(handoff_text: str) -> list[str]: reasons: list[str] = [] lower = _lower(handoff_text) ledger = _lower(_extract_mutation_ledger(handoff_text)) no_server = ( "no server-side state changed" in ledger or "none — no server-side" in ledger or re.search(r"\bnone\b", ledger) ) claims_posted = "approved review posted to gitea" in lower claims_merge = "merge performed" in lower or ( re.search(r"\bmerged\b", lower) and "merge not performed" not in lower ) if claims_posted and no_server: reasons.append( "narrative claims APPROVED review posted but mutation ledger " "shows no server-side state changed" ) if claims_merge and no_server: reasons.append( "narrative claims merge but mutation ledger lacks merge proof" ) if ( "approved review posted to gitea" in lower and "no server-side state changed" in ledger ): reasons.append( "mutation ledger contradicts APPROVED review posted claim" ) return reasons def _blocked_without_gate_reasons(handoff_text: str, ledger_text: str) -> list[str]: reasons: list[str] = [] combined = _lower(handoff_text) + "\n" + _lower(ledger_text) if not re.search(r"\bblocked\b", combined): return reasons has_classification = bool(_BLOCKER_CLASS_RE.search(ledger_text or "")) has_gate = any( marker in combined for marker in ( "exact gate", "failing gate", "blocker classification:", "process/rule blocker", "environment/tooling blocker", "permission/capability blocker", ) ) if not has_classification and not has_gate: reasons.append( "handoff or ledger says blocked but does not identify exact " "failing gate or blocker classification" ) return reasons def _local_server_separation_reasons(ledger_text: str) -> list[str]: reasons: list[str] = [] lower = _lower(ledger_text) server_line = "" local_line = "" for line in (ledger_text or "").splitlines(): stripped = line.strip().lower() if stripped.startswith("- server-side decision state:"): server_line = stripped if stripped.startswith("- local verdict/state:"): local_line = stripped if not server_line or not local_line: return reasons if "approve verdict prepared locally" in server_line: reasons.append( "local-only verdict incorrectly listed under server-side " "decision state" ) if "approved review posted to gitea" in local_line: reasons.append( "server-side decision incorrectly listed under local verdict/state" ) return reasons def _ledger_field_reasons(ledger_text: str) -> list[str]: reasons: list[str] = [] lower = _lower(ledger_text) for section in _LEDGER_REQUIRED_SECTIONS: if section not in lower: reasons.append(f"thread state ledger missing section '{section}'") for field in _LEDGER_REQUIRED_FIELDS: if field not in lower: reasons.append(f"thread state ledger missing field '{field}'") match = _BLOCKER_CLASS_RE.search(ledger_text or "") if not match: reasons.append("thread state ledger missing blocker classification") else: value = match.group(1).strip().lower().rstrip(".") if value not in BLOCKER_CLASSIFICATIONS: reasons.append( f"blocker classification '{value}' is not a recognized value" ) if "do not do:" not in lower: reasons.append( "thread state ledger missing explicit 'Do not do' guidance" ) return reasons def _assessment( reasons: list[str], *, performed: bool = True, ) -> dict[str, Any]: return { "proven": not reasons, "block": bool(reasons), "reasons": reasons, "performed": performed, } def assess_controller_handoff_comment(body: str) -> dict[str, Any]: """Validate a standalone ``[CONTROLLER HANDOFF]`` comment body.""" text = body or "" if not _has_tagged_handoff(text): return _assessment([], performed=False) reasons: list[str] = [] if not _MUTATION_LEDGER_RE.search(text): reasons.append( "controller handoff missing 'Server-side mutation ledger' section" ) reasons.extend(_ambiguous_term_violations(text, scoped=True)) reasons.extend(_mutation_contradiction_reasons(text)) return _assessment(reasons) def assess_thread_state_ledger_comment(body: str) -> dict[str, Any]: """Validate a standalone ``[THREAD STATE LEDGER]`` comment body.""" text = body or "" if not _has_tagged_ledger(text): return _assessment([], performed=False) reasons = _ledger_field_reasons(text) reasons.extend(_ambiguous_term_violations(text, scoped=True)) reasons.extend(_local_server_separation_reasons(text)) return _assessment(reasons) def assess_two_comment_pair( handoff_body: str, ledger_body: str, ) -> dict[str, Any]: """Validate paired Gitea comments (handoff then ledger).""" reasons: list[str] = [] handoff = handoff_body or "" ledger = ledger_body or "" if _has_tagged_handoff(handoff) and not _has_tagged_ledger(ledger): reasons.append( "controller handoff posted without follow-up THREAD STATE LEDGER" ) handoff_result = assess_controller_handoff_comment(handoff) ledger_result = assess_thread_state_ledger_comment(ledger) reasons.extend(handoff_result.get("reasons") or []) reasons.extend(ledger_result.get("reasons") or []) reasons.extend(_blocked_without_gate_reasons(handoff, ledger)) if handoff_result.get("performed") or ledger_result.get("performed"): performed = True else: performed = False return _assessment(reasons, performed=performed) def assess_two_comment_workflow(report_text: str) -> dict[str, Any]: """Validate combined final-report text containing both comment types.""" text = report_text or "" if not (_has_tagged_handoff(text) or _has_tagged_ledger(text)): if _has_legacy_handoff(text): return _assessment([], performed=False) return _assessment([], performed=False) reasons: list[str] = [] if _has_tagged_handoff(text) and not _has_tagged_ledger(text): reasons.append( "tagged controller handoff present without THREAD STATE LEDGER" ) handoff_section = _section_after_tag(text, _HANDOFF_TAG_RE) or text ledger_section = _section_after_tag(text, _LEDGER_TAG_RE) or "" reasons.extend( (assess_controller_handoff_comment(handoff_section).get("reasons") or []) ) if ledger_section: reasons.extend( (assess_thread_state_ledger_comment(ledger_section).get("reasons") or []) ) reasons.extend(_blocked_without_gate_reasons(handoff_section, ledger_section)) reasons.extend(_mutation_contradiction_reasons(handoff_section)) reasons.extend(_local_server_separation_reasons(ledger_section)) return _assessment(reasons) def findings_for_final_report(report_text: str) -> list[dict[str, str]]: """Return final_report_validator findings for the two-comment workflow.""" from final_report_validator import validator_finding if not (_has_tagged_handoff(report_text) or _has_tagged_ledger(report_text)): return [] result = assess_two_comment_workflow(report_text) if not result.get("reasons"): return [] findings: list[dict[str, str]] = [] for reason in result.get("reasons") or []: severity = "block" if result.get("block") else "downgrade" findings.append( validator_finding( "shared.two_comment_workflow", severity, "Thread State Ledger", reason, "add a [THREAD STATE LEDGER] after [CONTROLLER HANDOFF] " "with precise server-side vs local state language", ) ) return findings