fix(workflow): cross-profile decision-lock cleanup and irrecoverable provenance (#709)

Prevent merger-local empty decision locks from standing in for reviewer
terminal cleanup, refuse silent re-init overwrite of unresolved terminal
evidence, record post-merge recovery-required state when audit fails, and
add a truthful irrecoverable-provenance path that never claims applied=true.

Closes #709
This commit is contained in:
2026-07-14 00:54:33 -04:00
parent 1eafb757a9
commit ec5cf67771
5 changed files with 1453 additions and 27 deletions
+565 -26
View File
@@ -3357,17 +3357,28 @@ def _review_decision_session_reasons(lock: dict | None) -> list[str]:
def init_review_decision_lock(remote: str | None, task: str | None, force: bool = True):
"""Seed read-only-until-ready state for reviewer PR review tasks."""
"""Seed read-only-until-ready state for reviewer PR review tasks.
#709 AC2: never overwrite unresolved terminal decision-lock evidence with
an empty initialized lock even when *force* is True. Terminal ledgers
are cleared only via moot cleanup or sanctioned recovery/archive paths.
"""
if task != "review_pr":
return
if not force:
lock = _load_review_decision_lock()
if lock is not None:
existing = _load_review_decision_lock()
if existing is not None:
overwrite = stale_review_decision_lock.assess_init_overwrite(
existing, force=force
)
if not overwrite.get("overwrite_allowed"):
# Preserve terminal evidence; keep existing durable lock.
return
if not force:
env_lock = (os.environ.get(SESSION_PROFILE_LOCK_ENV) or "").strip()
stored_lock = (lock.get("session_profile_lock") or "").strip()
same_remote = lock.get("remote") == remote
stored_lock = (lock_get_session_profile_lock(existing)).strip()
same_remote = existing.get("remote") == remote
same_profile = (not env_lock or not stored_lock or env_lock == stored_lock)
if same_remote and same_profile and not _review_decision_session_reasons(lock):
if same_remote and same_profile and not _review_decision_session_reasons(existing):
return
review_workflow_load.clear_review_workflow_load()
profile = get_profile()
@@ -3400,6 +3411,17 @@ def init_review_decision_lock(remote: str | None, task: str | None, force: bool
})
def lock_get_session_profile_lock(lock: dict | None) -> str:
if not isinstance(lock, dict):
return ""
return (
lock.get("session_profile_lock")
or lock.get("profile_identity")
or lock.get("session_profile")
or ""
)
def _review_workflow_load_gate_reasons() -> list[str]:
"""Fail closed when canonical review workflow was not loaded (#389)."""
return review_workflow_load.review_workflow_load_blockers(PROJECT_ROOT)
@@ -4470,6 +4492,288 @@ def gitea_authorize_review_correction(
return {"authorized": True, "correction_reason": reason, "reasons": []}
def _record_post_merge_decision_recovery(
*,
pr_number: int,
head_sha: str | None,
merge_commit_sha: str | None,
target_profile_identity: str | None,
failed_step: str,
error: str | None,
remote: str | None,
org: str | None,
repo: str | None,
) -> dict:
"""Persist durable post-merge recovery-required state (#709 AC3)."""
try:
actor = None
try:
if remote in REMOTES:
h, _, _ = _resolve(remote, None, org, repo)
actor = _authenticated_username(h)
except Exception:
actor = None
profile = get_profile()
profile_name = (profile.get("profile_name") or "").strip() or None
payload = stale_review_decision_lock.build_post_merge_recovery_record(
pr_number=pr_number,
head_sha=head_sha,
merge_commit_sha=merge_commit_sha,
target_profile_identity=target_profile_identity,
failed_step=failed_step,
error=error,
remote=remote,
org=org,
repo=repo,
actor_username=actor,
profile_name=profile_name,
)
# Key by merger/active profile so the merging session owns the recovery row.
binding = _decision_lock_binding()
saved = mcp_session_state.save_state(
kind=mcp_session_state.KIND_POST_MERGE_DECISION_RECOVERY,
payload=payload,
remote=remote,
org=org,
repo=repo,
profile_identity=binding.get("profile_identity"),
)
return dict(saved or payload)
except Exception as exc: # noqa: BLE001
return {"status": "recovery_record_failed", "error": _redact(str(exc))}
def _clear_decision_lock_for_profile(
*,
profile_identity: str,
pr_number: int,
expected_head_sha: str | None,
remote: str | None,
org: str | None,
repo: str | None,
) -> dict:
"""Clear one profile's durable decision lock when it targets *pr_number* approve."""
lock = mcp_session_state.load_state_for_profile(
kind=mcp_session_state.KIND_DECISION_LOCK,
profile_identity=profile_identity,
remote=remote,
org=org,
repo=repo,
skip_identity_match=True,
)
if lock is None:
return {
"profile_identity": profile_identity,
"cleared": False,
"reason": "no durable lock for profile",
}
if not stale_review_decision_lock.lock_targets_merged_pr_approval(
lock, pr_number=pr_number, expected_head_sha=expected_head_sha
):
# Also allow any terminal for this PR once merged (request_changes history).
last = stale_review_decision_lock.last_terminal_mutation(lock)
if not last or last.get("pr_number") != pr_number:
return {
"profile_identity": profile_identity,
"cleared": False,
"reason": "lock terminal does not target this PR approval",
}
# Archive then clear.
try:
mcp_session_state.save_state(
kind=mcp_session_state.KIND_DECISION_LOCK_ARCHIVE,
payload={
**dict(lock),
"archived_reason": "post_merge_cross_profile_cleanup",
"archived_for_pr": pr_number,
"recovery_critical": True,
"kind": mcp_session_state.KIND_DECISION_LOCK_ARCHIVE,
},
remote=remote or lock.get("remote"),
org=org or lock.get("org") or lock.get("ready_org"),
repo=repo or lock.get("repo") or lock.get("ready_repo"),
profile_identity=f"{profile_identity}-archive-pr{pr_number}",
)
except Exception:
pass
mcp_session_state.clear_state(
kind=mcp_session_state.KIND_DECISION_LOCK,
profile_identity=profile_identity,
remote=remote or lock.get("remote"),
org=org or lock.get("org") or lock.get("ready_org"),
repo=repo or lock.get("repo") or lock.get("ready_repo"),
)
# If this is the in-memory active profile lock, clear memory too.
active = _decision_lock_binding().get("profile_identity")
if active and active == profile_identity:
global _REVIEW_DECISION_LOCK
_REVIEW_DECISION_LOCK = None
return {
"profile_identity": profile_identity,
"cleared": True,
"reason": f"cleared terminal lock for merged PR #{pr_number}",
"prior_summary": stale_review_decision_lock.lock_summary(lock),
}
def _reconcile_decision_locks_after_merge(
*,
pr_number: int,
head_sha: str | None,
merge_commit_sha: str | None,
remote: str,
host: str,
org: str,
repo: str,
auth,
) -> dict:
"""Cross-profile decision-lock reconcile after a successful merge (#709)."""
report: dict = {
"cleared_any": False,
"profiles_scanned": [],
"cleared_profiles": [],
"audit_comment_ids": [],
"recovery_required": False,
"reason_lines": [],
"applied": False, # overall "historical applied cleanup" never claimed blindly
}
try:
actor = _authenticated_username(host)
except Exception:
actor = None
profile = get_profile()
profile_name = (profile.get("profile_name") or "").strip() or None
identities = list(mcp_session_state.list_decision_lock_profile_identities())
active = _decision_lock_binding().get("profile_identity")
if active and active not in identities:
identities.append(active)
# Always consider common role profiles so empty local merger lock cannot
# hide a reviewer terminal ledger (#709 AC1).
for candidate in ("prgs-reviewer", "prgs-merger", "prgs-author", "prgs-reconciler"):
if candidate not in identities:
identities.append(candidate)
report["profiles_scanned"] = list(identities)
for identity in identities:
try:
outcome = _clear_decision_lock_for_profile(
profile_identity=identity,
pr_number=pr_number,
expected_head_sha=head_sha,
remote=remote,
org=org,
repo=repo,
)
except Exception as exc: # noqa: BLE001
report["recovery_required"] = True
report["reason_lines"].append(
f"failed clearing decision lock for {identity}: {_redact(str(exc))}"
)
_record_post_merge_decision_recovery(
pr_number=pr_number,
head_sha=head_sha,
merge_commit_sha=merge_commit_sha,
target_profile_identity=identity,
failed_step="clear_profile_lock",
error=_redact(str(exc)),
remote=remote,
org=org,
repo=repo,
)
continue
if outcome.get("cleared"):
report["cleared_any"] = True
report["cleared_profiles"].append(identity)
report["reason_lines"].append(
f"cleared decision lock profile={identity} after merge of "
f"PR #{pr_number} (#709)"
)
# Audit publication (AC4): post and require comment id for full reconcile.
audit = stale_review_decision_lock.build_cleanup_audit_record(
assessment={
"last_terminal_pr": pr_number,
"last_terminal_action": "approve",
"pr_state": "closed",
"pr_merged": True,
"pr_merged_or_closed": True,
"merge_commit_sha": merge_commit_sha,
"cleanup_allowed": True,
"is_moot": True,
"lock_summary": outcome.get("prior_summary"),
"reasons": [outcome.get("reason") or "cleared"],
},
actor_username=actor,
profile_name=profile_name,
applied=True,
)
audit["cleanup_target_profile"] = identity
audit["issue_ref"] = "#709"
comment_block = _profile_operation_gate("gitea.pr.comment")
if comment_block:
report["recovery_required"] = True
report["reason_lines"].append(
f"audit comment blocked for {identity}: {comment_block}"
)
_record_post_merge_decision_recovery(
pr_number=pr_number,
head_sha=head_sha,
merge_commit_sha=merge_commit_sha,
target_profile_identity=identity,
failed_step="audit_comment_permission",
error=str(comment_block),
remote=remote,
org=org,
repo=repo,
)
continue
try:
body = stale_review_decision_lock.format_cleanup_audit_comment(audit)
comment_url = f"{repo_api_url(host, org, repo)}/issues/{pr_number}/comments"
with _audited(
"comment_pr",
host=host,
remote=remote,
org=org,
repo=repo,
pr_number=pr_number,
request_metadata={"source": "post_merge_decision_lock_reconcile"},
):
posted = api_request("POST", comment_url, auth, {"body": body})
cid = (posted or {}).get("id")
if not cid:
raise RuntimeError("audit comment missing id on readback")
report["audit_comment_ids"].append(cid)
report["reason_lines"].append(
f"audit comment published id={cid} for profile={identity}"
)
except Exception as exc: # noqa: BLE001
report["recovery_required"] = True
report["reason_lines"].append(
f"audit comment failed for {identity}: {_redact(str(exc))}"
)
_record_post_merge_decision_recovery(
pr_number=pr_number,
head_sha=head_sha,
merge_commit_sha=merge_commit_sha,
target_profile_identity=identity,
failed_step="audit_comment_publish",
error=_redact(str(exc)),
remote=remote,
org=org,
repo=repo,
)
if report["cleared_any"] and not report["recovery_required"]:
report["applied"] = True # this-session successful cleanup only
elif not report["cleared_any"]:
report["reason_lines"].append(
f"no cross-profile decision lock targeted PR #{pr_number} for cleanup"
)
return report
@mcp.tool()
def gitea_cleanup_stale_review_decision_lock(
apply: bool = False,
@@ -4669,12 +4973,227 @@ def gitea_cleanup_stale_review_decision_lock(
"POST", comment_url, auth, {"body": body}
)
report["audit_comment_id"] = (posted or {}).get("id")
if not report["audit_comment_id"]:
report["reconciled"] = False
report["recovery_required"] = True
report["reasons"] = list(report.get("reasons") or []) + [
"cleanup applied but audit comment id missing on readback "
"(#709 AC4 fail-closed for full reconcile)"
]
_record_post_merge_decision_recovery(
pr_number=int(assessment["last_terminal_pr"]),
head_sha=assessment.get("locked_head_sha"),
merge_commit_sha=assessment.get("merge_commit_sha"),
target_profile_identity=active_identity,
failed_step="audit_comment_missing_id",
error="comment response missing id",
remote=remote,
org=o,
repo=r,
)
else:
report["reconciled"] = True
except Exception as exc: # noqa: BLE001
report["audit_comment_error"] = _redact(str(exc))
report["reconciled"] = False
report["recovery_required"] = True
report["reasons"] = list(report.get("reasons") or []) + [
"cleanup applied but audit comment failed; recovery-required "
"recorded (#709 AC4)"
]
try:
_record_post_merge_decision_recovery(
pr_number=int(assessment["last_terminal_pr"]),
head_sha=assessment.get("locked_head_sha"),
merge_commit_sha=assessment.get("merge_commit_sha"),
target_profile_identity=active_identity,
failed_step="audit_comment_publish",
error=_redact(str(exc)),
remote=remote,
org=o,
repo=r,
)
except Exception:
pass
if post_audit_comment is False:
report["reconciled"] = False
report["reasons"] = list(report.get("reasons") or []) + [
"cleanup applied without audit comment (post_audit_comment=false); "
"not fully reconciled (#709 AC4)"
]
return report
@mcp.tool()
def gitea_record_irrecoverable_decision_lock_provenance(
pr_number: int,
reason: str,
confirmation: str = "",
operator_authorized: bool = False,
expected_head_sha: str | None = None,
incident_ref: str | None = None,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
post_audit_comment: bool = True,
) -> dict:
"""Record truthful absence of decision-lock cleanup proof (#709 AC5).
Never emits applied=true or claims historical cleanup was proven.
Requires operator_authorized=True and confirmation exactly equal to
``IRRECOVERABLE DECISION PROVENANCE PR <pr_number>``.
"""
h, o, r = _resolve(remote, host, org, repo)
expected_confirm = f"IRRECOVERABLE DECISION PROVENANCE PR {int(pr_number)}"
report = {
"success": False,
"performed": False,
"applied": False,
"historical_cleanup_proven": False,
"status": "provenance_irrecoverable",
"pr_number": pr_number,
"expected_head_sha": expected_head_sha,
"reasons": [],
"record": None,
"audit_comment_id": None,
}
read_block = _profile_operation_gate("gitea.read")
if read_block:
report["reasons"] = read_block
report["permission_report"] = _permission_block_report("gitea.read")
return report
if not operator_authorized:
report["reasons"].append(
"operator_authorized must be true for irrecoverable provenance "
"recording (fail closed, #709 AC5)"
)
return report
if (confirmation or "").strip() != expected_confirm:
report["reasons"].append(
f"confirmation must equal exactly {expected_confirm!r} (fail closed)"
)
return report
if not (reason or "").strip():
report["reasons"].append("reason is required (fail closed)")
return report
try:
actor = _authenticated_username(h)
except Exception:
actor = None
if not actor:
report["reasons"].append(
"authenticated identity could not be verified (fail closed)"
)
return report
profile = get_profile()
profile_name = (profile.get("profile_name") or "").strip() or None
# Idempotent: if matching record already exists for pr+head, return it.
binding = _decision_lock_binding()
existing = mcp_session_state.load_state(
kind=mcp_session_state.KIND_IRRECOVERABLE_DECISION_PROVENANCE,
remote=remote,
org=o,
repo=r,
profile_identity=binding.get("profile_identity"),
)
if (
isinstance(existing, dict)
and existing.get("pr_number") == pr_number
and (
not expected_head_sha
or stale_review_decision_lock.heads_equal(
existing.get("head_sha"), expected_head_sha
)
)
and existing.get("status") == "provenance_irrecoverable"
):
report["success"] = True
report["performed"] = False
report["record"] = existing
report["reasons"].append("idempotent: matching irrecoverable record already present")
return report
record = stale_review_decision_lock.build_irrecoverable_provenance_record(
pr_number=pr_number,
head_sha=expected_head_sha,
remote=remote,
org=o,
repo=r,
actor_username=actor,
profile_name=profile_name,
reason=reason.strip(),
incident_ref=incident_ref,
operator_authorized=True,
)
# Stamp kind for TTL exemption
record["kind"] = mcp_session_state.KIND_IRRECOVERABLE_DECISION_PROVENANCE
saved = mcp_session_state.save_state(
kind=mcp_session_state.KIND_IRRECOVERABLE_DECISION_PROVENANCE,
payload=record,
remote=remote,
org=o,
repo=r,
profile_identity=binding.get("profile_identity"),
)
report["record"] = dict(saved or record)
report["performed"] = True
report["success"] = True
report["reasons"].append(
"recorded provenance_irrecoverable (applied=false; historical cleanup not proven)"
)
if post_audit_comment:
comment_block = _profile_operation_gate("gitea.pr.comment") or _profile_operation_gate(
"gitea.issue.comment"
)
# Prefer issue comment capability for discussion thread.
issue_block = _profile_operation_gate("gitea.issue.comment")
if issue_block:
report["reasons"].append(f"audit comment skipped: {issue_block}")
else:
try:
body = stale_review_decision_lock.format_irrecoverable_audit_comment(
report["record"]
)
comment_url = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
with _audited(
"comment_issue",
host=h,
remote=remote,
org=o,
repo=r,
issue_number=pr_number,
request_metadata={
"source": "record_irrecoverable_decision_lock_provenance"
},
):
posted = api_request(
"POST",
comment_url,
_auth(h),
{"body": body},
)
report["audit_comment_id"] = (posted or {}).get("id")
if report["audit_comment_id"]:
# Re-save with comment id for readback completeness.
report["record"]["audit_comment_id"] = report["audit_comment_id"]
mcp_session_state.save_state(
kind=mcp_session_state.KIND_IRRECOVERABLE_DECISION_PROVENANCE,
payload=report["record"],
remote=remote,
org=o,
repo=r,
profile_identity=binding.get("profile_identity"),
)
except Exception as exc: # noqa: BLE001
report["reasons"].append(
f"audit comment failed: {_redact(str(exc))} (record still durable)"
)
return report
@mcp.tool()
def gitea_dry_run_pr_review(
pr_number: int,
@@ -5772,29 +6291,49 @@ def gitea_merge_pr(
reviewer_pr_lease.get_session_lease()
)
)
# Same-profile auto-expire (#594): if *this* profile's decision lock ends
# with an approve of the PR just merged, clear it so durable state cannot
# block later unrelated reviews. Cross-profile locks (e.g. reviewer vs
# merger) still require gitea_cleanup_stale_review_decision_lock.
# #709 AC1/AC3/AC4: reconcile decision locks after irreversible merge.
# Clears the same-profile lock when it holds the approve, and also scans
# other durable profile locks (e.g. prgs-reviewer) so merger-local empty
# init cannot leave the reviewer terminal ledger behind. Failures write a
# recovery-required record (applied=false) — merge is never undone.
try:
lock_after = _load_review_decision_lock()
last_term = stale_review_decision_lock.last_terminal_mutation(lock_after)
if (
last_term
and last_term.get("action") == "approve"
and last_term.get("pr_number") == pr_number
):
_save_review_decision_lock(None)
result["review_decision_lock_cleared_after_merge"] = True
reasons.append(
f"cleared same-profile review decision lock after merge of "
f"approved PR #{pr_number} (#594)"
)
else:
result["review_decision_lock_cleared_after_merge"] = False
reconcile = _reconcile_decision_locks_after_merge(
pr_number=pr_number,
head_sha=expected_head_sha,
merge_commit_sha=(
(merged or {}).get("merge_commit_sha")
if isinstance(merged, dict)
else None
),
remote=remote,
host=h,
org=o,
repo=r,
auth=auth,
)
result["review_decision_lock_reconcile"] = reconcile
result["review_decision_lock_cleared_after_merge"] = bool(
reconcile.get("cleared_any")
)
for line in reconcile.get("reason_lines") or []:
reasons.append(line)
except Exception as clear_exc: # noqa: BLE001 — never fail the merge
result["review_decision_lock_cleared_after_merge"] = False
result["review_decision_lock_clear_error"] = _redact(str(clear_exc))
try:
_record_post_merge_decision_recovery(
pr_number=pr_number,
head_sha=expected_head_sha,
merge_commit_sha=None,
target_profile_identity=None,
failed_step="reconcile_exception",
error=_redact(str(clear_exc)),
remote=remote,
org=o,
repo=r,
)
except Exception:
pass
reasons.append(f"all gates passed; merged PR #{pr_number} via '{do}'")
return result